diff --git a/src/all/torrentioanime/AndroidManifest.xml b/src/all/torrentioanime/AndroidManifest.xml new file mode 100644 index 0000000000..7161058d40 --- /dev/null +++ b/src/all/torrentioanime/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/src/all/torrentioanime/build.gradle b/src/all/torrentioanime/build.gradle new file mode 100644 index 0000000000..fe42d897d3 --- /dev/null +++ b/src/all/torrentioanime/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Torrentio Anime (Torrent / Debrid)' + extClass = '.Torrentio' + extVersionCode = 1 + containsNsfw = false +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/torrentioanime/res/mipmap-hdpi/ic_launcher.png b/src/all/torrentioanime/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..317a43c2d6 Binary files /dev/null and b/src/all/torrentioanime/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/torrentioanime/res/mipmap-mdpi/ic_launcher.png b/src/all/torrentioanime/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..a74feb073a Binary files /dev/null and b/src/all/torrentioanime/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/torrentioanime/res/mipmap-xhdpi/ic_launcher.png b/src/all/torrentioanime/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..2e4d185ce1 Binary files /dev/null and b/src/all/torrentioanime/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/torrentioanime/res/mipmap-xxhdpi/ic_launcher.png b/src/all/torrentioanime/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..b90706cfe7 Binary files /dev/null and b/src/all/torrentioanime/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/torrentioanime/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/torrentioanime/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..bba11b734f Binary files /dev/null and b/src/all/torrentioanime/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/torrentioanime/src/eu/kanade/tachiyomi/animeextension/all/torrentioanime/Torrentio.kt b/src/all/torrentioanime/src/eu/kanade/tachiyomi/animeextension/all/torrentioanime/Torrentio.kt new file mode 100644 index 0000000000..7e1e0d1cdf --- /dev/null +++ b/src/all/torrentioanime/src/eu/kanade/tachiyomi/animeextension/all/torrentioanime/Torrentio.kt @@ -0,0 +1,855 @@ +package eu.kanade.tachiyomi.animeextension.all.torrentioanime + +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 eu.kanade.tachiyomi.animeextension.all.torrentioanime.dto.AnilistMeta +import eu.kanade.tachiyomi.animeextension.all.torrentioanime.dto.AnilistMetaLatest +import eu.kanade.tachiyomi.animeextension.all.torrentioanime.dto.DetailsById +import eu.kanade.tachiyomi.animeextension.all.torrentioanime.dto.EpisodeList +import eu.kanade.tachiyomi.animeextension.all.torrentioanime.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.FormBody +import okhttp3.Request +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 Anime (Torrent / Debrid)" + + override val baseUrl = "https://torrentio.strem.fun" + + override val lang = "all" + + override val supportsLatest = true + + 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()) } + + // ============================== Anilist API Request =================== + private fun makeGraphQLRequest(query: String, variables: String): Request { + val requestBody = FormBody.Builder() + .add("query", query) + .add("variables", variables) + .build() + + return POST("https://graphql.anilist.co", body = requestBody) + } + + // ============================== Anilist Meta List ====================== + private fun anilistQuery(): String { + return """ + query (${"$"}page: Int, ${"$"}perPage: Int, ${"$"}sort: [MediaSort], ${"$"}search: String) { + Page(page: ${"$"}page, perPage: ${"$"}perPage) { + pageInfo{ + currentPage + hasNextPage + } + media(type: ANIME, sort: ${"$"}sort, search: ${"$"}search, status_in:[RELEASING,FINISHED]) { + id + title { + romaji + english + native + } + coverImage { + extraLarge + large + } + description + status + tags{ + name + } + genres + studios { + nodes { + name + } + } + countryOfOrigin + isAdult + } + } + } + """.trimIndent() + } + + private fun anilistLatestQuery(): String { + return """ + query (${"$"}page: Int, ${"$"}perPage: Int, ${"$"}sort: [AiringSort]) { + Page(page: ${"$"}page, perPage: ${"$"}perPage) { + pageInfo { + currentPage + hasNextPage + } + airingSchedules( + airingAt_greater: 0 + airingAt_lesser: ${System.currentTimeMillis() / 1000 - 10000} + sort: ${"$"}sort + ) { + media{ + id + title { + romaji + english + native + } + coverImage { + extraLarge + large + } + description + status + tags{ + name + } + genres + studios { + nodes { + name + } + } + countryOfOrigin + isAdult + } + } + } + } + """.trimIndent() + } + + private fun parseSearchJson(jsonLine: String?, isLatestQuery: Boolean = false): AnimesPage { + val jsonData = jsonLine ?: return AnimesPage(emptyList(), false) + val metaData: Any = if (!isLatestQuery) { + json.decodeFromString(jsonData) + } else { + json.decodeFromString(jsonData) + } + + val mediaList = when (metaData) { + is AnilistMeta -> metaData.data?.page?.media.orEmpty() + is AnilistMetaLatest -> metaData.data?.page?.airingSchedules.orEmpty().map { it.media } + else -> emptyList() + } + + val hasNextPage: Boolean = when (metaData) { + is AnilistMeta -> metaData.data?.page?.pageInfo?.hasNextPage ?: false + is AnilistMetaLatest -> metaData.data?.page?.pageInfo?.hasNextPage ?: false + else -> false + } + + val animeList = mediaList + .filterNot { (it?.countryOfOrigin == "CN" || it?.isAdult == true) && isLatestQuery } + .map { media -> + val anime = SAnime.create().apply { + url = media?.id.toString() + title = when (preferences.getString(PREF_TITLE_KEY, "romaji")) { + "romaji" -> media?.title?.romaji.toString() + "english" -> (media?.title?.english?.takeIf { it.isNotBlank() } ?: media?.title?.romaji).toString() + "native" -> media?.title?.native.toString() + else -> "" + } + thumbnail_url = media?.coverImage?.extraLarge + description = media?.description + ?.replace(Regex("

"), "\n") + ?.replace(Regex("<.*?>"), "") + ?: "No Description" + + status = when (media?.status) { + "RELEASING" -> SAnime.ONGOING + "FINISHED" -> SAnime.COMPLETED + "HIATUS" -> SAnime.ON_HIATUS + "NOT_YET_RELEASED" -> SAnime.LICENSED + else -> SAnime.UNKNOWN + } + + // Extracting tags + val tagsList = media?.tags?.mapNotNull { it.name }.orEmpty() + // Extracting genres + val genresList = media?.genres.orEmpty() + genre = (tagsList + genresList).toSet().sorted().joinToString() + + // Extracting studios + val studiosList = media?.studios?.nodes?.mapNotNull { it.name }.orEmpty() + author = studiosList.sorted().joinToString() + + initialized = true + } + anime + } + + return AnimesPage(animeList, hasNextPage) + } + + // ============================== Popular =============================== + override fun popularAnimeRequest(page: Int): Request { + val variables = """ + { + "page": $page, + "perPage": 30, + "sort": "TRENDING_DESC" + } + """.trimIndent() + + return makeGraphQLRequest(anilistQuery(), variables) + } + + override fun popularAnimeParse(response: Response): AnimesPage { + val jsonData = response.body.string() + return parseSearchJson(jsonData) } + + // =============================== Latest =============================== + override fun latestUpdatesRequest(page: Int): Request { + val variables = """ + { + "page": $page, + "perPage": 30, + "sort": "TIME_DESC" + } + """.trimIndent() + + return makeGraphQLRequest(anilistLatestQuery(), variables) + } + + override fun latestUpdatesParse(response: Response): AnimesPage { + val jsonData = response.body.string() + return parseSearchJson(jsonData, true) + } + + // =============================== 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")) + .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 variables = """ + { + "page": $page, + "perPage": 30, + "sort": "POPULARITY_DESC", + "search": "$query" + } + """.trimIndent() + + return makeGraphQLRequest(anilistQuery(), variables) + } + + override fun searchAnimeParse(response: Response) = popularAnimeParse(response) + // =========================== Anime Details ============================ + + override fun animeDetailsParse(response: Response): SAnime = throw UnsupportedOperationException() + + override suspend fun getAnimeDetails(anime: SAnime): SAnime { + val query = """ + query(${"$"}id: Int){ + Media(id: ${"$"}id){ + id + title { + romaji + english + native + } + coverImage { + extraLarge + large + } + description + status + tags{ + name + } + genres + studios { + nodes { + name + } + } + countryOfOrigin + isAdult + } + } + """.trimIndent() + + val variables = """{"id": ${anime.url}}""" + + val metaData = runCatching { + json.decodeFromString(client.newCall(makeGraphQLRequest(query, variables)).execute().body.string()) + }.getOrNull()?.data?.media + + anime.title = metaData?.title?.let { title -> + when (preferences.getString(PREF_TITLE_KEY, "romaji")) { + "romaji" -> title.romaji + "english" -> (metaData.title.english?.takeIf { it.isNotBlank() } ?: metaData.title.romaji).toString() + "native" -> title.native + else -> "" + } + } ?: "" + + anime.thumbnail_url = metaData?.coverImage?.extraLarge + anime.description = metaData?.description + ?.replace(Regex("

"), "\n") + ?.replace(Regex("<.*?>"), "") + ?: "No Description" + + anime.status = when (metaData?.status) { + "RELEASING" -> SAnime.ONGOING + "FINISHED" -> SAnime.COMPLETED + "HIATUS" -> SAnime.ON_HIATUS + "NOT_YET_RELEASED" -> SAnime.LICENSED + else -> SAnime.UNKNOWN + } + + // Extracting tags, genres, and studios + val tagsList = metaData?.tags?.mapNotNull { it.name } ?: emptyList() + val genresList = metaData?.genres ?: emptyList() + val studiosList = metaData?.studios?.nodes?.mapNotNull { it.name } ?: emptyList() + + anime.genre = (tagsList + genresList).toSet().sorted().joinToString() + anime.author = studiosList.sorted().joinToString() + + return anime + } + + // ============================== Episodes ============================== + override fun episodeListRequest(anime: SAnime): Request { + return GET("https://anime-kitsu.strem.fun/meta/series/anilist%3A${anime.url}.json") + } + + override fun episodeListParse(response: Response): List { + val responseString = response.body.string() + val episodeList = json.decodeFromString(responseString) + + return when (episodeList.meta?.type) { + "series" -> { + episodeList.meta.videos?.filter { video -> + (video.released?.let { parseDate(it) } ?: 0L) <= System.currentTimeMillis() + }?.map { video -> + SEpisode.create().apply { + episode_number = video.episode?.toFloat() ?: 0.0F + url = "/stream/series/${video.videoId}.json" + date_upload = video.released?.let { parseDate(it) } ?: 0L + name = "Episode ${video.episode} : ${ + video.title?.removePrefix("Episode ") + ?.replaceFirst("\\d+\\s*".toRegex(), "") + ?.trim() + }" + } + }.orEmpty().reversed() + } + + "movie" -> { + // Handle movie response + val movieId = episodeList.meta.kitsuId?.substringAfterLast(":")?.toIntOrNull() ?: 0 + listOf( + SEpisode.create().apply { + episode_number = 1.0F + url = "/stream/movie/$movieId.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, null) + + when { + token.isNullOrBlank() && debridProvider != "none" -> { + handler.post { + context.let { + Toast.makeText( + it, + "Kindly input the 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