diff --git a/src/all/torrentio/AndroidManifest.xml b/src/all/torrentio/AndroidManifest.xml new file mode 100644 index 0000000000..360fba20a4 --- /dev/null +++ b/src/all/torrentio/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/src/all/torrentio/build.gradle b/src/all/torrentio/build.gradle new file mode 100644 index 0000000000..9c35d4543d --- /dev/null +++ b/src/all/torrentio/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Torrentio (Torrent / Debrid)' + extClass = '.Torrentio' + extVersionCode = 1 + containsNsfw = false +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/torrentio/res/mipmap-hdpi/ic_launcher.png b/src/all/torrentio/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..317a43c2d6 Binary files /dev/null and b/src/all/torrentio/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/torrentio/res/mipmap-mdpi/ic_launcher.png b/src/all/torrentio/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..a74feb073a Binary files /dev/null and b/src/all/torrentio/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/torrentio/res/mipmap-xhdpi/ic_launcher.png b/src/all/torrentio/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..2e4d185ce1 Binary files /dev/null and b/src/all/torrentio/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/torrentio/res/mipmap-xxhdpi/ic_launcher.png b/src/all/torrentio/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..b90706cfe7 Binary files /dev/null and b/src/all/torrentio/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/torrentio/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/torrentio/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..bba11b734f Binary files /dev/null and b/src/all/torrentio/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/torrentio/src/eu/kanade/tachiyomi/animeextension/all/torrentio/Torrentio.kt b/src/all/torrentio/src/eu/kanade/tachiyomi/animeextension/all/torrentio/Torrentio.kt new file mode 100644 index 0000000000..a50dc6b302 --- /dev/null +++ b/src/all/torrentio/src/eu/kanade/tachiyomi/animeextension/all/torrentio/Torrentio.kt @@ -0,0 +1,867 @@ +package eu.kanade.tachiyomi.animeextension.all.torrentio + +import android.app.Application +import android.content.SharedPreferences +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import androidx.preference.EditTextPreference +import androidx.preference.ListPreference +import androidx.preference.MultiSelectListPreference +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat +import eu.kanade.tachiyomi.animeextension.all.torrentio.dto.EpisodeList +import eu.kanade.tachiyomi.animeextension.all.torrentio.dto.GetPopularTitlesResponse +import eu.kanade.tachiyomi.animeextension.all.torrentio.dto.GetUrlTitleDetailsResponse +import eu.kanade.tachiyomi.animeextension.all.torrentio.dto.StreamDataTorrent +import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource +import eu.kanade.tachiyomi.animesource.model.AnimeFilterList +import eu.kanade.tachiyomi.animesource.model.AnimesPage +import eu.kanade.tachiyomi.animesource.model.SAnime +import eu.kanade.tachiyomi.animesource.model.SEpisode +import eu.kanade.tachiyomi.animesource.model.Video +import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.awaitSuccess +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.Locale + +class Torrentio : ConfigurableAnimeSource, AnimeHttpSource() { + + override val name = "Torrentio (Torrent / Debrid)" + + override val baseUrl = "https://torrentio.strem.fun" + + override val lang = "all" + + override val supportsLatest = false + + private val json: Json by injectLazy() + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + private val context = Injekt.get() + private val handler by lazy { Handler(Looper.getMainLooper()) } + + // ============================== JustWatch API Request =================== + private fun makeGraphQLRequest(query: String, variables: String): Request { + val requestBody = """ + {"query": "${query.replace("\n", "")}", "variables": $variables} + """.trimIndent().toRequestBody("application/json; charset=utf-8".toMediaType()) + + return POST("https://apis.justwatch.com/graphql", headers = headers, body = requestBody) + } + + // ============================== JustWatch Api Query ====================== + private fun justWatchQuery(): String { + return """ + query GetPopularTitles( + ${"$"}country: Country!, + ${"$"}first: Int!, + ${"$"}language: Language!, + ${"$"}offset: Int, + ${"$"}searchQuery: String, + ${"$"}packages: [String!]!, + ${"$"}objectTypes: [ObjectType!]!, + ${"$"}popularTitlesSortBy: PopularTitlesSorting!, + ${"$"}releaseYear: IntFilter + ) { + popularTitles( + country: ${"$"}country + first: ${"$"}first + offset: ${"$"}offset + sortBy: ${"$"}popularTitlesSortBy + filter: { + objectTypes: ${"$"}objectTypes, + searchQuery: ${"$"}searchQuery, + packages: ${"$"}packages, + genres: [], + excludeGenres: [], + releaseYear: ${"$"}releaseYear + } + ) { + edges { + node { + id + objectType + content(country: ${"$"}country, language: ${"$"}language) { + fullPath + title + shortDescription + externalIds { + imdbId + } + posterUrl + genres { + translation(language: ${"$"}language) + } + credits { + name + role + } + } + } + } + pageInfo { + hasPreviousPage + hasNextPage + } + } + } + """.trimIndent() + } + + private fun parseSearchJson(jsonLine: String?): AnimesPage { + val jsonData = jsonLine ?: return AnimesPage(emptyList(), false) + val popularTitlesResponse = json.decodeFromString(jsonData) + + val edges = popularTitlesResponse.data?.popularTitles?.edges.orEmpty() + val hasNextPage = popularTitlesResponse.data?.popularTitles?.pageInfo?.hasNextPage ?: false + + val metaList = edges + .mapNotNull { edge -> + val node = edge.node ?: return@mapNotNull null + val content = node.content ?: return@mapNotNull null + + SAnime.create().apply { + url = "${content.externalIds?.imdbId ?: ""},${node.objectType ?: ""},${content.fullPath ?: ""}" + title = content.title ?: "" + thumbnail_url = "https://images.justwatch.com${content.posterUrl?.replace("{profile}", "s276")?.replace("{format}", "webp")}" + description = content.shortDescription ?: "" + val genresList = content.genres?.mapNotNull { it.translation }.orEmpty() + genre = genresList.joinToString() + + val directors = content.credits?.filter { it.role == "DIRECTOR" }?.mapNotNull { it.name } + author = directors?.joinToString() + val actors = content.credits?.filter { it.role == "ACTOR" }?.take(4)?.mapNotNull { it.name } + artist = actors?.joinToString() + initialized = true + } + } + + return AnimesPage(metaList, hasNextPage) + } + + // ============================== Popular =============================== + override fun popularAnimeRequest(page: Int): Request { + return searchAnimeRequest(page, "", AnimeFilterList()) + } + + override fun popularAnimeParse(response: Response): AnimesPage { + val jsonData = response.body.string() + return parseSearchJson(jsonData) } + + // =============================== Latest =============================== + override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException() + + override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException() + + // =============================== Search =============================== + override suspend fun getSearchAnime(page: Int, query: String, filters: AnimeFilterList): AnimesPage { + return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler + val id = query.removePrefix(PREFIX_SEARCH) + client.newCall(GET("$baseUrl/anime/$id", headers)) + .awaitSuccess() + .use(::searchAnimeByIdParse) + } else { + super.getSearchAnime(page, query, filters) + } + } + + private fun searchAnimeByIdParse(response: Response): AnimesPage { + val details = animeDetailsParse(response) + return AnimesPage(listOf(details), false) + } + + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + val country = preferences.getString(PREF_REGION_KEY, PREF_REGION_DEFAULT) + val language = preferences.getString(PREF_JW_LANG_KEY, PREF_JW_LANG_DEFAULT) + val perPage = 40 + val packages = "" + val year = 0 + val objectTypes = "" + val variables = """ + { + "first": $perPage, + "offset": ${(page - 1) * perPage}, + "platform": "WEB", + "country": "$country", + "language": "$language", + "searchQuery": "${query.replace(searchQueryRegex, "").trim()}", + "packages": [$packages], + "objectTypes": [$objectTypes], + "popularTitlesSortBy": "TRENDING", + "releaseYear": { + "min": $year, + "max": $year + } + } + """.trimIndent() + + return makeGraphQLRequest(justWatchQuery(), variables) + } + + private val searchQueryRegex by lazy { + Regex("[^A-Za-z0-9 ]") + } + + override fun searchAnimeParse(response: Response) = popularAnimeParse(response) + + // =========================== Anime Details ============================ + + override fun animeDetailsParse(response: Response): SAnime = throw UnsupportedOperationException() + + // override suspend fun getAnimeDetails(anime: SAnime): SAnime = throw UnsupportedOperationException() + + override suspend fun getAnimeDetails(anime: SAnime): SAnime { + val query = """ + query GetUrlTitleDetails(${"$"}fullPath: String!, ${"$"}country: Country!, ${"$"}language: Language!) { + urlV2(fullPath: ${"$"}fullPath) { + node { + ...TitleDetails + } + } + } + + fragment TitleDetails on Node { + ... on MovieOrShowOrSeason { + id + objectType + content(country: ${"$"}country, language: ${"$"}language) { + title + shortDescription + externalIds { + imdbId + } + posterUrl + genres { + translation(language: ${"$"}language) + } + } + } + } + """.trimIndent() + + val country = preferences.getString(PREF_REGION_KEY, PREF_REGION_DEFAULT) + val language = preferences.getString(PREF_JW_LANG_KEY, PREF_JW_LANG_DEFAULT) + val variables = """ + { + "fullPath": "${anime.url.split(',').last()}", + "country": "$country", + "language": "$language" + } + """.trimIndent() + + val content = runCatching { + json.decodeFromString(client.newCall(makeGraphQLRequest(query, variables)).execute().body.string()) + }.getOrNull()?.data?.urlV2?.node?.content + + anime.title = content?.title ?: "" + anime.thumbnail_url = "https://images.justwatch.com${content?.posterUrl?.replace("{profile}", "s718")?.replace("{format}", "webp")}" + anime.description = content?.shortDescription ?: "" + val genresList = content?.genres?.mapNotNull { it.translation }.orEmpty() + anime.genre = genresList.joinToString() + + return anime + } + + // ============================== Episodes ============================== + override fun episodeListRequest(anime: SAnime): Request { + val parts = anime.url.split(",") + val type = parts[1].lowercase() + val imdbId = parts[0] + return GET("https://cinemeta-live.strem.io/meta/$type/$imdbId.json") + } + + override fun episodeListParse(response: Response): List { + val responseString = response.body.string() + val episodeList = json.decodeFromString(responseString) + return when (episodeList.meta?.type) { + "show" -> { + episodeList.meta.videos + ?.let { videos -> + if (preferences.getBoolean(UPCOMING_EP_KEY, UPCOMING_EP_DEFAULT)) { videos } else { videos.filter { video -> (video.firstAired?.let { parseDate(it) } ?: 0L) <= System.currentTimeMillis() } } + } + ?.map { video -> + SEpisode.create().apply { + episode_number = "${video.season}.${video.number}".toFloat() + url = "/stream/series/${video.id}.json" + date_upload = video.firstAired?.let { parseDate(it) } ?: 0L + name = "S${video.season.toString().trim()}:E${video.number} - ${video.name}" + scanlator = (video.firstAired?.let { parseDate(it) } ?: 0L) + .takeIf { it > System.currentTimeMillis() } + ?.let { "Upcoming" } + ?: "" + } + } + ?.sortedWith( + compareBy { it.name.substringAfter("S").substringBefore(":").toInt() } + .thenBy { it.name.substringAfter("E").substringBefore(" -").toInt() }, + ) + .orEmpty().reversed() + } + + "movie" -> { + // Handle movie response + listOf( + SEpisode.create().apply { + episode_number = 1.0F + url = "/stream/movie/${episodeList.meta.id}.json" + name = "Movie" + }, + ).reversed() + } + + else -> emptyList() + } + } + private fun parseDate(dateStr: String): Long { + return runCatching { DATE_FORMATTER.parse(dateStr)?.time } + .getOrNull() ?: 0L + } + + // ============================ Video Links ============================= + + override fun videoListRequest(episode: SEpisode): Request { + val mainURL = buildString { + append("$baseUrl/") + + val appendQueryParam: (String, Set?) -> Unit = { key, values -> + values?.takeIf { it.isNotEmpty() }?.let { + append("$key=${it.filter(String::isNotBlank).joinToString(",")}|") + } + } + + appendQueryParam("providers", preferences.getStringSet(PREF_PROVIDER_KEY, PREF_PROVIDERS_DEFAULT)) + appendQueryParam("language", preferences.getStringSet(PREF_LANG_KEY, PREF_LANG_DEFAULT)) + appendQueryParam("qualityfilter", preferences.getStringSet(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)) + + val sortKey = preferences.getString(PREF_SORT_KEY, "quality") + appendQueryParam("sort", sortKey?.let { setOf(it) }) + + val token = preferences.getString(PREF_TOKEN_KEY, null) + val debridProvider = preferences.getString(PREF_DEBRID_KEY, "none") + + when { + token.isNullOrBlank() && debridProvider != "none" -> { + handler.post { + context.let { + Toast.makeText( + it, + "Kindly input the debrid token in the extension settings.", + Toast.LENGTH_LONG, + ).show() + } + } + throw UnsupportedOperationException() + } + !token.isNullOrBlank() && debridProvider != "none" -> append("$debridProvider=$token|") + } + append(episode.url) + }.removeSuffix("|") + return GET(mainURL) + } + + override fun videoListParse(response: Response): List