tsg&1c6E2dpFMy6TyzPT0?YJ%!r#WGbhD5KqAX6)mr$D6*qnHolWk7}u;
zoSNoETC*L;XHwshM-XN_Qim`oVXi4}EdM;W)GJ3{*jwp--+P_SY#-L8Pe}jCHKc=0
z=wh8lHS!${#h?`)ln|RShX46a(Ur@XS!>CB*_j3^tH{mCX*2bzsYPtd*sb-uXx(4U
z@Z8xA3vMIosPs_D=H}**ggTw%a^!`*NybTEos09aS%m=gpPaV-tE;PZU2h3Ljd$f|
zB928j85=9T!^baOw2bncvWtu$RYnm^58QNKYE4
z%r_#~^j7c}ivT`1BJzZWJ%d_35ri)5#=Eg{_AaCGs@|jiXb`FGXhd`Az`N|#FQ7w?
zvdnmSa;vAWU&!~>|6UCtI}M>4u#z#8s#Qx0O(YY0TL!tBJwHExGMp#AJM6gQ9Tb!`
z%$Ap%+tF!Txc+ERIYg>NfV!!P7lbuk0}QCVgoK1tJKmfVvRCoCX@UKGtO$ySQ&ae=
zowyMEiT6F|?$E;isy0deAaRr{npXZ;3o_htHvnj9X$`2os%fBn5Gr}wt?lq;S9Q;-9
zT8BAe$M=eJ(NCmkvw4-rZ~b7Cv43vZS{Kw9Kso=7sO%PaL20Sd4M{nKuwqvF|=h}54?
zK_>0+3E@I(lC?hVb|TDH;eP?I9ybt%afxkLC#@3;B3e~9d<(=okN~aL)Tq(pcjA
z+TD+L5YGLnxtQ^@;Pki5X#rf;qe{q>WjcfZOoL-3Z{(sF^pw?D(NZpak}dp4_XF(F
z@4P4jv$EreKZ!a;*MrNa8`m^1rPCOY^C7qC{z3P64`C{43KtLkPv^iZ2Leux1w
zs;z+_<`c=g_lsh=
z?1HY_j}PFR)A~@AmnzHS_+b!(XG$Vl`0yx$lN4C$n_^G`K1(-O`?3`7}0ha3}@nYQ2g)a}t
zMv)dnx;>|393f{9-Bqk!u>}8Ag~y~0en`Q-VXk4P!P!OiywWsmo^D>BytIRLl?^yB
z!ebjwtol>8Y*xhe*HQ%$FX+l77{9KlK9W=(>9Mb-P$HwnQxZMkU30AkRT1FJI?*nN
zI-g1hG+3|<^^&NpRVCB%Dp*#4st8N-YZHTrI-X7%6)kug1fH-fkxM7%vI{90_|4lA
zP!VRf<@9ao`xeWm+R;8Vv^Z28j>k&Qs0-eXU`QSpo8ZN!XA@eXhGj1QX{nJ?I^k0a
zx=ud6hmX0GDF*pDQFav|59mls^B#`TD`Ta-hjS#g92KKT9XZde-5~Kc$CU$am9p)N
zLQM!?{1oSV_!A{l3yO5Y+1c!-F`
z(INO~R(Nvy`LRusoSMZQc*->TxWaqcl-?MxfA#B!pOLb1%U=ARakiYYIBMPqz;rG7
z$4UA(q~Os5`_@Z$SADQZsq}e7GU)~ct1u(P>#EA!@?NAElp|92wv7|>;9UXb0q7!a
ze+9QN%GfouPqbu7?W&gz?>A5y|HSSuo2bg$lN$NM&}CC~f(}+i0v%_Ne7hjesiF#7lrIl0`tMODJgUH}!ZUVE)SO>hnNh5pSoWohk6>
zLr{{jo5TgNs|CPz?ugmJ$XnVsYb#$rBB0XWobU+fuP!upyGVPTw$GDhhv&DPXoGS8
zwWxovPz@rE|H$N~Y9DD`HYp&FSx#^<1NC&_jLX>Juf;D&r-SH^q++NF1#T?>^gn-8<8Z%LqHTr8tKH%&5wD|Nh?yMvB_GJsCrGm;^S|)HG?O1kD
z{v$6RZ=266h#1Ma+Un@XPnHa7@QU^(>94;!dyopb82tXWkgEyD_T)0tL6z#LEN94~
zjMhuAp{4p4&N_-WJ^q=@6bk(~l_3@@TT@&Lgq
z0bNkd!rAAvonp}T>*
zyI!5mJP3PhswXgi?u#GT;{i*Rjbk&d7+{cLm^=cLA)4Sp7eWbd6HR2hIur`#MP0+B
z&UINgwP?LSNG^nx9nBx{S)<)7_0u6tu-@jS$J`Fv@w|Y-pGr;#sc+Lq$HuklEa2j#
zt?VM~I{TpAmXs!@E0&HncB-KJ2gOSk
zvY59f!I?ndlUgS5Op*3`9>){sh-4-L;F&z)Tgb;?HDlTLn!KNc5s6)_QAb0y>=}!XV>vQH
zWZ$;1Ezu$D70b}<2Tg+Avh3v}631slq^?%K0Pd6NQ`
zntPO76iqvBv|irdR0yajpRf%zJdj6e=e9HwqWHQ%8l_r>+gs69z~DXG
z2R@W?ckWFHkMQ_33ju_Nmb7fd^%ku*YhfK$IIH9==<~l{rpHQNXE{&Q`eSygvsgI!^cw!Kbi$XImK0D&W8dk!AG7hHKmdiPN*#
z$FNp|Nb`tKx8~+}2zDlzY`dM@$_dUDK7GF`$cB^j&D4RpO+{@DAS|RFaIQPR@&4x6
z!FSY47C~A}-aE4MEaF~16D;DY3Q%CsuR}dJum6>~%TN#&IK==j{imHuAq(|v+9DcV
z_=d#wn>{D3AVRS$!b5f@s8iiaLk@R8WB+aNNfn-yiWV`*Ia9bsEd^iN6Exllo8tI!
zLR|T1(zu(Ub=_nS;fwzM`pv*5qwe8{%g`#o_J@o?PE=bo(b%k%MBm!mmzEU+l~MLZ
zcdW_+7bfizTu0nROaZ6H`7esP0A-)4Ck)1=i^7+;U+!C~`+l9TT{mZr7dv3O(zGLk
zpX8SFmUT$Qn*de3;bjw5{RIm@s9`K4r#WpREoOn)RLLFUv3MTb%{IjQ&5Nbv>`bwT
zvk#f`PvP{C0N>@^#8GdFN8|G!Ij|li6IOI)m;Q3Smtj$-K?A2irHuiSjeKqN19yDO
zex&t00~N&`>P*>k!Vm4&-AsX?^u_xW9~=1@2*#-ktl2-@Q5v*x8c6l^iHtmpFvXJw
z}8;NO^isp=&7cEN)TJbnoi@ophosc8+CPlo=U4bcpUVZOiK87R1PEy07;V
zdp>^AXE|(aVKX#_u`f~VFm~t*Sh)(wiyAEJA6x?m?#xyy_HpdLDz7JW*vN!hxPKo{
z6_dZ3F~pOmSb&7N#`k=QGI}iUQxag%^2J2hz?YbHvt>Z6ci*vPG?kQ#CMz-ENatfX
z0o+|Nvvg!Tx7z{+-H?K3M{f|(LtV<8G}{W;`TYn#BOZ#)!7?ok(ip~s!YFHwzwy9b
zyB^NXIpGA6{;oGgQ<#aCB4bY9G;R75YxML5>2}H`9OcZAgeBzU6Yr?QossALGl~K6
zgLh}b3^`lL4!@xnikr3U$4pu}dl|2=cL#n_;C3N_jPM1Mc(X~h>Ib!ceWC-e@j_Yj
zsuI`Xc2swJm+Zl#EKOXhVys|T1xjGG{}mtEL-){rW8kI1e3pBC!O@s~nGvMUn?++8
z{kY~TEd-u6D43c=TFfq9kVj!V^p)v2W+}D7;p49-4_ofTc+zB$TGll^XoWGUuv8Ds
zkKj^VGWW@~+3zyv!R6$iv$sO`LGe)~y?;|t1=DA;UjonD@7_T|t+i-9$XF+rzr~z=
z<=f6us4PorrZxq9H23-rJftpFs^K%L6c9@Zg?6X?FSYUIeedQteegdP{C?UYyFL)|
zUlQpjOH2&i&@CKW5uVFg$^AEv)FO{<3`cTcOk^A3;>FVEBd#!=hqM!<*#@7GrGtv?
zjF{~;xMs^hjrbUTSX1d5ni0z(h#0=da7a1&4*6`Fzv`C;YLw{v9u5BEP*
zd_51RN{l~k{K%CzN~VCF
zcGGH->I{-h6_DbO_aK^|vx0f0um54a9ldYc#kh-}vsl+R1#~Km@C0%-2#Q_uj;Q;Q
zNMz-Wnvf28nGG=5D4sXHg3^)Trcad~2&DooK&L%e$azn8^ol
z7R8lYXr7B(JbN=qtecsci3xQzlPora@Cn+Z*R{T3V8d?>tNx$U8CvOdA^tNLrJ~#F
zZjxjd$L!=-7kwkoN0SG}ztcN!i)c-qqHcSSP&WW$7a{>;^_|G|J
z5=JJj62fb7)KESf{bHP)8$}~}fx$Siv$Kcmn?@K!^xjp@BIkC!y-erPOo-Y^AaRai
zI^8O#ksqdNQq!ZhsfJ&|ex#6`hLEtj|&)#dqvz`cSMkM)ZaBO)5<UPi>Woxz6y}bL?
z**TLa*Wv|dWG*fKFi7H&Hx%Rw%W&5ZU#%fEjPfB(`eQ(2Z%|52&{V&aW|Kj#tmws55pM(
().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