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