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