From 5563a4aa9ddce655d9930ff02f43a65ba330c3e8 Mon Sep 17 00:00:00 2001 From: Swissky <12152583+swisskyrepo@users.noreply.github.com> Date: Sat, 22 Jun 2024 00:07:00 +0200 Subject: [PATCH] repo.json force ci button to force ci No build yet branch build is here Test update homepage test NaN for mainpage test1 fallback Homepage1 search 1 Add type in search load part1 load part2 fix sourceIds fix document + import jsoup force movie type debug data str Enable logs Log fix import qulities custom logging log1 sourceIds debug 1 force type of sourceIds array element fix map sourceIds parse links fix json struct Fix JSOn struct Update json json1 json2 json3 json4 json5 data was string instead of list wrong url extractor 1 update callback extractor2 extract doodre var new var episode season episodes logging mixdrop fix upstream v1 Fix package Fix log ref upstream first regex upstr fix conflict confl2 conf3 conf5 conf6 conf7 fix regex adding more plugins Adding code from repositories Limit build aniemtv Second build Add kotlinx Enabling two providers Anywave Aniwave Update more fix Cronch KronchEN Superstream with secrets env perms perm read Create test.yml Update test.yml Update test.yml Update test.yml set env env23 write implies read Upgrade --- .github/workflows/build.yml | 36 +- .gitignore | 5 +- 9anime/9anime.kt | 307 ++ Aniwave/build.gradle.kts | 41 + Aniwave/src/main/AndroidManifest.xml | 2 + .../main/kotlin/com/RowdyAvocado/Aniwave.kt | 475 ++++ .../kotlin/com/RowdyAvocado/AniwavePlugin.kt | 71 + .../kotlin/com/RowdyAvocado/AniwaveUtils.kt | 109 + .../kotlin/com/RowdyAvocado/BottomSheet.kt | 117 + .../kotlin/com/RowdyAvocado/JsInterceptor.kt | 152 + .../com/RowdyAvocado/JsVrfInterceptor.kt | 106 + Aniwave/src/main/res/drawable/outline.xml | 19 + Aniwave/src/main/res/drawable/save_icon.xml | 10 + .../main/res/layout/bottom_sheet_layout.xml | 102 + Aniwave/src/main/res/layout/radio_button.xml | 15 + .../kotlin/com/example/ExampleProvider.kt | 22 - HiAnime/build.gradle.kts | 26 + HiAnime/src/main/AndroidManifest.xml | 2 + .../main/kotlin/com/RowdyAvocado/HiAnime.kt | 312 ++ .../kotlin/com/RowdyAvocado/HiAnimePlugin.kt | 12 + .../kotlin/com/RowdyAvocado/RabbitStream.kt | 871 ++++++ Onstream/TODO | 0 README.md | 45 +- {ExampleProvider => Sflix}/build.gradle.kts | 7 +- .../src/main/AndroidManifest.xml | 0 .../main/kotlin/com/example/BlankFragment.kt | 0 .../main/kotlin/com/example/SflixPlugin.kt | 14 + .../main/kotlin/com/example/SflixProvider.kt | 277 ++ .../kotlin/com/example/UpstreamExtractor.kt | 67 + .../src/main/res/drawable/ic_android_24dp.xml | 0 .../src/main/res/layout/fragment_blank.xml | 0 .../src/main/res/values-pl/strings.xml | 0 .../src/main/res/values/strings.xml | 0 SoraStream/Icon.png | Bin 0 -> 41051 bytes SoraStream/build.gradle.kts.todo | 46 + SoraStream/ci.yml | 103 + SoraStream/src/main/AndroidManifest.xml | 2 + .../src/main/kotlin/com/hexated/Extractors.kt | 528 ++++ .../main/kotlin/com/hexated/SoraExtractor.kt | 2523 +++++++++++++++++ .../src/main/kotlin/com/hexated/SoraParser.kt | 483 ++++ .../src/main/kotlin/com/hexated/SoraStream.kt | 854 ++++++ .../main/kotlin/com/hexated/SoraStreamLite.kt | 347 +++ .../kotlin/com/hexated/SoraStreamPlugin.kt | 40 + .../src/main/kotlin/com/hexated/SoraUtils.kt | 1569 ++++++++++ Superstream/build.gradle.kts | 42 + Superstream/src/main/AndroidManifest.xml | 2 + .../src/main/kotlin/com/hexated/Extractors.kt | 243 ++ .../main/kotlin/com/hexated/Superstream.kt | 823 ++++++ .../kotlin/com/hexated/SuperstreamPlugin.kt | 14 + ZoroTV/TODO | 0 ZoroTV/zoro.kt | 366 +++ build.gradle.kts | 5 +- repo.json | 8 + settings.gradle.kts | 24 +- 54 files changed, 11192 insertions(+), 52 deletions(-) create mode 100644 9anime/9anime.kt create mode 100644 Aniwave/build.gradle.kts create mode 100644 Aniwave/src/main/AndroidManifest.xml create mode 100644 Aniwave/src/main/kotlin/com/RowdyAvocado/Aniwave.kt create mode 100644 Aniwave/src/main/kotlin/com/RowdyAvocado/AniwavePlugin.kt create mode 100644 Aniwave/src/main/kotlin/com/RowdyAvocado/AniwaveUtils.kt create mode 100644 Aniwave/src/main/kotlin/com/RowdyAvocado/BottomSheet.kt create mode 100644 Aniwave/src/main/kotlin/com/RowdyAvocado/JsInterceptor.kt create mode 100644 Aniwave/src/main/kotlin/com/RowdyAvocado/JsVrfInterceptor.kt create mode 100644 Aniwave/src/main/res/drawable/outline.xml create mode 100644 Aniwave/src/main/res/drawable/save_icon.xml create mode 100644 Aniwave/src/main/res/layout/bottom_sheet_layout.xml create mode 100644 Aniwave/src/main/res/layout/radio_button.xml delete mode 100644 ExampleProvider/src/main/kotlin/com/example/ExampleProvider.kt create mode 100644 HiAnime/build.gradle.kts create mode 100644 HiAnime/src/main/AndroidManifest.xml create mode 100644 HiAnime/src/main/kotlin/com/RowdyAvocado/HiAnime.kt create mode 100644 HiAnime/src/main/kotlin/com/RowdyAvocado/HiAnimePlugin.kt create mode 100644 HiAnime/src/main/kotlin/com/RowdyAvocado/RabbitStream.kt create mode 100644 Onstream/TODO rename {ExampleProvider => Sflix}/build.gradle.kts (91%) rename {ExampleProvider => Sflix}/src/main/AndroidManifest.xml (100%) rename {ExampleProvider => Sflix}/src/main/kotlin/com/example/BlankFragment.kt (100%) rename ExampleProvider/src/main/kotlin/com/example/ExamplePlugin.kt => Sflix/src/main/kotlin/com/example/SflixPlugin.kt (62%) create mode 100644 Sflix/src/main/kotlin/com/example/SflixProvider.kt create mode 100644 Sflix/src/main/kotlin/com/example/UpstreamExtractor.kt rename {ExampleProvider => Sflix}/src/main/res/drawable/ic_android_24dp.xml (100%) rename {ExampleProvider => Sflix}/src/main/res/layout/fragment_blank.xml (100%) rename {ExampleProvider => Sflix}/src/main/res/values-pl/strings.xml (100%) rename {ExampleProvider => Sflix}/src/main/res/values/strings.xml (100%) create mode 100644 SoraStream/Icon.png create mode 100644 SoraStream/build.gradle.kts.todo create mode 100644 SoraStream/ci.yml create mode 100644 SoraStream/src/main/AndroidManifest.xml create mode 100644 SoraStream/src/main/kotlin/com/hexated/Extractors.kt create mode 100644 SoraStream/src/main/kotlin/com/hexated/SoraExtractor.kt create mode 100644 SoraStream/src/main/kotlin/com/hexated/SoraParser.kt create mode 100644 SoraStream/src/main/kotlin/com/hexated/SoraStream.kt create mode 100644 SoraStream/src/main/kotlin/com/hexated/SoraStreamLite.kt create mode 100644 SoraStream/src/main/kotlin/com/hexated/SoraStreamPlugin.kt create mode 100644 SoraStream/src/main/kotlin/com/hexated/SoraUtils.kt create mode 100644 Superstream/build.gradle.kts create mode 100644 Superstream/src/main/AndroidManifest.xml create mode 100644 Superstream/src/main/kotlin/com/hexated/Extractors.kt create mode 100644 Superstream/src/main/kotlin/com/hexated/Superstream.kt create mode 100644 Superstream/src/main/kotlin/com/hexated/SuperstreamPlugin.kt create mode 100644 ZoroTV/TODO create mode 100644 ZoroTV/zoro.kt create mode 100644 repo.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 02b75c9..4481f1a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,17 +6,22 @@ concurrency: cancel-in-progress: true on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + push: branches: - # choose your default branch - master - main paths-ignore: - '*.md' +permissions: write-all + jobs: build: runs-on: ubuntu-latest + environment: env steps: - name: Checkout uses: actions/checkout@master @@ -40,6 +45,35 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v2 + - name: Access Secrets + env: + TMDB_API: ${{ secrets.TMDB_API }} + DUMP_API: ${{ secrets.DUMP_API }} + DUMP_KEY: ${{ secrets.DUMP_KEY }} + CRUNCHYROLL_BASIC_TOKEN: ${{ secrets.CRUNCHYROLL_BASIC_TOKEN }} + CRUNCHYROLL_REFRESH_TOKEN: ${{ secrets.CRUNCHYROLL_REFRESH_TOKEN }} + SFMOVIES_API: ${{ secrets.SFMOVIES_API }} + CINEMATV_API: ${{ secrets.CINEMATV_API }} + GHOSTX_API: ${{ secrets.GHOSTX_API }} + SUPERSTREAM_FIRST_API: ${{ secrets.SUPERSTREAM_FIRST_API }} + SUPERSTREAM_SECOND_API: ${{ secrets.SUPERSTREAM_SECOND_API }} + SUPERSTREAM_THIRD_API: ${{ secrets.SUPERSTREAM_THIRD_API }} + SUPERSTREAM_FOURTH_API: ${{ secrets.SUPERSTREAM_FOURTH_API }} + run: | + cd $GITHUB_WORKSPACE/src + echo TMDB_API=$TMDB_API >> local.properties + echo DUMP_API=$DUMP_API >> local.properties + echo DUMP_KEY=$DUMP_KEY >> local.properties + echo CRUNCHYROLL_BASIC_TOKEN=$CRUNCHYROLL_BASIC_TOKEN >> local.properties + echo CRUNCHYROLL_REFRESH_TOKEN=$CRUNCHYROLL_REFRESH_TOKEN >> local.properties + echo SFMOVIES_API=$SFMOVIES_API >> local.properties + echo CINEMATV_API=$CINEMATV_API >> local.properties + echo GHOSTX_API=$GHOSTX_API >> local.properties + echo SUPERSTREAM_FIRST_API=$SUPERSTREAM_FIRST_API >> local.properties + echo SUPERSTREAM_SECOND_API=$SUPERSTREAM_SECOND_API >> local.properties + echo SUPERSTREAM_THIRD_API=$SUPERSTREAM_THIRD_API >> local.properties + echo SUPERSTREAM_FOURTH_API=$SUPERSTREAM_FOURTH_API >> local.properties + - name: Build Plugins run: | cd $GITHUB_WORKSPACE/src diff --git a/.gitignore b/.gitignore index 41fd579..d3e0f60 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,7 @@ .externalNativeBuild .cxx local.properties -.vscode \ No newline at end of file +.vscode +/NOTES +NOTES.md +scrapping.py \ No newline at end of file diff --git a/9anime/9anime.kt b/9anime/9anime.kt new file mode 100644 index 0000000..e535272 --- /dev/null +++ b/9anime/9anime.kt @@ -0,0 +1,307 @@ +package com.lagradost.cloudstream3.animeproviders + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.loadExtractor +import org.jsoup.Jsoup +import java.util.* + +class NineAnimeProvider : MainAPI() { + override var mainUrl = "https://9anime.id" + override var name = "9Anime" + override val hasMainPage = true + override val hasChromecastSupport = true + override val hasDownloadSupport = true + override val supportedTypes = setOf(TvType.Anime) + + companion object { + fun getDubStatus(title: String): DubStatus { + return if (title.contains("(dub)", ignoreCase = true)) { + DubStatus.Dubbed + } else { + DubStatus.Subbed + } + } + } + + override suspend fun getMainPage(): HomePageResponse { + val items = listOf( + Pair("$mainUrl/ajax/home/widget?name=trending", "Trending"), + Pair("$mainUrl/ajax/home/widget?name=updated_all", "All"), + Pair("$mainUrl/ajax/home/widget?name=updated_sub&page=1", "Recently Updated (SUB)"), + Pair( + "$mainUrl/ajax/home/widget?name=updated_dub&page=1", + "Recently Updated (DUB)(DUB)" + ), + Pair( + "$mainUrl/ajax/home/widget?name=updated_chinese&page=1", + "Recently Updated (Chinese)" + ), + Pair("$mainUrl/ajax/home/widget?name=random", "Random"), + ).apmap { (url, name) -> + val home = Jsoup.parse( + app.get( + url + ).mapped().html + ).select("ul.anime-list li").map { + val title = it.selectFirst("a.name").text() + val link = it.selectFirst("a").attr("href") + val poster = it.selectFirst("a.poster img").attr("src") + + newAnimeSearchResponse(title, link) { + this.posterUrl = poster + addDubStatus(getDubStatus(title)) + } + } + + HomePageList(name, home) + } + + return HomePageResponse(items) + } + + //Credits to https://github.com/jmir1 + private val key = "0wMrYU+ixjJ4QdzgfN2HlyIVAt3sBOZnCT9Lm7uFDovkb/EaKpRWhqXS5168ePcG" + + private fun getVrf(id: String): String? { + val reversed = ue(encode(id) + "0000000").slice(0..5).reversed() + + return reversed + ue(je(reversed, encode(id) ?: return null)).replace( + """=+$""".toRegex(), + "" + ) + } + + private fun getLink(url: String): String? { + val i = url.slice(0..5) + val n = url.slice(6..url.lastIndex) + return decode(je(i, ze(n))) + } + + private fun ue(input: String): String { + if (input.any { it.code >= 256 }) throw Exception("illegal characters!") + var output = "" + for (i in input.indices step 3) { + val a = intArrayOf(-1, -1, -1, -1) + a[0] = input[i].code shr 2 + a[1] = (3 and input[i].code) shl 4 + if (input.length > i + 1) { + a[1] = a[1] or (input[i + 1].code shr 4) + a[2] = (15 and input[i + 1].code) shl 2 + } + if (input.length > i + 2) { + a[2] = a[2] or (input[i + 2].code shr 6) + a[3] = 63 and input[i + 2].code + } + for (n in a) { + if (n == -1) output += "=" + else { + if (n in 0..63) output += key[n] + } + } + } + return output; + } + + private fun je(inputOne: String, inputTwo: String): String { + val arr = IntArray(256) { it } + var output = "" + var u = 0 + var r: Int + for (a in arr.indices) { + u = (u + arr[a] + inputOne[a % inputOne.length].code) % 256 + r = arr[a] + arr[a] = arr[u] + arr[u] = r + } + u = 0 + var c = 0 + for (f in inputTwo.indices) { + c = (c + f) % 256 + u = (u + arr[c]) % 256 + r = arr[c] + arr[c] = arr[u] + arr[u] = r + output += (inputTwo[f].code xor arr[(arr[c] + arr[u]) % 256]).toChar() + } + return output + } + + private fun ze(input: String): String { + val t = if (input.replace("""[\t\n\f\r]""".toRegex(), "").length % 4 == 0) { + input.replace(Regex("""/==?$/"""), "") + } else input + if (t.length % 4 == 1 || t.contains(Regex("""[^+/0-9A-Za-z]"""))) throw Exception("bad input") + var i: Int + var r = "" + var e = 0 + var u = 0 + for (o in t.indices) { + e = e shl 6 + i = key.indexOf(t[o]) + e = e or i + u += 6 + if (24 == u) { + r += ((16711680 and e) shr 16).toChar() + r += ((65280 and e) shr 8).toChar() + r += (255 and e).toChar() + e = 0 + u = 0 + } + } + return if (12 == u) { + e = e shr 4 + r + e.toChar() + } else { + if (18 == u) { + e = e shr 2 + r += ((65280 and e) shr 8).toChar() + r += (255 and e).toChar() + } + r + } + } + + private fun encode(input: String): String? = java.net.URLEncoder.encode(input, "utf-8") + + private fun decode(input: String): String? = java.net.URLDecoder.decode(input, "utf-8") + + override suspend fun search(query: String): List { + val url = "$mainUrl/filter?sort=title%3Aasc&keyword=$query" + + return app.get(url).document.select("ul.anime-list li").mapNotNull { + val title = it.selectFirst("a.name").text() + val href = + fixUrlNull(it.selectFirst("a").attr("href"))?.replace(Regex("(\\?ep=(\\d+)\$)"), "") + ?: return@mapNotNull null + val image = it.selectFirst("a.poster img").attr("src") + AnimeSearchResponse( + title, + href, + this.name, + TvType.Anime, + image, + null, + if (title.contains("(DUB)") || title.contains("(Dub)")) EnumSet.of( + DubStatus.Dubbed + ) else EnumSet.of(DubStatus.Subbed), + ) + } + } + + data class Response( + @JsonProperty("html") val html: String + ) + + override suspend fun load(url: String): LoadResponse? { + val validUrl = url.replace("https://9anime.to", mainUrl) + val doc = app.get(validUrl).document + val animeid = doc.selectFirst("div.player-wrapper.watchpage").attr("data-id") ?: return null + val animeidencoded = encode(getVrf(animeid) ?: return null) + val poster = doc.selectFirst("aside.main div.thumb div img").attr("src") + val title = doc.selectFirst(".info .title").text() + val description = doc.selectFirst("div.info p").text().replace("Ver menos", "").trim() + val episodes = Jsoup.parse( + app.get( + "$mainUrl/ajax/anime/servers?ep=1&id=${animeid}&vrf=$animeidencoded&ep=8&episode=&token=" + ).mapped().html + )?.select("ul.episodes li a")?.mapNotNull { + val link = it?.attr("href") ?: return@mapNotNull null + val name = "Episode ${it.text()}" + Episode(link, name) + } ?: return null + + val recommendations = + doc.select("div.container aside.main section div.body ul.anime-list li") + ?.mapNotNull { element -> + val recTitle = element.select("a.name")?.text() ?: return@mapNotNull null + val image = element.select("a.poster img")?.attr("src") + val recUrl = fixUrl(element.select("a").attr("href")) + newAnimeSearchResponse(recTitle, recUrl) { + this.posterUrl = image + addDubStatus(getDubStatus(recTitle)) + } + } + + val infodoc = doc.selectFirst("div.info .meta .col1").text() + val tvType = if (infodoc.contains("Movie")) TvType.AnimeMovie else TvType.Anime + val status = + if (infodoc.contains("Completed")) ShowStatus.Completed + else if (infodoc.contains("Airing")) ShowStatus.Ongoing + else null + val tags = doc.select("div.info .meta .col1 div:contains(Genre) a").map { it.text() } + + return newAnimeLoadResponse(title, validUrl, tvType) { + this.posterUrl = poster + this.plot = description + this.recommendations = recommendations + this.showStatus = status + this.tags = tags + addEpisodes(DubStatus.Subbed, episodes) + } + } + + data class Links( + @JsonProperty("url") val url: String + ) + + data class Servers( + @JsonProperty("28") val mcloud: String?, + @JsonProperty("35") val mp4upload: String?, + @JsonProperty("40") val streamtape: String?, + @JsonProperty("41") val vidstream: String?, + @JsonProperty("43") val videovard: String? + ) + + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + val document = app.get(data).document + val animeid = + document.selectFirst("div.player-wrapper.watchpage").attr("data-id") ?: return false + val animeidencoded = encode(getVrf(animeid) ?: return false) + + Jsoup.parse( + app.get( + "$mainUrl/ajax/anime/servers?&id=${animeid}&vrf=$animeidencoded&episode=&token=" + ).mapped().html + ).select("div.body").map { element -> + val jsonregex = Regex("(\\{.+\\}.*$data)") + val servers = jsonregex.find(element.toString())?.value?.replace( + Regex("(\".*data-base=.*href=\"$data)"), + "" + )?.replace(""", "\"") ?: return@map + + val jsonservers = parseJson(servers) ?: return@map + listOfNotNull( + jsonservers.vidstream, + jsonservers.mcloud, + jsonservers.mp4upload, + jsonservers.streamtape + ).mapNotNull { + try { + val epserver = app.get("$mainUrl/ajax/anime/episode?id=$it").text + (if (epserver.contains("url")) { + parseJson(epserver) + } else null)?.url?.let { it1 -> getLink(it1.replace("=", "")) } + ?.replace("/embed/", "/e/") + } catch (e: Exception) { + logError(e) + null + } + }.apmap { url -> + loadExtractor( + url, data, callback + ) + } + } + + return true + } +} \ No newline at end of file diff --git a/Aniwave/build.gradle.kts b/Aniwave/build.gradle.kts new file mode 100644 index 0000000..b78b675 --- /dev/null +++ b/Aniwave/build.gradle.kts @@ -0,0 +1,41 @@ +// use an integer for version numbers +version = 57 + +cloudstream { + language = "en" + // All of these properties are optional, you can safely remove them + + description = "Watch Aniwave/9anime, I have had reports saying homepage doesn't work the first time but retrying should fix it" + authors = listOf("RowdyRushya, Horis, Stormunblessed, KillerDogeEmpire, Enimax, Chokerman") + + /** + * Status int as the following: + * 0: Down + * 1: Ok + * 2: Slow + * 3: Beta only + * */ + status = 1 // will be 3 if unspecified + tvTypes = listOf( + "Anime", + "OVA", + ) + + iconUrl = "https://www.google.com/s2/favicons?domain=aniwave.to&sz=%size%" + + requiresResources = true +} + +dependencies { + implementation("androidx.preference:preference-ktx:1.2.1") + implementation("androidx.legacy:legacy-support-v4:1.0.0") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.recyclerview:recyclerview:1.3.2") + implementation("androidx.navigation:navigation-fragment-ktx:2.7.7") + implementation("androidx.preference:preference:1.2.1") +} +android { + buildFeatures { + viewBinding = true + } +} diff --git a/Aniwave/src/main/AndroidManifest.xml b/Aniwave/src/main/AndroidManifest.xml new file mode 100644 index 0000000..09881ca --- /dev/null +++ b/Aniwave/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Aniwave/src/main/kotlin/com/RowdyAvocado/Aniwave.kt b/Aniwave/src/main/kotlin/com/RowdyAvocado/Aniwave.kt new file mode 100644 index 0000000..5576c35 --- /dev/null +++ b/Aniwave/src/main/kotlin/com/RowdyAvocado/Aniwave.kt @@ -0,0 +1,475 @@ +package com.RowdyAvocado + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.Episode +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.HomePageResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.addAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.addDuration +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MainPageRequest +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.SeasonData +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.addDubStatus +import com.lagradost.cloudstream3.addEpisodes +import com.lagradost.cloudstream3.amap +import com.lagradost.cloudstream3.apmap +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.AnyVidplay +import com.lagradost.cloudstream3.fixUrl +import com.lagradost.cloudstream3.mainPageOf +import com.lagradost.cloudstream3.newAnimeLoadResponse +import com.lagradost.cloudstream3.newAnimeSearchResponse +import com.lagradost.cloudstream3.newEpisode +import com.lagradost.cloudstream3.newHomePageResponse +import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.utils.AppUtils +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.JsUnpacker +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.loadExtractor +import java.net.URLEncoder +import kotlinx.coroutines.delay +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element + +@OptIn(kotlin.ExperimentalStdlibApi::class) +class Aniwave : MainAPI() { + override var mainUrl = AniwavePlugin.currentAniwaveServer + override var name = "Aniwave/9Anime" + override val hasMainPage = true + override val hasChromecastSupport = true + override val hasDownloadSupport = true + override val supportedSyncNames = setOf(SyncIdName.Anilist, SyncIdName.MyAnimeList) + override val supportedTypes = setOf(TvType.Anime) + override val hasQuickSearch = true + + companion object { + var keys: Pair? = null + + fun encode(input: String): String = + java.net.URLEncoder.encode(input, "utf-8").replace("+", "%2B") + + private fun decode(input: String): String = java.net.URLDecoder.decode(input, "utf-8") + } + + private suspend fun getKeys(): Pair { + if (keys == null) { + val res = + app.get("https://rowdy-avocado.github.io/multi-keys/").parsedSafe() + ?: throw Exception("Unable to fetch keys") + keys = res.keys.first() to res.keys.last() + } + return keys!! + } + + private fun Element.toSearchResponse(): SearchResponse? { + val title = this.selectFirst(".info .name") ?: return null + val link = title.attr("href").replace(Regex("/ep.*\$"), "") + val poster = this.selectFirst(".poster > a > img")?.attr("src") + val meta = this.selectFirst(".poster > a > .meta > .inner > .left") + val subbedEpisodes = meta?.selectFirst(".sub")?.text()?.toIntOrNull() + val dubbedEpisodes = meta?.selectFirst(".dub")?.text()?.toIntOrNull() + + return newAnimeSearchResponse(title.text() ?: return null, link) { + this.posterUrl = poster + addDubStatus( + dubbedEpisodes != null, + subbedEpisodes != null, + dubbedEpisodes, + subbedEpisodes + ) + } + } + + override val mainPage = + mainPageOf( + "$mainUrl/ajax/home/widget/trending?page=" to "Trending", + "$mainUrl/ajax/home/widget/updated-all?page=" to "All", + "$mainUrl/ajax/home/widget/updated-sub?page=" to "Recently Updated (SUB)", + "$mainUrl/ajax/home/widget/updated-dub?page=" to "Recently Updated (DUB)", + "$mainUrl/ajax/home/widget/updated-china?page=" to "Recently Updated (Chinese)", + "$mainUrl/ajax/home/widget/random?page=" to "Random", + ) + + override suspend fun quickSearch(query: String): List { + delay(1000) + return search(query) + } + + override suspend fun search(query: String): List { + val url = "$mainUrl/filter?keyword=${query}" + return app.get(url).document.select("#list-items div.inner:has(div.poster)").mapNotNull { + it.toSearchResponse() + } + } + + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + val url = request.data + page + val res = app.get(url).parsed() + if (!res.status.equals(200)) throw ErrorLoadingException("Could not connect to the server") + val home = res.getHtml().select("div.item").mapNotNull { it.toSearchResponse() } + return newHomePageResponse(request.name, home, true) + } + + override suspend fun load(url: String): LoadResponse { + val doc = app.get(url).document + + val meta = doc.selectFirst("#w-info") ?: throw ErrorLoadingException("Could not find info") + val ratingElement = meta.selectFirst(".brating > #w-rating") + val id = ratingElement?.attr("data-id") ?: throw ErrorLoadingException("Could not find id") + val binfo = + meta.selectFirst(".binfo") ?: throw ErrorLoadingException("Could not find binfo") + val info = binfo.selectFirst(".info") ?: throw ErrorLoadingException("Could not find info") + val poster = binfo.selectFirst(".poster > span > img")?.attr("src") + val backimginfo = doc.selectFirst("#player")?.attr("style") + val backimgRegx = Regex("(http|https).*jpg") + val backposter = backimgRegx.find(backimginfo.toString())?.value ?: poster + val title = + (info.selectFirst(".title") ?: info.selectFirst(".d-title"))?.text() + ?: throw ErrorLoadingException("Could not find title") + val vrf = AniwaveUtils.vrfEncrypt(getKeys().first, id) + val episodeListUrl = "$mainUrl/ajax/episode/list/$id?$vrf" + val body = + app.get(episodeListUrl).parsedSafe()?.getHtml() + ?: throw ErrorLoadingException( + "Could not parse json with Vrf=$vrf id=$id url=\n$episodeListUrl" + ) + + val subEpisodes = ArrayList() + val dubEpisodes = ArrayList() + val softsubeps = ArrayList() + val uncensored = ArrayList() + val genres = + doc.select("div.meta:nth-child(1) > div:contains(Genres:) a").mapNotNull { + it.text() + } + val recss = + doc.select("div#watch-second .w-side-section div.body a.item").mapNotNull { rec -> + val href = rec.attr("href") + val rectitle = rec.selectFirst(".name")?.text() ?: "" + val recimg = rec.selectFirst("img")?.attr("src") + newAnimeSearchResponse(rectitle, fixUrl(href)) { this.posterUrl = recimg } + } + val status = + when (doc.selectFirst("div.meta:nth-child(1) > div:contains(Status:) span")?.text() + ) { + "Releasing" -> ShowStatus.Ongoing + "Completed" -> ShowStatus.Completed + else -> null + } + + val typetwo = + when (doc.selectFirst("div.meta:nth-child(1) > div:contains(Type:) span")?.text()) { + "OVA" -> TvType.OVA + "SPECIAL" -> TvType.OVA + else -> TvType.Anime + } + val duration = doc.selectFirst(".bmeta > div > div:contains(Duration:) > span")?.text() + + body.select(".episodes > ul > li > a").apmap { element -> + val ids = element.attr("data-ids").split(",", limit = 3) + val dataDub = element.attr("data-dub").toIntOrNull() + val epNum = element.attr("data-num").toIntOrNull() + val epTitle = element.selectFirst("span.d-title")?.text() + val isUncen = element.attr("data-slug").contains("uncen") + + if (ids.size > 0) { + if (isUncen) { + ids.getOrNull(0)?.let { uncen -> + val epdd = "{\"ID\":\"$uncen\",\"type\":\"sub\"}" + uncensored.add( + newEpisode(epdd) { + this.episode = epNum + this.name = epTitle + this.season = -4 + } + ) + } + } else { + if (ids.size == 1 && dataDub == 1) { + ids.getOrNull(0)?.let { dub -> + val epdd = "{\"ID\":\"$dub\",\"type\":\"dub\"}" + dubEpisodes.add( + newEpisode(epdd) { + this.episode = epNum + this.name = epTitle + this.season = -2 + } + ) + } + } else { + ids.getOrNull(0)?.let { sub -> + val epdd = "{\"ID\":\"$sub\",\"type\":\"sub\"}" + subEpisodes.add( + newEpisode(epdd) { + this.episode = epNum + this.name = epTitle + this.season = -1 + } + ) + } + } + } + if (ids.size > 1) { + if (dataDub == 0 || ids.size > 2) { + ids.getOrNull(1)?.let { softsub -> + val epdd = "{\"ID\":\"$softsub\",\"type\":\"softsub\"}" + softsubeps.add( + newEpisode(epdd) { + this.episode = epNum + this.name = epTitle + this.season = -3 + } + ) + } + } else { + ids.getOrNull(1)?.let { dub -> + val epdd = "{\"ID\":\"$dub\",\"type\":\"dub\"}" + dubEpisodes.add( + newEpisode(epdd) { + this.episode = epNum + this.name = epTitle + this.season = -2 + } + ) + } + } + + if (ids.size > 2) { + ids.getOrNull(2)?.let { dub -> + val epdd = "{\"ID\":\"$dub\",\"type\":\"dub\"}" + dubEpisodes.add( + newEpisode(epdd) { + this.episode = epNum + this.name = epTitle + this.season = -2 + } + ) + } + } + } + } + } + + // season -1 HARDSUBBED + // season -2 Dubbed + // Season -3 SofSubbed + + val names = + listOf( + Pair("Sub", -1), + Pair("Dub", -2), + Pair("S-Sub", -3), + Pair("Uncensored", -4), + ) + + // Reading info from web page to fetch anilistData + val titleRomaji = + (info.selectFirst(".title") ?: info.selectFirst(".d-title"))?.attr("data-jp") ?: "" + val premieredDetails = + info.select(".bmeta > .meta > div") + .find { it.text().contains("Premiered: ", true) } + ?.selectFirst("span > a") + ?.text() + ?.split(" ") + val season = premieredDetails?.get(0).toString() + val year = premieredDetails?.get(1)?.toInt() ?: 0 + + return newAnimeLoadResponse(title, url, TvType.Anime) { + addEpisodes(DubStatus.Subbed, dubEpisodes) + addEpisodes(DubStatus.Subbed, subEpisodes) + addEpisodes(DubStatus.Subbed, softsubeps) + addEpisodes(DubStatus.Subbed, uncensored) + this.seasonNames = names.map { (name, int) -> SeasonData(int, name) } + plot = info.selectFirst(".synopsis > .shorting > .content")?.text() + this.posterUrl = poster + rating = ratingElement.attr("data-score").toFloat().times(1000f).toInt() + this.backgroundPosterUrl = backposter + this.tags = genres + this.recommendations = recss + this.showStatus = status + if (AniwavePlugin.aniwaveSimklSync) + addAniListId(aniAPICall(AniwaveUtils.aniQuery(titleRomaji, year, season))?.id) + else this.type = typetwo + addDuration(duration) + } + } + + private fun serverName(serverID: String?): String? { + val sss = + when (serverID) { + "41" -> "vidplay" + "44" -> "filemoon" + "40" -> "streamtape" + "35" -> "mp4upload" + "28" -> "MyCloud" + else -> null + } + return sss + } + + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + val parseData = AppUtils.parseJson(data) + val datavrf = AniwaveUtils.vrfEncrypt(getKeys().first, parseData.ID) + val one = app.get("$mainUrl/ajax/server/list/${parseData.ID}?$datavrf").parsed() + val two = one.getHtml() + val aas = + two.select("div.servers .type[data-type=${parseData.type}] li").mapNotNull { + val datalinkId = it.attr("data-link-id") + val serverID = it.attr("data-sv-id").toString() + val newSname = serverName(serverID) + Pair(newSname, datalinkId) + } + aas.amap { (sName, sId) -> + try { + val vrf = AniwaveUtils.vrfEncrypt(getKeys().first, sId) + val videncrr = app.get("$mainUrl/ajax/server/$sId?$vrf").parsed() + val encUrl = videncrr.result?.url ?: return@amap + val asss = AniwaveUtils.vrfDecrypt(getKeys().second, encUrl) + + if (sName.equals("filemoon")) { + val res = app.get(asss) + if (res.code == 200) { + val packedJS = + res.document + .selectFirst("script:containsData(function(p,a,c,k,e,d))") + ?.data() + .toString() + JsUnpacker(packedJS).unpack().let { unPacked -> + Regex("sources:\\[\\{file:\"(.*?)\"") + .find(unPacked ?: "") + ?.groupValues + ?.get(1) + ?.let { link -> + callback.invoke( + ExtractorLink( + "Filemoon", + "Filemoon", + link, + "", + Qualities.Unknown.value, + link.contains(".m3u8") + ) + ) + } + } + } + } else if (sName.equals("vidplay")) { + val host = AniwaveUtils.getBaseUrl(asss) + AnyVidplay(host).getUrl(asss, host, subtitleCallback, callback) + } else loadExtractor(asss, subtitleCallback, callback) + } catch (e: Exception) {} + } + return true + } + + override suspend fun getLoadUrl(name: SyncIdName, id: String): String? { + val syncId = id.split("/").last() + + // formatting the JSON response to search on aniwave site + val anilistData = aniAPICall(AniwaveUtils.aniQuery(name, syncId.toInt())) + val title = anilistData?.title?.romaji ?: anilistData?.title?.english + val year = anilistData?.year + val season = anilistData?.season + val searchUrl = + "$mainUrl/filter?keyword=${title}&year%5B%5D=${year}&season%5B%5D=unknown&season%5B%5D=${season?.lowercase()}&sort=recently_updated" + + // searching the anime on aniwave site using advance filter and capturing the url from + // search result + val document = app.get(searchUrl).document + val syncUrl = + document.select("#list-items div.info div.b1 > a") + .find { it.attr("data-jp").equals(title, true) } + ?.attr("href") + return fixUrl(syncUrl ?: return null) + } + + private suspend fun aniAPICall(query: String): Media? { + // Fetching data using POST method + val url = "https://graphql.anilist.co" + val res = + app.post( + url, + headers = + mapOf( + "Accept" to "application/json", + "Content-Type" to "application/json", + ), + data = + mapOf( + "query" to query, + ) + ) + .parsedSafe() + + return res?.data?.media + } + + // JSON formatter for data fetched from anilistApi + data class SyncTitle( + @JsonProperty("romaji") val romaji: String? = null, + @JsonProperty("english") val english: String? = null, + ) + + data class Media( + @JsonProperty("title") val title: SyncTitle? = null, + @JsonProperty("id") val id: Int? = null, + @JsonProperty("idMal") val idMal: Int? = null, + @JsonProperty("season") val season: String? = null, + @JsonProperty("seasonYear") val year: Int? = null, + ) + + data class Response( + @JsonProperty("status") val status: Int, + @JsonProperty("result") val result: String + ) { + fun getHtml(): Document { + return Jsoup.parse(result) + } + } + + data class AniwaveMediaInfo( + @JsonProperty("result") val result: AniwaveResult? = AniwaveResult() + ) + + data class AniwaveResult( + @JsonProperty("sources") var sources: ArrayList = arrayListOf(), + @JsonProperty("tracks") var tracks: ArrayList = arrayListOf() + ) + + data class AniwaveTracks( + @JsonProperty("file") var file: String? = null, + @JsonProperty("label") var label: String? = null, + ) + + data class Data( + @JsonProperty("Media") val media: Media? = null, + ) + + data class SyncInfo( + @JsonProperty("data") val data: Data? = null, + ) + + data class Result(@JsonProperty("url") val url: String? = null) + + data class Links(@JsonProperty("result") val result: Result? = null) + + data class SubDubInfo( + @JsonProperty("ID") val ID: String, + @JsonProperty("type") val type: String + ) + + data class Keys(@JsonProperty("aniwave") val keys: List) +} diff --git a/Aniwave/src/main/kotlin/com/RowdyAvocado/AniwavePlugin.kt b/Aniwave/src/main/kotlin/com/RowdyAvocado/AniwavePlugin.kt new file mode 100644 index 0000000..92d3ed0 --- /dev/null +++ b/Aniwave/src/main/kotlin/com/RowdyAvocado/AniwavePlugin.kt @@ -0,0 +1,71 @@ +package com.RowdyAvocado + +import android.content.Context +import android.os.Handler +import androidx.appcompat.app.AppCompatActivity +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin +import com.lagradost.cloudstream3.plugins.Plugin +import com.lagradost.cloudstream3.plugins.PluginManager + +enum class ServerList(val link: String) { + TO("https://aniwave.to"), + LI("https://aniwave.li"), + VC("https://aniwave.vc") +} + +@CloudstreamPlugin +class AniwavePlugin : Plugin() { + override fun load(context: Context) { + // All providers should be added in this manner. Please don't edit the providers list + // directly. + registerMainAPI(Aniwave()) + + this.openSettings = + openSettings@{ + val manager = + (context.getActivity() as? AppCompatActivity)?.supportFragmentManager + ?: return@openSettings + BottomFragment(this).show(manager, "") + } + } + + fun reload(context: Context?) { + val pluginData = + PluginManager.getPluginsOnline().find { it.internalName.contains("Aniwave") } + if (pluginData == null) { + PluginManager.hotReloadAllLocalPlugins(context as AppCompatActivity) + } else { + PluginManager.unloadPlugin(pluginData.filePath) + PluginManager.loadAllOnlinePlugins(context!!) + afterPluginsLoadedEvent.invoke(true) + } + } + + companion object { + inline fun Handler.postFunction(crossinline function: () -> Unit) { + this.post( + object : Runnable { + override fun run() { + function() + } + } + ) + } + + var currentAniwaveServer: String + get() = getKey("ANIWAVE_CURRENT_SERVER") ?: ServerList.TO.link + set(value) { + setKey("ANIWAVE_CURRENT_SERVER", value) + } + + var aniwaveSimklSync: Boolean + get() = getKey("ANIWAVE_SIMKL_SYNC") ?: false + set(value) { + setKey("ANIWAVE_SIMKL_SYNC", value) + } + } +} diff --git a/Aniwave/src/main/kotlin/com/RowdyAvocado/AniwaveUtils.kt b/Aniwave/src/main/kotlin/com/RowdyAvocado/AniwaveUtils.kt new file mode 100644 index 0000000..7343bae --- /dev/null +++ b/Aniwave/src/main/kotlin/com/RowdyAvocado/AniwaveUtils.kt @@ -0,0 +1,109 @@ +package com.RowdyAvocado + +import android.util.Base64 +import com.lagradost.cloudstream3.syncproviders.SyncIdName +import java.net.URI +import java.net.URLDecoder +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +@OptIn(kotlin.ExperimentalStdlibApi::class) +object AniwaveUtils { + + fun vrfEncrypt(key: String, input: String): String { + val rc4Key = SecretKeySpec(key.toByteArray(), "RC4") + val cipher = Cipher.getInstance("RC4") + cipher.init(Cipher.DECRYPT_MODE, rc4Key, cipher.parameters) + + var vrf = cipher.doFinal(input.toByteArray()) + vrf = Base64.encode(vrf, Base64.URL_SAFE or Base64.NO_WRAP) + vrf = Base64.encode(vrf, Base64.DEFAULT or Base64.NO_WRAP) + vrf = vrfShift(vrf) + // vrf = rot13(vrf) + vrf = vrf.reversed().toByteArray() + vrf = Base64.encode(vrf, Base64.URL_SAFE or Base64.NO_WRAP) + val stringVrf = vrf.toString(Charsets.UTF_8) + return "vrf=${java.net.URLEncoder.encode(stringVrf, "utf-8")}" + } + + fun vrfDecrypt(key: String, input: String): String { + var vrf = input.toByteArray() + vrf = Base64.decode(vrf, Base64.URL_SAFE) + + val rc4Key = SecretKeySpec(key.toByteArray(), "RC4") + val cipher = Cipher.getInstance("RC4") + cipher.init(Cipher.DECRYPT_MODE, rc4Key, cipher.parameters) + vrf = cipher.doFinal(vrf) + + return URLDecoder.decode(vrf.toString(Charsets.UTF_8), "utf-8") + } + + private fun rot13(vrf: ByteArray): ByteArray { + for (i in vrf.indices) { + val byte = vrf[i] + if (byte in 'A'.code..'Z'.code) { + vrf[i] = ((byte - 'A'.code + 13) % 26 + 'A'.code).toByte() + } else if (byte in 'a'.code..'z'.code) { + vrf[i] = ((byte - 'a'.code + 13) % 26 + 'a'.code).toByte() + } + } + return vrf + } + + private fun vrfShift(vrf: ByteArray): ByteArray { + for (i in vrf.indices) { + val shift = arrayOf(-2, -4, -5, 6, 2, -3, 3, 6)[i % 8] + vrf[i] = vrf[i].plus(shift).toByte() + } + return vrf + } + + fun aniQuery(name: SyncIdName, id: Int): String { + // creating query for Anilist API using ID + val idType = + when (name) { + SyncIdName.MyAnimeList -> "idMal" + else -> "id" + } + val query = + """ + query { + Media($idType: $id, type: ANIME) { + title { + romaji + english + } + id + idMal + season + seasonYear + } + } + """ + return query + } + + fun aniQuery(titleRomaji: String, year: Int, season: String): String { + // creating query for Anilist API using name and other details + val query = + """ + query { + Media(search: "$titleRomaji", season:${season.uppercase()}, seasonYear:$year, type: ANIME) { + title { + romaji + english + } + id + idMal + season + seasonYear + } + } + """ + return query + } + + fun getBaseUrl(url: String): String { + return URI(url).let { "${it.scheme}://${it.host}" } + } +} diff --git a/Aniwave/src/main/kotlin/com/RowdyAvocado/BottomSheet.kt b/Aniwave/src/main/kotlin/com/RowdyAvocado/BottomSheet.kt new file mode 100644 index 0000000..c29fba4 --- /dev/null +++ b/Aniwave/src/main/kotlin/com/RowdyAvocado/BottomSheet.kt @@ -0,0 +1,117 @@ +package com.RowdyAvocado + +import android.annotation.SuppressLint +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.View.OnClickListener +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.Switch +import android.widget.Toast +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class BottomFragment(private val plugin: AniwavePlugin) : BottomSheetDialogFragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val id = + plugin.resources!!.getIdentifier( + "bottom_sheet_layout", + "layout", + BuildConfig.LIBRARY_PACKAGE_NAME + ) + val layout = plugin.resources!!.getLayout(id) + val view = inflater.inflate(layout, container, false) + val outlineId = + plugin.resources!!.getIdentifier( + "outline", + "drawable", + BuildConfig.LIBRARY_PACKAGE_NAME + ) + + // building save button and settings click listener + val saveIconId = + plugin.resources!!.getIdentifier( + "save_icon", + "drawable", + BuildConfig.LIBRARY_PACKAGE_NAME + ) + val saveBtn = view.findView("save") + saveBtn.setImageDrawable(plugin.resources!!.getDrawable(saveIconId, null)) + saveBtn.background = plugin.resources!!.getDrawable(outlineId, null) + saveBtn.setOnClickListener( + object : OnClickListener { + override fun onClick(btn: View) { + plugin.reload(context) + Toast.makeText(context, "Saved", Toast.LENGTH_SHORT).show() + dismiss() + } + } + ) + + // building simkl sync switch and settings click listener + val simklSyncSwitch = view.findView("simkl_sync") + simklSyncSwitch.isChecked = AniwavePlugin.aniwaveSimklSync + simklSyncSwitch.background = plugin.resources!!.getDrawable(outlineId, null) + simklSyncSwitch.setOnClickListener( + object : OnClickListener { + override fun onClick(btn: View?) { + AniwavePlugin.aniwaveSimklSync = simklSyncSwitch.isChecked + } + } + ) + + // building server options and settings click listener + val serverGroup = view.findView("server_group") + val radioBtnId = + plugin.resources!!.getIdentifier( + "radio_button", + "layout", + BuildConfig.LIBRARY_PACKAGE_NAME + ) + ServerList.values().forEach { server -> + val radioBtnLayout = plugin.resources!!.getLayout(radioBtnId) + val radioBtnView = inflater.inflate(radioBtnLayout, container, false) + val radioBtn = radioBtnView.findView("radio_button") + radioBtn.text = server.link + val newId = View.generateViewId() + radioBtn.id = newId + radioBtn.background = plugin.resources!!.getDrawable(outlineId, null) + radioBtn.setOnClickListener( + object : OnClickListener { + override fun onClick(btn: View?) { + AniwavePlugin.currentAniwaveServer = radioBtn.text.toString() + serverGroup.check(newId) + } + } + ) + serverGroup.addView(radioBtnView) + if (AniwavePlugin.currentAniwaveServer.equals(server.link)) serverGroup.check(newId) + } + return view + } + + private fun View.findView(name: String): T { + val id = plugin.resources!!.getIdentifier(name, "id", BuildConfig.LIBRARY_PACKAGE_NAME) + return this.findViewById(id) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) + (dialog as? BottomSheetDialog)?.behavior?.state = BottomSheetBehavior.STATE_EXPANDED + return dialog + } + + @SuppressLint("SetTextI18n") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + } +} diff --git a/Aniwave/src/main/kotlin/com/RowdyAvocado/JsInterceptor.kt b/Aniwave/src/main/kotlin/com/RowdyAvocado/JsInterceptor.kt new file mode 100644 index 0000000..90af105 --- /dev/null +++ b/Aniwave/src/main/kotlin/com/RowdyAvocado/JsInterceptor.kt @@ -0,0 +1,152 @@ +package com.RowdyAvocado + +import android.annotation.SuppressLint +import android.os.Handler +import android.os.Looper +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import com.RowdyAvocado.AniwavePlugin.Companion.postFunction +import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.utils.Coroutines +import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.nicehttp.requestCreator +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response + +// Credits +// https://github.com/jmir1/aniyomi-extensions/blob/master/src/en/nineanime/src/eu/kanade/tachiyomi/animeextension/en/nineanime/JsInterceptor.kt +class JsInterceptor(private val serverid: String, private val lang: String) : Interceptor { + + private val handler by lazy { Handler(Looper.getMainLooper()) } + class JsObject(var payload: String = "") { + @JavascriptInterface + fun passPayload(passedPayload: String) { + payload = passedPayload + } + } + + override fun intercept(chain: Interceptor.Chain): Response { + // val mess = if (serverid == "41") "Vidstream" else if (serverid == "28") "Mcloud" else "" + val request = chain.request() + return runBlocking { + val fixedRequest = resolveWithWebView(request) + return@runBlocking chain.proceed(fixedRequest ?: request) + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun resolveWithWebView(request: Request): Request? { + val latch = CountDownLatch(1) + + var webView: WebView? = null + + val origRequestUrl = request.url.toString() + + fun destroyWebView() { + Coroutines.main { + webView?.stopLoading() + webView?.destroy() + webView = null + println("Destroyed webview") + } + } + + // JavaSrcipt gets the Dub or Sub link of vidstream + val jsScript = + """ + (function() { + var click = document.createEvent('MouseEvents'); + click.initMouseEvent('click', true, true); + document.querySelector('div[data-type="$lang"] ul li[data-sv-id="$serverid"]').dispatchEvent(click); + })(); + """ + val headers = + request.headers + .toMultimap() + .mapValues { it.value.getOrNull(0) ?: "" } + .toMutableMap() + + var newRequest: Request? = null + + handler.postFunction { + val webview = WebView(context!!) + webView = webview + with(webview.settings) { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + useWideViewPort = false + loadWithOverviewMode = false + userAgentString = USER_AGENT + blockNetworkImage = true + webview.webViewClient = + object : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest? + ): WebResourceResponse? { + if (serverid == "41") { + if (!request?.url.toString().contains("vidstream") && + !request?.url.toString().contains("vizcloud") + ) + return null + } + if (serverid == "28") { + if (!request?.url.toString().contains("mcloud")) return null + } + + if (request?.url.toString().contains(Regex("list.m3u8|/simple/"))) { + newRequest = + requestCreator( + "GET", + request?.url.toString(), + headers = + mapOf( + "referer" to + "/orp.maertsdiv//:sptth".reversed() + ) + ) + latch.countDown() + return null + } + return super.shouldInterceptRequest(view, request) + } + override fun onPageFinished(view: WebView?, url: String?) { + view?.evaluateJavascript(jsScript) {} + } + } + webView?.loadUrl(origRequestUrl, headers) + } + } + + latch.await(30, TimeUnit.SECONDS) + handler.postFunction { + webView?.stopLoading() + webView?.destroy() + webView = null + // context.let { Toast.makeText(it, "Success!", Toast.LENGTH_SHORT).show()} + } + + var loop = 0 + val totalTime = 60000L + + val delayTime = 100L + + while (loop < totalTime / delayTime) { + if (newRequest != null) return newRequest + loop += 1 + } + + println("Web-view timeout after ${totalTime / 1000}s") + destroyWebView() + return newRequest + } +} diff --git a/Aniwave/src/main/kotlin/com/RowdyAvocado/JsVrfInterceptor.kt b/Aniwave/src/main/kotlin/com/RowdyAvocado/JsVrfInterceptor.kt new file mode 100644 index 0000000..66c388e --- /dev/null +++ b/Aniwave/src/main/kotlin/com/RowdyAvocado/JsVrfInterceptor.kt @@ -0,0 +1,106 @@ +package com.RowdyAvocado + +import android.annotation.SuppressLint +import android.os.Handler +import android.os.Looper +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import com.RowdyAvocado.AniwavePlugin.Companion.postFunction +import com.lagradost.cloudstream3.AcraApplication.Companion.context +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +// The following code is extracted from here +// https://github.com/jmir1/aniyomi-extensions/blob/master/src/en/nineanime/src/eu/kanade/tachiyomi/animeextension/en/nineanime/JsVrfInterceptor.kt +// The following code is under the Apache License 2.0 +// https://github.com/jmir1/aniyomi-extensions/blob/master/LICENSE +class JsVrfInterceptor(private val baseUrl: String) { + + private val handler by lazy { Handler(Looper.getMainLooper()) } + + private val vrfWebView = createWebView() + + fun wake() = "" + + fun getVrf(query: String): String { + val jscript = getJs(query) + val cdl = CountDownLatch(1) + var vrf = "" + handler.postFunction { + vrfWebView?.evaluateJavascript(jscript) { + vrf = it?.removeSurrounding("\"") ?: "" + cdl.countDown() + } + } + cdl.await(12, TimeUnit.SECONDS) + if (vrf.isBlank()) throw Exception("vrf could not be retrieved") + return vrf + } + + @SuppressLint("SetJavaScriptEnabled") + private fun createWebView(): WebView? { + val latch = CountDownLatch(1) + var webView: WebView? = null + + handler.postFunction { + val webview = WebView(context!!) + webView = webview + with(webview.settings) { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + useWideViewPort = false + loadWithOverviewMode = false + userAgentString = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:106.0) Gecko/20100101 Firefox/106.0" + + webview.webViewClient = + object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + if (request?.url.toString().contains("$baseUrl/filter")) { + return super.shouldOverrideUrlLoading(view, request) + } else { + // Block the request + return true + } + } + override fun onPageFinished(view: WebView?, url: String?) { + latch.countDown() + } + } + webView?.loadUrl("$baseUrl/filter") + } + } + + latch.await() + + handler.postFunction { webView?.stopLoading() } + return webView + } + + private fun getJs(query: String): String { + return """ + (function() { + document.querySelector("form.filters input.form-control").value = '$query'; + let inputElemente = document.querySelector('form.filters input.form-control'); + let e = document.createEvent('HTMLEvents'); + e.initEvent('keyup', true, true); + inputElemente.dispatchEvent(e); + let val = ""; + while (val == "") { + let element = document.querySelector('form.filters input[type="hidden"]').value; + if (element) { + val = element; + break; + } + } + document.querySelector("form.filters input.form-control").value = ''; + return val; + })(); + """.trimIndent() + } +} diff --git a/Aniwave/src/main/res/drawable/outline.xml b/Aniwave/src/main/res/drawable/outline.xml new file mode 100644 index 0000000..6a726e7 --- /dev/null +++ b/Aniwave/src/main/res/drawable/outline.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Aniwave/src/main/res/drawable/save_icon.xml b/Aniwave/src/main/res/drawable/save_icon.xml new file mode 100644 index 0000000..0fe7fc2 --- /dev/null +++ b/Aniwave/src/main/res/drawable/save_icon.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/Aniwave/src/main/res/layout/bottom_sheet_layout.xml b/Aniwave/src/main/res/layout/bottom_sheet_layout.xml new file mode 100644 index 0000000..22b10aa --- /dev/null +++ b/Aniwave/src/main/res/layout/bottom_sheet_layout.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Aniwave/src/main/res/layout/radio_button.xml b/Aniwave/src/main/res/layout/radio_button.xml new file mode 100644 index 0000000..59f4c2e --- /dev/null +++ b/Aniwave/src/main/res/layout/radio_button.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/ExampleProvider/src/main/kotlin/com/example/ExampleProvider.kt b/ExampleProvider/src/main/kotlin/com/example/ExampleProvider.kt deleted file mode 100644 index e2eb00b..0000000 --- a/ExampleProvider/src/main/kotlin/com/example/ExampleProvider.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.example - -import androidx.appcompat.app.AppCompatActivity -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.MainAPI -import com.lagradost.cloudstream3.SearchResponse - -class ExampleProvider(val plugin: TestPlugin) : MainAPI() { // all providers must be an intstance of MainAPI - override var mainUrl = "https://example.com/" - override var name = "Example provider" - override val supportedTypes = setOf(TvType.Movie) - - override var lang = "en" - - // enable this when your provider has a main page - override val hasMainPage = true - - // this function gets called when you search for something - override suspend fun search(query: String): List { - return listOf() - } -} \ No newline at end of file diff --git a/HiAnime/build.gradle.kts b/HiAnime/build.gradle.kts new file mode 100644 index 0000000..0b9b950 --- /dev/null +++ b/HiAnime/build.gradle.kts @@ -0,0 +1,26 @@ +// use an integer for version numbers +version = 5 + + +cloudstream { + language = "en" + // All of these properties are optional, you can safely remove them + + //description = "Webview is used to load links, reload if necessary" + authors = listOf("Stormunblessed, KillerDogeEmpire") + + /** + * Status int as the following: + * 0: Down + * 1: Ok + * 2: Slow + * 3: Beta only + * */ + status = 1 // will be 3 if unspecified + tvTypes = listOf( + "Anime", + "OVA", + ) + + iconUrl = "https://www.google.com/s2/favicons?domain=hianime.to&sz=%size%" +} \ No newline at end of file diff --git a/HiAnime/src/main/AndroidManifest.xml b/HiAnime/src/main/AndroidManifest.xml new file mode 100644 index 0000000..15d9019 --- /dev/null +++ b/HiAnime/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/HiAnime/src/main/kotlin/com/RowdyAvocado/HiAnime.kt b/HiAnime/src/main/kotlin/com/RowdyAvocado/HiAnime.kt new file mode 100644 index 0000000..f946efa --- /dev/null +++ b/HiAnime/src/main/kotlin/com/RowdyAvocado/HiAnime.kt @@ -0,0 +1,312 @@ +package com.RowdyAvocado + +import com.RowdyAvocado.RabbitStream.Companion.extractRabbitStream +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.Actor +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.ActorRole +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.Episode +import com.lagradost.cloudstream3.HomePageResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.addAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.addMalId +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MainPageRequest +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.addDubStatus +import com.lagradost.cloudstream3.addEpisodes +import com.lagradost.cloudstream3.apmap +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.fixUrl +import com.lagradost.cloudstream3.getDurationFromString +import com.lagradost.cloudstream3.mainPageOf +import com.lagradost.cloudstream3.newAnimeLoadResponse +import com.lagradost.cloudstream3.newAnimeSearchResponse +import com.lagradost.cloudstream3.newEpisode +import com.lagradost.cloudstream3.newHomePageResponse +import com.lagradost.cloudstream3.toRatingInt +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.loadExtractor +import com.lagradost.nicehttp.Requests.Companion.await +import java.net.URI +import okhttp3.Interceptor +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element + +private const val OPTIONS = "OPTIONS" + +class HiAnime : MainAPI() { + override var mainUrl = "https://hianime.to" + override var name = "HiAnime" + override val hasQuickSearch = false + override val hasMainPage = true + override val hasChromecastSupport = true + override val hasDownloadSupport = true + override val usesWebView = true + override val supportedTypes = setOf(TvType.Anime, TvType.AnimeMovie, TvType.OVA) + + val epRegex = Regex("Ep (\\d+)/") + var sid: HashMap = hashMapOf() // Url hashcode to sid + + private fun Element.toSearchResult(): SearchResponse { + val href = fixUrl(this.select("a").attr("href")) + val title = this.select("h3.film-name").text() + val subCount = + this.selectFirst(".film-poster > .tick.ltr > .tick-sub")?.text()?.toIntOrNull() + val dubCount = + this.selectFirst(".film-poster > .tick.ltr > .tick-dub")?.text()?.toIntOrNull() + + val posterUrl = fixUrl(this.select("img").attr("data-src")) + val type = getType(this.selectFirst("div.fd-infor > span.fdi-item")?.text() ?: "") + + return newAnimeSearchResponse(title, href, type) { + this.posterUrl = posterUrl + addDubStatus(dubCount != null, subCount != null, dubCount, subCount) + } + } + + private fun Element.getActorData(): ActorData? { + var actor: Actor? = null + var role: ActorRole? = null + var voiceActor: Actor? = null + val elements = this.select(".per-info") + elements.forEachIndexed { index, actorInfo -> + val name = actorInfo.selectFirst(".pi-name")?.text() ?: return null + val image = actorInfo.selectFirst("a > img")?.attr("data-src") ?: return null + when (index) { + 0 -> { + actor = Actor(name, image) + val castType = actorInfo.selectFirst(".pi-cast")?.text() ?: "Main" + role = ActorRole.valueOf(castType) + } + 1 -> voiceActor = Actor(name, image) + else -> {} + } + } + return ActorData(actor ?: return null, role, voiceActor = voiceActor) + } + + companion object { + fun getType(t: String): TvType { + return if (t.contains("OVA") || t.contains("Special")) TvType.OVA + else if (t.contains("Movie")) TvType.AnimeMovie else TvType.Anime + } + + fun getStatus(t: String): ShowStatus { + return when (t) { + "Finished Airing" -> ShowStatus.Completed + "Currently Airing" -> ShowStatus.Ongoing + else -> ShowStatus.Completed + } + } + } + + override val mainPage = + mainPageOf( + "$mainUrl/recently-updated?page=" to "Latest Episodes", + "$mainUrl/recently-added?page=" to "New On HiAnime", + "$mainUrl/top-airing?page=" to "Top Airing", + "$mainUrl/most-popular?page=" to "Most Popular", + "$mainUrl/most-favorite?page=" to "Most Favorite", + "$mainUrl/completed?page=" to "Latest Completed", + ) + + override suspend fun search(query: String): List { + val link = "$mainUrl/search?keyword=$query" + val res = app.get(link).document + + return res.select("div.flw-item").map { it.toSearchResult() } + } + + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + val res = app.get("${request.data}$page").document + val items = res.select("div.flw-item").map { it.toSearchResult() } + return newHomePageResponse(request.name, items) + } + + override suspend fun load(url: String): LoadResponse { + val document = app.get(url).document + + val syncData = tryParseJson(document.selectFirst("#syncData")?.data()) + + val title = document.selectFirst(".anisc-detail > .film-name")?.text().toString() + val poster = document.selectFirst(".anisc-poster img")?.attr("src") + val animeId = URI(url).path.split("-").last() + + val subCount = document.selectFirst(".anisc-detail .tick-sub")?.text()?.toIntOrNull() + val dubCount = document.selectFirst(".anisc-detail .tick-dub")?.text()?.toIntOrNull() + + var dubEpisodes = emptyList() + var subEpisodes = emptyList() + val epRes = + app.get("$mainUrl/ajax/v2/episode/list/$animeId") + .parsedSafe() + ?.getDocument() + epRes?.select(".ss-list > a[href].ssl-item.ep-item")?.forEachIndexed { index, ep -> + subCount?.let { + if (index < it) { + subEpisodes += + newEpisode("sub|" + ep.attr("href")) { + name = ep.attr("title") + episode = ep.selectFirst(".ssli-order")?.text()?.toIntOrNull() + } + } + } + dubCount?.let { + if (index < it) { + dubEpisodes += + newEpisode("dub|" + ep.attr("href")) { + name = ep.attr("title") + episode = ep.selectFirst(".ssli-order")?.text()?.toIntOrNull() + } + } + } + } + + val actors = + document.select("div.block-actors-content div.bac-item").mapNotNull { + it.getActorData() + } + + val recommendations = + document.select("div.block_area_category div.flw-item").map { it.toSearchResult() } + + return newAnimeLoadResponse(title, url, TvType.Anime) { + engName = title + posterUrl = poster + addEpisodes(DubStatus.Subbed, subEpisodes) + addEpisodes(DubStatus.Dubbed, dubEpisodes) + this.recommendations = recommendations + this.actors = actors + addMalId(syncData?.malId?.toIntOrNull()) + addAniListId(syncData?.aniListId?.toIntOrNull()) + + // adding info + document.select(".anisc-info > .item").forEach { info -> + val infoType = info.select("span.item-head").text().removeSuffix(":") + when (infoType) { + "Overview" -> plot = info.selectFirst(".text")?.text() + "Japanese" -> japName = info.selectFirst(".name")?.text() + "Premiered" -> + year = + info.selectFirst(".name") + ?.text() + ?.substringAfter(" ") + ?.toIntOrNull() + "Duration" -> + duration = getDurationFromString(info.selectFirst(".name")?.text()) + "Status" -> showStatus = getStatus(info.selectFirst(".name")?.text().toString()) + "Genres" -> tags = info.select("a").map { it.text() } + "MAL Score" -> rating = info.selectFirst(".name")?.text().toRatingInt() + else -> {} + } + } + } + } + + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + val dubType = data.replace("$mainUrl/", "").split("|").first() + val epId = data.split("|").last().split("=").last() + + val servers: List = + app.get("$mainUrl/ajax/v2/episode/servers?episodeId=$epId") + .parsed() + .getDocument() + .select(".server-item[data-type=$dubType][data-id]") + .map { it.attr("data-id") } + + // val extractorData = "https://ws1.rapid-cloud.ru/socket.io/?EIO=4&transport=polling" + + // Prevent duplicates + servers.distinct().apmap { + val link = "$mainUrl/ajax/v2/episode/sources?id=$it" + val extractorLink = app.get(link).parsed().link + val hasLoadedExtractorLink = + loadExtractor( + extractorLink, + "https://rapid-cloud.ru/", + subtitleCallback, + callback + ) + if (!hasLoadedExtractorLink) { + extractRabbitStream( + extractorLink, + subtitleCallback, + // Blacklist VidCloud for now + { videoLink -> + if (!videoLink.url.contains("betterstream")) callback(videoLink) + }, + false, + null, + decryptKey = getKey() + ) { sourceName -> sourceName } + } + } + return true + } + + override fun getVideoInterceptor(extractorLink: ExtractorLink): Interceptor { + // Needs to be object instead of lambda to make it compile correctly + return object : Interceptor { + override fun intercept(chain: Interceptor.Chain): okhttp3.Response { + val request = chain.request() + if (request.url.toString().endsWith(".ts") && + request.method != OPTIONS + // No option requests on VidCloud + && + !request.url.toString().contains("betterstream") + ) { + val newRequest = + chain.request() + .newBuilder() + .apply { + sid[extractorLink.url.hashCode()]?.let { sid -> + addHeader("SID", sid) + } + } + .build() + val options = request.newBuilder().method(OPTIONS, request.body).build() + ioSafe { app.baseClient.newCall(options).await() } + + return chain.proceed(newRequest) + } else { + return chain.proceed(chain.request()) + } + } + } + } + + private suspend fun getKey(): String { + return app.get("https://raw.githubusercontent.com/enimax-anime/key/e6/key.txt").text + } + + // #region - Data classes + private data class Response( + @JsonProperty("status") val status: Boolean, + @JsonProperty("html") val html: String + ) { + fun getDocument(): Document { + return Jsoup.parse(html) + } + } + + private data class ZoroSyncData( + @JsonProperty("mal_id") val malId: String?, + @JsonProperty("anilist_id") val aniListId: String?, + ) + + private data class RapidCloudResponse(@JsonProperty("link") val link: String) + // #endregion - Data classes +} diff --git a/HiAnime/src/main/kotlin/com/RowdyAvocado/HiAnimePlugin.kt b/HiAnime/src/main/kotlin/com/RowdyAvocado/HiAnimePlugin.kt new file mode 100644 index 0000000..1d37b39 --- /dev/null +++ b/HiAnime/src/main/kotlin/com/RowdyAvocado/HiAnimePlugin.kt @@ -0,0 +1,12 @@ +package com.RowdyAvocado + +import android.content.Context +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin +import com.lagradost.cloudstream3.plugins.Plugin + +@CloudstreamPlugin +class HiAnimeProviderPlugin : Plugin() { + override fun load(context: Context) { + registerMainAPI(HiAnime()) + } +} diff --git a/HiAnime/src/main/kotlin/com/RowdyAvocado/RabbitStream.kt b/HiAnime/src/main/kotlin/com/RowdyAvocado/RabbitStream.kt new file mode 100644 index 0000000..6bfe88a --- /dev/null +++ b/HiAnime/src/main/kotlin/com/RowdyAvocado/RabbitStream.kt @@ -0,0 +1,871 @@ +package com.RowdyAvocado + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.Episode +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.HomePageList +import com.lagradost.cloudstream3.HomePageResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.addActors +import com.lagradost.cloudstream3.LoadResponse.Companion.addDuration +import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MainPageRequest +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.VPNStatus +import com.lagradost.cloudstream3.apmap +import com.lagradost.cloudstream3.apmapIndexed +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.base64DecodeArray +import com.lagradost.cloudstream3.fixUrl +import com.lagradost.cloudstream3.fixUrlNull +import com.lagradost.cloudstream3.getQualityFromString +import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall +import com.lagradost.cloudstream3.newEpisode +import com.lagradost.cloudstream3.newMovieLoadResponse +import com.lagradost.cloudstream3.newMovieSearchResponse +import com.lagradost.cloudstream3.newTvSeriesLoadResponse +import com.lagradost.cloudstream3.newTvSeriesSearchResponse +import com.lagradost.cloudstream3.toRatingInt +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.getQualityFromName +import com.lagradost.cloudstream3.utils.loadExtractor +import com.lagradost.nicehttp.NiceResponse +import java.net.URI +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.system.measureTimeMillis +import kotlinx.coroutines.delay +import okhttp3.RequestBody.Companion.toRequestBody +import org.jsoup.Jsoup +import org.jsoup.nodes.Element + +open class RabbitStream : MainAPI() { + override var mainUrl = "https://sflix.to" + override var name = "Sflix.to" + + override val hasQuickSearch = false + override val hasMainPage = true + override val hasChromecastSupport = true + override val hasDownloadSupport = true + override val usesWebView = true + override val supportedTypes = + setOf( + TvType.Movie, + TvType.TvSeries, + ) + override val vpnStatus = VPNStatus.None + + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + val html = app.get("$mainUrl/home").text + val document = Jsoup.parse(html) + + val all = ArrayList() + + val map = + mapOf( + "Trending Movies" to "div#trending-movies", + "Trending TV Shows" to "div#trending-tv", + ) + map.forEach { + all.add( + HomePageList( + it.key, + document.select(it.value).select("div.flw-item").map { element -> + element.toSearchResult() + } + ) + ) + } + + document.select("section.block_area.block_area_home.section-id-02").forEach { + val title = it.select("h2.cat-heading").text().trim() + val elements = it.select("div.flw-item").map { element -> element.toSearchResult() } + all.add(HomePageList(title, elements)) + } + + return HomePageResponse(all) + } + + override suspend fun search(query: String): List { + val url = "$mainUrl/search/${query.replace(" ", "-")}" + val html = app.get(url).text + val document = Jsoup.parse(html) + + return document.select("div.flw-item").map { + val title = it.select("h2.film-name").text() + val href = fixUrl(it.select("a").attr("href")) + val year = it.select("span.fdi-item").text().toIntOrNull() + val image = it.select("img").attr("data-src") + val isMovie = href.contains("/movie/") + + val metaInfo = it.select("div.fd-infor > span.fdi-item") + // val rating = metaInfo[0].text() + val quality = getQualityFromString(metaInfo.getOrNull(1)?.text()) + + if (isMovie) { + newMovieSearchResponse(name = title, url = href, type = TvType.Movie, fix = true) { + posterUrl = image + this.year = year + this.quality = quality + } + } else { + newTvSeriesSearchResponse( + name = title, + url = href, + type = TvType.TvSeries, + fix = true + ) { + posterUrl = image + // this.year = year + this.quality = quality + } + } + } + } + + override suspend fun load(url: String): LoadResponse { + val document = app.get(url).document + + val details = document.select("div.detail_page-watch") + val img = details.select("img.film-poster-img") + val posterUrl = img.attr("src") + val title = img.attr("title") ?: throw ErrorLoadingException("No Title") + + /* + val year = Regex("""[Rr]eleased:\s*(\d{4})""").find( + document.select("div.elements").text() + )?.groupValues?.get(1)?.toIntOrNull() + val duration = Regex("""[Dd]uration:\s*(\d*)""").find( + document.select("div.elements").text() + )?.groupValues?.get(1)?.trim()?.plus(" min")*/ + var duration = document.selectFirst(".fs-item > .duration")?.text()?.trim() + var year: Int? = null + var tags: List? = null + var cast: List? = null + val youtubeTrailer = document.selectFirst("iframe#iframe-trailer")?.attr("data-src") + val rating = + document.selectFirst(".fs-item > .imdb") + ?.text() + ?.trim() + ?.removePrefix("IMDB:") + ?.toRatingInt() + + document.select("div.elements > .row > div > .row-line").forEach { element -> + val type = element.select(".type").text() ?: return@forEach + when { + type.contains("Released") -> { + year = + Regex("\\d+") + .find(element.ownText() ?: return@forEach) + ?.groupValues + ?.firstOrNull() + ?.toIntOrNull() + } + type.contains("Genre") -> { + tags = element.select("a").mapNotNull { it.text() } + } + type.contains("Cast") -> { + cast = element.select("a").mapNotNull { it.text() } + } + type.contains("Duration") -> { + duration = duration ?: element.ownText().trim() + } + } + } + val plot = details.select("div.description").text().replace("Overview:", "").trim() + + val isMovie = url.contains("/movie/") + + // https://sflix.to/movie/free-never-say-never-again-hd-18317 -> 18317 + val idRegex = Regex(""".*-(\d+)""") + val dataId = details.attr("data-id") + val id = + if (dataId.isNullOrEmpty()) + idRegex.find(url)?.groupValues?.get(1) + ?: throw ErrorLoadingException("Unable to get id from '$url'") + else dataId + + val recommendations = + document.select("div.film_list-wrap > div.flw-item").mapNotNull { element -> + val titleHeader = + element.select("div.film-detail > .film-name > a") + ?: return@mapNotNull null + val recUrl = fixUrlNull(titleHeader.attr("href")) ?: return@mapNotNull null + val recTitle = titleHeader.text() ?: return@mapNotNull null + val poster = element.select("div.film-poster > img").attr("data-src") + newMovieSearchResponse( + name = recTitle, + recUrl, + type = + if (recUrl.contains("/movie/")) TvType.Movie + else TvType.TvSeries, + ) { this.posterUrl = poster } + } + + if (isMovie) { + // Movies + val episodesUrl = "$mainUrl/ajax/movie/episodes/$id" + val episodes = app.get(episodesUrl).text + + // Supported streams, they're identical + val sourceIds = + Jsoup.parse(episodes).select("a").mapNotNull { element -> + var sourceId = element.attr("data-id") + val serverName = element.select("span").text().trim() + if (sourceId.isNullOrEmpty()) sourceId = element.attr("data-linkid") + + if (element.select("span").text().trim().isValidServer()) { + if (sourceId.isNullOrEmpty()) { + fixUrlNull(element.attr("href")) to serverName + } else { + "$url.$sourceId".replace("/movie/", "/watch-movie/") to serverName + } + } else { + null + } + } + + val comingSoon = sourceIds.isEmpty() + + return newMovieLoadResponse(title, url, TvType.Movie, sourceIds) { + this.year = year + this.posterUrl = posterUrl + this.plot = plot + addDuration(duration) + addActors(cast) + this.tags = tags + this.recommendations = recommendations + this.comingSoon = comingSoon + addTrailer(youtubeTrailer) + this.rating = rating + } + } else { + val seasonsDocument = app.get("$mainUrl/ajax/v2/tv/seasons/$id").document + val episodes = arrayListOf() + var seasonItems = seasonsDocument.select("div.dropdown-menu.dropdown-menu-model > a") + if (seasonItems.isNullOrEmpty()) + seasonItems = seasonsDocument.select("div.dropdown-menu > a.dropdown-item") + seasonItems.apmapIndexed { season, element -> + val seasonId = element.attr("data-id") + if (seasonId.isNullOrBlank()) return@apmapIndexed + + var episode = 0 + val seasonEpisodes = app.get("$mainUrl/ajax/v2/season/episodes/$seasonId").document + var seasonEpisodesItems = + seasonEpisodes.select("div.flw-item.film_single-item.episode-item.eps-item") + if (seasonEpisodesItems.isNullOrEmpty()) { + seasonEpisodesItems = seasonEpisodes.select("ul > li > a") + } + seasonEpisodesItems.forEach { + val episodeImg = it.select("img") + val episodeTitle = episodeImg.attr("title") ?: it.ownText() + val episodePosterUrl = episodeImg.attr("src") + val episodeData = it.attr("data-id") ?: return@forEach + + episode++ + + val episodeNum = + (it.select("div.episode-number").text() ?: episodeTitle).let { str -> + Regex("""\d+""") + .find(str) + ?.groupValues + ?.firstOrNull() + ?.toIntOrNull() + } + ?: episode + + episodes.add( + newEpisode(Pair(url, episodeData)) { + this.posterUrl = fixUrlNull(episodePosterUrl) + this.name = episodeTitle?.removePrefix("Episode $episodeNum: ") + this.season = season + 1 + this.episode = episodeNum + } + ) + } + } + + return newTvSeriesLoadResponse(title, url, TvType.TvSeries, episodes) { + this.posterUrl = posterUrl + this.year = year + this.plot = plot + addDuration(duration) + addActors(cast) + this.tags = tags + this.recommendations = recommendations + addTrailer(youtubeTrailer) + this.rating = rating + } + } + } + + data class Tracks( + @JsonProperty("file") val file: String?, + @JsonProperty("label") val label: String?, + @JsonProperty("kind") val kind: String? + ) + + data class Sources( + @JsonProperty("file") val file: String?, + @JsonProperty("type") val type: String?, + @JsonProperty("label") val label: String? + ) + + data class SourceObject( + @JsonProperty("sources") val sources: List? = null, + @JsonProperty("sources_1") val sources1: List? = null, + @JsonProperty("sources_2") val sources2: List? = null, + @JsonProperty("sourcesBackup") val sourcesBackup: List? = null, + @JsonProperty("tracks") val tracks: List? = null + ) + + data class SourceObjectEncrypted( + @JsonProperty("sources") val sources: String?, + @JsonProperty("encrypted") val encrypted: Boolean?, + @JsonProperty("sources_1") val sources1: String?, + @JsonProperty("sources_2") val sources2: String?, + @JsonProperty("sourcesBackup") val sourcesBackup: String?, + @JsonProperty("tracks") val tracks: List? + ) + + data class IframeJson( + // @JsonProperty("type") val type: String? = null, + @JsonProperty("link") val link: String? = null, + // @JsonProperty("sources") val sources: ArrayList = arrayListOf(), + // @JsonProperty("tracks") val tracks: ArrayList = arrayListOf(), + // @JsonProperty("title") val title: String? = null + ) + + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + val urls = + (tryParseJson>(data)?.let { (prefix, server) -> + val episodesUrl = "$mainUrl/ajax/v2/episode/servers/$server" + + // Supported streams, they're identical + app.get(episodesUrl).document.select("a").mapNotNull { element -> + val id = element.attr("data-id") ?: return@mapNotNull null + val serverName = element.select("span").text().trim() + if (element.select("span").text().trim().isValidServer()) { + "$prefix.$id".replace("/tv/", "/watch-tv/") to serverName + } else { + null + } + } + } + ?: tryParseJson>>(data))?.distinct() + + urls?.apmap { (url, serverName) -> + suspendSafeApiCall { + // Possible without token + + // val response = app.get(url) + // val key = + // + // response.document.select("script[src*=https://www.google.com/recaptcha/api.js?render=]") + // .attr("src").substringAfter("render=") + // val token = getCaptchaToken(mainUrl, key) ?: + // return@suspendSafeApiCall + + val serverId = url?.substringAfterLast(".") ?: return@suspendSafeApiCall + val iframeLink = + app.get("${this.mainUrl}/ajax/get_link/$serverId").parsed().link + ?: return@suspendSafeApiCall + + // Some smarter ws11 or w10 selection might be required in the future. + // val extractorData = + // + // "https://ws11.rabbitstream.net/socket.io/?EIO=4&transport=polling" + val res = + !loadExtractor(iframeLink, null, subtitleCallback) { extractorLink -> + callback.invoke( + ExtractorLink( + source = serverName, + name = serverName, + url = extractorLink.url, + referer = extractorLink.referer, + quality = extractorLink.quality, + type = extractorLink.type, + headers = extractorLink.headers, + extractorData = extractorLink.extractorData + ) + ) + } + + if (res) { + extractRabbitStream( + iframeLink, + subtitleCallback, + callback, + false, + decryptKey = getKey() + ) { it } + } + } + } + + return !urls.isNullOrEmpty() + } + + // override suspend fun extractorVerifierJob(extractorData: String?) { + // runSflixExtractorVerifierJob(this, extractorData, "https://rabbitstream.net/") + // } + + private fun Element.toSearchResult(): SearchResponse { + val inner = this.selectFirst("div.film-poster") + val img = inner!!.select("img") + val title = img.attr("title") + val posterUrl = img.attr("data-src") ?: img.attr("src") + val href = fixUrl(inner.select("a").attr("href")) + val isMovie = href.contains("/movie/") + val otherInfo = + this.selectFirst("div.film-detail > div.fd-infor")?.select("span")?.toList() + ?: listOf() + // var rating: Int? = null + var year: Int? = null + var quality: SearchQuality? = null + when (otherInfo.size) { + 1 -> { + year = otherInfo[0].text().trim().toIntOrNull() + } + 2 -> { + year = otherInfo[0].text().trim().toIntOrNull() + } + 3 -> { + // rating = otherInfo[0]?.text()?.toRatingInt() + quality = getQualityFromString(otherInfo[1].text()) + year = otherInfo[2].text().trim().toIntOrNull() + } + } + + return if (isMovie) { + newMovieSearchResponse(name = title, url = href, type = TvType.Movie, fix = true) { + this.posterUrl = posterUrl + this.year = year + this.quality = quality + } + } else { + newTvSeriesSearchResponse( + name = title, + url = href, + type = TvType.TvSeries, + fix = true + ) { + this.posterUrl = posterUrl + // this.year = year + this.quality = quality + } + } + } + + companion object { + data class PollingData( + @JsonProperty("sid") val sid: String? = null, + @JsonProperty("upgrades") val upgrades: ArrayList = arrayListOf(), + @JsonProperty("pingInterval") val pingInterval: Int? = null, + @JsonProperty("pingTimeout") val pingTimeout: Int? = null + ) + + /* + # python code to figure out the time offset based on code if necessary + chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_" + code = "Nxa_-bM" + total = 0 + for i, char in enumerate(code[::-1]): + index = chars.index(char) + value = index * 64**i + total += value + print(f"total {total}") + */ + private fun generateTimeStamp(): String { + val chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_" + var code = "" + var time = unixTimeMS + while (time > 0) { + code += chars[(time % (chars.length)).toInt()] + time /= chars.length + } + return code.reversed() + } + + suspend fun getKey(): String? { + return app.get("https://e4.tvembed.cc/e4").text + } + + /** Generates a session 1 Get request. */ + private suspend fun negotiateNewSid(baseUrl: String): PollingData? { + // Tries multiple times + for (i in 1..5) { + val jsonText = + app.get("$baseUrl&t=${generateTimeStamp()}").text.replaceBefore("{", "") + // println("Negotiated sid $jsonText") + parseJson(jsonText)?.let { + return it + } + delay(1000L * i) + } + return null + } + + /** + * Generates a new session if the request fails + * @return the data and if it is new. + */ + private suspend fun getUpdatedData( + response: NiceResponse, + data: PollingData, + baseUrl: String + ): Pair { + if (!response.okhttpResponse.isSuccessful) { + return negotiateNewSid(baseUrl)?.let { it to true } ?: (data to false) + } + return data to false + } + + private suspend fun initPolling( + extractorData: String, + referer: String + ): Pair { + val headers = + mapOf( + "Referer" to referer // "https://rabbitstream.net/" + ) + + val data = negotiateNewSid(extractorData) ?: return null to null + app.post( + "$extractorData&t=${generateTimeStamp()}&sid=${data.sid}", + requestBody = "40".toRequestBody(), + headers = headers + ) + + // This makes the second get request work, and re-connect work. + val reconnectSid = + parseJson( + app.get( + "$extractorData&t=${generateTimeStamp()}&sid=${data.sid}", + headers = headers + ) + // .also { println("First get + // ${it.text}") } + .text + .replaceBefore("{", "") + ) + .sid + + // This response is used in the post requests. Same contents in all it seems. + // val authInt = + // app.get( + // "$extractorData&t=${generateTimeStamp()}&sid=${data.sid}", + // timeout = 60, + // headers = headers + // ) + // .text + // // .also { println("Second get ${it}") } + // // Dunno if it's actually generated like this, just guessing. + // .toIntOrNull() + // ?.plus(1) + // ?: 3 + + return data to reconnectSid + } + + suspend fun runSflixExtractorVerifierJob(extractorData: String?, referer: String) { + if (extractorData == null) return + val headers = + mapOf( + "Referer" to referer // "https://rabbitstream.net/" + ) + + lateinit var data: PollingData + var reconnectSid = "" + + initPolling(extractorData, referer).also { + data = it.first ?: throw RuntimeException("Data Null") + reconnectSid = it.second ?: throw RuntimeException("ReconnectSid Null") + } + + // Prevents them from fucking us over with doing a while(true){} loop + val interval = maxOf(data.pingInterval?.toLong()?.plus(2000) ?: return, 10000L) + var reconnect = false + var newAuth = false + + while (true) { + val authData = + when { + newAuth -> "40" + reconnect -> """42["_reconnect", "$reconnectSid"]""" + else -> "3" + } + + val url = "${extractorData}&t=${generateTimeStamp()}&sid=${data.sid}" + + getUpdatedData( + app.post(url, json = authData, headers = headers), + data, + extractorData + ) + .also { + newAuth = it.second + data = it.first + } + + // .also { println("Sflix post job ${it.text}") } + + val time = measureTimeMillis { + // This acts as a timeout + val getResponse = app.get(url, timeout = interval / 1000, headers = headers) + // .also { println("Sflix get job ${it.text}") } + reconnect = getResponse.text.contains("sid") + } + // Always waits even if the get response is instant, to prevent a while true loop. + if (time < interval - 4000) delay(4000) + } + } + + // Only scrape servers with these names + fun String?.isValidServer(): Boolean { + val list = listOf("upcloud", "vidcloud", "streamlare") + return list.contains(this?.lowercase(Locale.ROOT)) + } + + // For re-use in Zoro + private suspend fun Sources.toExtractorLink( + caller: MainAPI, + name: String, + extractorData: String? = null, + ): List? { + return this.file?.let { file -> + // println("FILE::: $file") + val isM3u8 = + URI(this.file).path.endsWith(".m3u8") || + this.type.equals("hls", ignoreCase = true) + return if (isM3u8) { + suspendSafeApiCall { + M3u8Helper() + .m3u8Generation( + M3u8Helper.M3u8Stream( + this.file, + null, + mapOf("Referer" to "https://mzzcloud.life/") + ), + false + ) + .map { stream -> + ExtractorLink( + caller.name, + "${caller.name} $name", + stream.streamUrl, + caller.mainUrl, + getQualityFromName(stream.quality?.toString()), + true, + extractorData = extractorData + ) + } + } + .takeIf { !it.isNullOrEmpty() } + ?: listOf( + // Fallback if m3u8 extractor fails + ExtractorLink( + caller.name, + "${caller.name} $name", + this.file, + caller.mainUrl, + getQualityFromName(this.label), + isM3u8, + extractorData = extractorData + ) + ) + } else { + listOf( + ExtractorLink( + caller.name, + caller.name, + file, + caller.mainUrl, + getQualityFromName(this.label), + false, + extractorData = extractorData + ) + ) + } + } + } + + private fun Tracks.toSubtitleFile(): SubtitleFile? { + return this.file?.let { SubtitleFile(this.label ?: "Unknown", it) } + } + + private fun md5(input: ByteArray): ByteArray { + return MessageDigest.getInstance("MD5").digest(input) + } + + private fun generateKey(salt: ByteArray, secret: ByteArray): ByteArray { + var key = md5(secret + salt) + var currentKey = key + while (currentKey.size < 48) { + key = md5(key + secret + salt) + currentKey += key + } + return currentKey + } + + private fun decryptSourceUrl(decryptionKey: ByteArray, sourceUrl: String): String { + val cipherData = base64DecodeArray(sourceUrl) + val encrypted = cipherData.copyOfRange(16, cipherData.size) + val aesCBC = Cipher.getInstance("AES/CBC/PKCS5Padding") + + Objects.requireNonNull(aesCBC) + .init( + Cipher.DECRYPT_MODE, + SecretKeySpec(decryptionKey.copyOfRange(0, 32), "AES"), + IvParameterSpec(decryptionKey.copyOfRange(32, decryptionKey.size)) + ) + val decryptedData = aesCBC!!.doFinal(encrypted) + return String(decryptedData, StandardCharsets.UTF_8) + } + + private inline fun decryptMapped(input: String, key: String): T? { + return tryParseJson(decrypt(input, key)) + } + + private fun decrypt(input: String, key: String): String { + return decryptSourceUrl( + generateKey(base64DecodeArray(input).copyOfRange(8, 16), key.toByteArray()), + input + ) + } + + suspend fun MainAPI.extractRabbitStream( + url: String, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + useSidAuthentication: Boolean, + /** Used for extractorLink name, input: Source name */ + extractorData: String? = null, + decryptKey: String? = null, + nameTransformer: (String) -> String, + ) = suspendSafeApiCall { + // https://rapid-cloud.ru/embed-6/dcPOVRE57YOT?z= -> https://rapid-cloud.ru/embed-6 + val mainIframeUrl = url.substringBeforeLast("/") + val mainIframeId = + url.substringAfterLast("/") + .substringBefore( + "?" + ) // https://rapid-cloud.ru/embed-6/dcPOVRE57YOT?z= -> dcPOVRE57YOT + // val iframe = app.get(url, referer = mainUrl) + // val iframeKey = + // + // iframe.document.select("script[src*=https://www.google.com/recaptcha/api.js?render=]") + // .attr("src").substringAfter("render=") + // val iframeToken = getCaptchaToken(url, iframeKey) + // val number = + // Regex("""recaptchaNumber = + // '(.*?)'""").find(iframe.text)?.groupValues?.get(1) + + var sid: String? = null + if (useSidAuthentication && extractorData != null) { + negotiateNewSid(extractorData)?.also { pollingData -> + app.post( + "$extractorData&t=${generateTimeStamp()}&sid=${pollingData.sid}", + requestBody = "40".toRequestBody(), + timeout = 60 + ) + val text = + app.get( + "$extractorData&t=${generateTimeStamp()}&sid=${pollingData.sid}", + timeout = 60 + ) + .text + .replaceBefore("{", "") + + sid = parseJson(text).sid + ioSafe { + app.get("$extractorData&t=${generateTimeStamp()}&sid=${pollingData.sid}") + } + } + } + val getSourcesUrl = + "${ + mainIframeUrl.replace( + "/embed", + "/ajax/embed" + ) + }/getSources?id=$mainIframeId${sid?.let { "$&sId=$it" } ?: ""}" + val response = + app.get( + getSourcesUrl, + referer = mainUrl, + headers = + mapOf( + "X-Requested-With" to "XMLHttpRequest", + "Accept" to "*/*", + "Accept-Language" to "en-US,en;q=0.5", + "Connection" to "keep-alive", + "TE" to "trailers" + ) + ) + + val sourceObject = + if (decryptKey != null) { + val encryptedMap = response.parsedSafe() + val sources = encryptedMap?.sources + if (sources == null || encryptedMap.encrypted == false) { + response.parsedSafe() + } else { + val decrypted = decryptMapped>(sources, decryptKey) + SourceObject(sources = decrypted, tracks = encryptedMap.tracks) + } + } else { + response.parsedSafe() + } + ?: return@suspendSafeApiCall + + sourceObject.tracks?.forEach { track -> + track?.toSubtitleFile()?.let { subtitleFile -> + subtitleCallback.invoke(subtitleFile) + } + } + + val list = + listOf( + sourceObject.sources to "source 1", + sourceObject.sources1 to "source 2", + sourceObject.sources2 to "source 3", + sourceObject.sourcesBackup to "source backup" + ) + + list.forEach { subList -> + subList.first?.forEach { source -> + source?.toExtractorLink( + this, + nameTransformer(subList.second), + extractorData, + ) + ?.forEach { + // Sets Zoro SID used for video loading + // (this as? + // ZoroProvider)?.sid?.set(it.url.hashCode(), sid) + callback(it) + } + } + } + } + } +} diff --git a/Onstream/TODO b/Onstream/TODO new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 403b4f1..6d55a65 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,38 @@ -**⚠️ This is currently under development, dont use it yet if you're not comfortable with constantly merging new changes** +# Cloudstream3 Plugin -# `Cloudstream3 Plugin Repo Template` +> Attempt to write some plugins for cloudstream -Template for a [Cloudstream3](https://github.com/recloudstream) plugin repo +Mnemosyne provider -**⚠️ Make sure you check "Include all branches" when using this template** +* Sflix (WIP from scratch) :x: - -## Getting started with writing your first plugin +* HiAnime (from Rowdy-Avocado) :white_check_mark: +* Aniwave (from Rowdy-Avocado) :white_check_mark: +* SuperStream (from Hexated) :white_check_mark: -This template includes 1 example plugin. +* ZoroTV (TODO from scratch) :x: +* Onstream (TODO from scratch) :x: +* SoraStream (from Hexated) :x: -1. Open the root build.gradle.kts, read the comments and replace all the placeholders -2. Familiarize yourself with the project structure. Most files are commented -3. Build or deploy your first plugin using: - - Windows: `.\gradlew.bat ExampleProvider:make` or `.\gradlew.bat ExampleProvider:deployWithAdb` - - Linux & Mac: `./gradlew ExampleProvider:make` or `./gradlew ExampleProvider:deployWithAdb` -## License +## Docs -Everything in this repo is released into the public domain. You may use it however you want with no conditions whatsoever +Build and deployment: :ok: (https://shorturl.at/WbYNF) -## Attribution +* https://recloudstream.github.io/csdocs/devs/create-your-own-providers/#3-loading-the-show-page +* https://recloudstream.github.io/csdocs/devs/scraping/starting/ -This template as well as the gradle plugin and the whole plugin system is **heavily** based on [Aliucord](https://github.com/Aliucord). -*Go use it, it's a great mobile discord client mod!* + +## Debug + +```ps1 +adb devices +adb connect 192.168.1.X:5555 +adb logcat -s mnemo +``` + + +## Notes + +* vidcloud / upcloud uses https://rabbitstream.net/ +* 9animetv is the same as Aniwave ? \ No newline at end of file diff --git a/ExampleProvider/build.gradle.kts b/Sflix/build.gradle.kts similarity index 91% rename from ExampleProvider/build.gradle.kts rename to Sflix/build.gradle.kts index cf02229..f8eb7ec 100644 --- a/ExampleProvider/build.gradle.kts +++ b/Sflix/build.gradle.kts @@ -9,9 +9,8 @@ version = -1 cloudstream { // All of these properties are optional, you can safely remove them - - description = "Lorem ipsum" - authors = listOf("Cloudburst") + description = "Sflix" + authors = listOf("Mnemosyne") /** * Status int as the following: @@ -21,9 +20,7 @@ cloudstream { * 3: Beta only * */ status = 1 - tvTypes = listOf("Movie") - requiresResources = true language = "en" diff --git a/ExampleProvider/src/main/AndroidManifest.xml b/Sflix/src/main/AndroidManifest.xml similarity index 100% rename from ExampleProvider/src/main/AndroidManifest.xml rename to Sflix/src/main/AndroidManifest.xml diff --git a/ExampleProvider/src/main/kotlin/com/example/BlankFragment.kt b/Sflix/src/main/kotlin/com/example/BlankFragment.kt similarity index 100% rename from ExampleProvider/src/main/kotlin/com/example/BlankFragment.kt rename to Sflix/src/main/kotlin/com/example/BlankFragment.kt diff --git a/ExampleProvider/src/main/kotlin/com/example/ExamplePlugin.kt b/Sflix/src/main/kotlin/com/example/SflixPlugin.kt similarity index 62% rename from ExampleProvider/src/main/kotlin/com/example/ExamplePlugin.kt rename to Sflix/src/main/kotlin/com/example/SflixPlugin.kt index a2c4135..c79cb50 100644 --- a/ExampleProvider/src/main/kotlin/com/example/ExamplePlugin.kt +++ b/Sflix/src/main/kotlin/com/example/SflixPlugin.kt @@ -3,6 +3,8 @@ package com.example import com.lagradost.cloudstream3.plugins.CloudstreamPlugin import com.lagradost.cloudstream3.plugins.Plugin import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.extractorApis import android.content.Context import android.util.Log import androidx.appcompat.app.AppCompatActivity @@ -14,6 +16,12 @@ class TestPlugin: Plugin() { override fun load(context: Context) { activity = context as AppCompatActivity // All providers should be added in this manner + registerExtractorAPI(DoodRe()) + registerExtractorAPI(MixDropCo()) + + // Force this extractor to be first in the list + addExtractor(Upstream()) + registerMainAPI(ExampleProvider(this)) openSettings = { ctx -> @@ -21,4 +29,10 @@ class TestPlugin: Plugin() { frag.show(activity!!.supportFragmentManager, "Frag") } } + + + private fun addExtractor(element: ExtractorApi) { + element.sourcePlugin = __filename + extractorApis.add(0, element) + } } \ No newline at end of file diff --git a/Sflix/src/main/kotlin/com/example/SflixProvider.kt b/Sflix/src/main/kotlin/com/example/SflixProvider.kt new file mode 100644 index 0000000..eb93c92 --- /dev/null +++ b/Sflix/src/main/kotlin/com/example/SflixProvider.kt @@ -0,0 +1,277 @@ +package com.example + +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.extractorApis +import com.lagradost.cloudstream3.utils.loadExtractor +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.extractors.DoodLaExtractor +import com.lagradost.cloudstream3.extractors.MixDrop +import com.fasterxml.jackson.annotation.JsonProperty +import org.jsoup.Jsoup + +class ExampleProvider(val plugin: TestPlugin) : MainAPI() { + // all providers must be an instance of MainAPI + override var mainUrl = "https://sflix.to" + override var name = "Mnemosyne" + override val supportedTypes = setOf(TvType.Movie, TvType.TvSeries, TvType.Anime) + override var lang = "en" + + // enable this when your provider has a main page + override val hasMainPage = true + + // this function gets called when you search for something + override suspend fun search(query: String): List { + val escaped = query.replace(' ', '-') + val url = "$mainUrl/search/${escaped}" + + return app.get( + url, + ).document.select(".flw-item").mapNotNull { article -> + val name = article.selectFirst("h2 > a")?.text() ?: "" + val poster = article.selectFirst("img")?.attr("data-src") + val url = article.selectFirst("a.btn")?.attr("href") ?: "" + val type = article.selectFirst("strong")?.text() ?: "" + + if (type == "Movie") { + newMovieSearchResponse(name, url) { + posterUrl = poster + } + } + else { + newTvSeriesSearchResponse(name, url) { + posterUrl = poster + } + } + } + } + + + override val mainPage = mainPageOf( + "tv-show" to "TV Show", + "movie" to "Movie", + "top-imdb" to "Top-IMDB", + ) + + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + // page: An integer > 0, starts on 1 and counts up, Depends on how much the user has scrolled. + val url = "$mainUrl/${request.data}?page=$page" + var list = mutableListOf() + val res = app.get(url).document + + res.select(".flw-item").mapNotNull { article -> + val name = article.selectFirst("h2 > a")?.text() ?: "" + val poster = article.selectFirst("img")?.attr("data-src") + val url = article.selectFirst("a.btn")?.attr("href") ?: "" + + list.add(newAnimeSearchResponse(name, url){ + this.posterUrl = poster + }) + } + + return newHomePageResponse( + list = HomePageList( + name = request.name, + list = list, + isHorizontalImages = true + ), + hasNext = true + ) + } + + + // start with movie easier than tv series + // this function only displays info about movies and series + override suspend fun load(url: String): LoadResponse { + val document = app.get(url).document + + val posterUrl = document.selectFirst("img.film-poster-img")?.attr("src") + val details = document.select("div.detail_page-watch") + val img = details.select("img.film-poster-img") + val title = img.attr("title") ?: throw ErrorLoadingException("No Title") + val plot = details.select("div.description").text().replace("Overview:", "").trim() + val rating = document.selectFirst(".fs-item > .imdb")?.text()?.trim()?.removePrefix("IMDB:")?.toRatingInt() + val isMovie = url.contains("/movie/") + + val idRegex = Regex(""".*-(\d+)""") + val dataId = details.attr("data-id") + val id = if (dataId.isNullOrEmpty()) + idRegex.find(url)?.groupValues?.get(1) + ?: throw ErrorLoadingException("Unable to get id from '$url'") + else dataId + + // duration duration = duration ?: element.ownText().trim() + // Casts cast = element.select("a").mapNotNull { it.text() } + // genre + // country + // production + // tags = element.select("a").mapNotNull { it.text() } + + if (isMovie){ + val episodesUrl = "$mainUrl/ajax/episode/list/$id" + val episodes = app.get(episodesUrl).text + + val sourceIds: List = Jsoup.parse(episodes).select("a").mapNotNull { element -> + var sourceId: String? = element.attr("data-id") + + if (sourceId.isNullOrEmpty()) + sourceId = element.attr("data-linkid") + + Log.d("mnemo", "sourceId: $sourceId, type: ${sourceId?.javaClass?.name}") + if (sourceId.isNullOrEmpty()) { + null + } + else{ + "$mainUrl/ajax/episode/sources/$sourceId" + } + } + Log.d("mnemo", sourceIds.toString()); + val comingSoon = sourceIds.isEmpty() + + return newMovieLoadResponse(title, url, TvType.Movie, sourceIds) { + this.posterUrl = posterUrl + this.plot = plot + this.comingSoon = comingSoon + this.rating = rating + // this.year = year + // addDuration(duration) + // addActors(cast) + // this.tags = tags + // this.recommendations = recommendations + // addTrailer(youtubeTrailer) + } + + } + else{ + // TV series + val seasonsDocument = app.get("$mainUrl/ajax/v2/tv/seasons/$id").document + val episodes = arrayListOf() + var seasonItems = seasonsDocument.select("div.dropdown-menu.dropdown-menu-model > a") + if (seasonItems.isNullOrEmpty()) + seasonItems = seasonsDocument.select("div.dropdown-menu > a.dropdown-item") + seasonItems.apmapIndexed { season, element -> + val seasonId = element.attr("data-id") + if (seasonId.isNullOrBlank()) return@apmapIndexed + + var episode = 0 + val seasonEpisodes = app.get("$mainUrl/ajax/v2/season/episodes/$seasonId").document + var seasonEpisodesItems = + seasonEpisodes.select("div.flw-item.film_single-item.episode-item.eps-item") + if (seasonEpisodesItems.isNullOrEmpty()) { + seasonEpisodesItems = + seasonEpisodes.select("ul > li > a") + } + seasonEpisodesItems.forEach { + val episodeImg = it?.select("img") + val episodeTitle = episodeImg?.attr("title") ?: it.ownText() + val episodePosterUrl = episodeImg?.attr("src") + val episodeData = it.attr("data-id") ?: return@forEach + + episode++ + + val episodeNum = + (it.select("div.episode-number").text() + ?: episodeTitle).let { str -> + Regex("""\d+""").find(str)?.groupValues?.firstOrNull() + ?.toIntOrNull() + } ?: episode + + episodes.add( + newEpisode(Pair(url, episodeData)) { + this.posterUrl = fixUrlNull(episodePosterUrl) + this.name = episodeTitle?.removePrefix("Episode $episodeNum: ") + this.season = season + 1 + this.episode = episodeNum + } + ) + } + } + + Log.d("mnemo", episodes.toString()); + return newTvSeriesLoadResponse(title, url, TvType.TvSeries, episodes) { + this.posterUrl = posterUrl + this.plot = plot + this.rating = rating + // this.year = year + // addDuration(duration) + // addActors(cast) + // this.tags = tags + // this.recommendations = recommendations + // addTrailer(youtubeTrailer) + } + } + } + + + + // this function loads the links (upcloud/vidcloud/doodstream/other) + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + Log.d("mnemo", "LOADLINKS") + Log.d("mnemo", data) + + val dataList = data + .replace("[", "") + .replace("]", "") + .replace("\"", "") + .split(",") + + val links = dataList.mapNotNull { url -> + var jsonData = app.get(url).parsed() + // {"type":"iframe","link":"https://example/e/xxxxx","sources":[],"tracks":[],"title":""} + + if (jsonData.link.startsWith("https://dood.watch")){ + // Cloudflare bypass + // dood.re need a correct DNS resolver != ISP + var replacedLink = jsonData.link.replace("dood.watch", "dood.re") + Log.d("mnemo", "Handling dood ${replacedLink}") + + // Extract the link and display the content + if (!loadExtractor(replacedLink, subtitleCallback, callback)){ + Log.d("mnemo", "Couldn't extract dood") + } + } + else if (jsonData.link.startsWith("https://rabbitstream")){ + // rabbitstream api player-v2-e4 is not compatible yet + Log.d("mnemo", "Handling rabbit ${jsonData.link}") + if (!loadExtractor(jsonData.link, subtitleCallback, callback)){ + Log.d("mnemo", "Couldn't extract rabbit/vidcloud/upcloud") + } + } + else{ + Log.d("mnemo", "Handling ${jsonData.link}") + if (!loadExtractor(jsonData.link, subtitleCallback, callback)){ + Log.d("mnemo", "Couldn't extract other provider") + } + } + } + + return true + } + + data class SrcJSON( + @JsonProperty("type") val type: String = "", + @JsonProperty("link") val link: String = "", + @JsonProperty("sources") val sources: ArrayList = arrayListOf(), + @JsonProperty("tracks") val tracks: ArrayList = arrayListOf(), + @JsonProperty("title") val title: String = "", + ) +} + +class DoodRe : DoodLaExtractor() { + override var mainUrl = "https://dood.re" +} + +class MixDropCo : MixDrop(){ + override var mainUrl = "https://mixdrop.co" +} \ No newline at end of file diff --git a/Sflix/src/main/kotlin/com/example/UpstreamExtractor.kt b/Sflix/src/main/kotlin/com/example/UpstreamExtractor.kt new file mode 100644 index 0000000..f6f7aca --- /dev/null +++ b/Sflix/src/main/kotlin/com/example/UpstreamExtractor.kt @@ -0,0 +1,67 @@ +package com.example + +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.M3u8Helper +import android.util.Log + +class Upstream : ExtractorApi() { + override val name: String = "Upstream" + override val mainUrl: String = "https://upstream.to" + override val requiresReferer = true + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + Log.d("mnemo", "Upstream extractor enabled") + val doc = app.get(url, referer = referer).text + if (doc.isNotBlank()) { + + // Find the matches in |file|01097|01|co|upstreamcdn|s18|HTTP + val regex = """\|file\|(\d+)\|(\d+)\|(\w+)\|(\w+)\|(\w+)\|HTTP""".toRegex() + val matchResult = regex.find(doc) + if (matchResult != null) { + val (n2, n1, tld, domain, subdomain) = matchResult.destructured + val fullDomain = "$subdomain.$domain.$tld" + + Log.d("mnemo", "n2 = \"$n2\"") + Log.d("mnemo", "n1 = \"$n1\"") + Log.d("mnemo", "domain = \"$fullDomain\"") + + var id = "9qx7lhanoezn_n" // master|9qx7lhanoezn_n|hls2 + + + var t = "bYYSztvRHlImhy_PjVqV91W7EoXRu4LXALz76pLJPFI" // sp|10800|bYYSztvRHlImhy_PjVqV91W7EoXRu4LXALz76pLJPFI|m3u8 + var e = "10800" + + + var s = "1719404641" // |data|1719404641|5485070||hide| + var f = "5485070" + + + var i = "0.0" // &i=0.0&5 + var sp = "0" // TODO + + + // https://s18.upstreamcdn.co/hls2/01/01097/9qx7lhnoezn_n/master.m3u8?t=bYYSztvRHlImhy_PjVqV91W7oXRu4LXALz76pLJPFI&s=1719404641&e=10800&f=5485070&i=0.0&sp=0 + val linkUrl = "https://${fullDomain}/hls2/${n1}/${n2}/${id}/master.m3u8?t=${t}&s=${s}&e=${e}&f=${f}&i=${i}&sp=${sp}" + + Log.d("mnemo", "Testing ${linkUrl}") + M3u8Helper.generateM3u8( + this.name, + linkUrl, + "$mainUrl/", + headers = mapOf("Origin" to mainUrl) + ).forEach(callback) + } + } + else{ + Log.d("mnemo", "Got nothing, are you banned ?") + } + } +} \ No newline at end of file diff --git a/ExampleProvider/src/main/res/drawable/ic_android_24dp.xml b/Sflix/src/main/res/drawable/ic_android_24dp.xml similarity index 100% rename from ExampleProvider/src/main/res/drawable/ic_android_24dp.xml rename to Sflix/src/main/res/drawable/ic_android_24dp.xml diff --git a/ExampleProvider/src/main/res/layout/fragment_blank.xml b/Sflix/src/main/res/layout/fragment_blank.xml similarity index 100% rename from ExampleProvider/src/main/res/layout/fragment_blank.xml rename to Sflix/src/main/res/layout/fragment_blank.xml diff --git a/ExampleProvider/src/main/res/values-pl/strings.xml b/Sflix/src/main/res/values-pl/strings.xml similarity index 100% rename from ExampleProvider/src/main/res/values-pl/strings.xml rename to Sflix/src/main/res/values-pl/strings.xml diff --git a/ExampleProvider/src/main/res/values/strings.xml b/Sflix/src/main/res/values/strings.xml similarity index 100% rename from ExampleProvider/src/main/res/values/strings.xml rename to Sflix/src/main/res/values/strings.xml diff --git a/SoraStream/Icon.png b/SoraStream/Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..639af0348414bf18f7921f7fdcb1c6489a0aee72 GIT binary patch literal 41051 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 local.properties + echo DUMP_API=$DUMP_API >> local.properties + echo DUMP_KEY=$DUMP_KEY >> local.properties + echo CRUNCHYROLL_BASIC_TOKEN=$CRUNCHYROLL_BASIC_TOKEN >> local.properties + echo CRUNCHYROLL_REFRESH_TOKEN=$CRUNCHYROLL_REFRESH_TOKEN >> local.properties + echo ANICHI_API=$ANICHI_API >> local.properties + echo ANICHI_SERVER=$ANICHI_SERVER >> local.properties + echo ANICHI_ENDPOINT=$ANICHI_ENDPOINT >> local.properties + echo ANICHI_APP=$ANICHI_APP >> local.properties + echo ZSHOW_API=$ZSHOW_API >> local.properties + echo SFMOVIES_API=$SFMOVIES_API >> local.properties + echo CINEMATV_API=$CINEMATV_API >> local.properties + echo GHOSTX_API=$GHOSTX_API >> local.properties + echo SUPERSTREAM_FIRST_API=$SUPERSTREAM_FIRST_API >> local.properties + echo SUPERSTREAM_SECOND_API=$SUPERSTREAM_SECOND_API >> local.properties + echo SUPERSTREAM_THIRD_API=$SUPERSTREAM_THIRD_API >> local.properties + echo SUPERSTREAM_FOURTH_API=$SUPERSTREAM_FOURTH_API >> local.properties + + - name: Build Plugins + run: | + cd $GITHUB_WORKSPACE/src + chmod +x gradlew + ./gradlew make makePluginsJson + cp **/build/*.cs3 $GITHUB_WORKSPACE/builds + cp build/plugins.json $GITHUB_WORKSPACE/builds + + - name: Move Kuramanime + run: | + rm $GITHUB_WORKSPACE/builds/KuramanimeProvider.cs3 || true + cp $GITHUB_WORKSPACE/builds/stored/KuramanimeProvider.cs3 $GITHUB_WORKSPACE/builds + + - name: Push builds + run: | + cd $GITHUB_WORKSPACE/builds + git config --local user.email "actions@github.com" + git config --local user.name "GitHub Actions" + git add . + git commit --amend -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit + git push --force \ No newline at end of file diff --git a/SoraStream/src/main/AndroidManifest.xml b/SoraStream/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c98063f --- /dev/null +++ b/SoraStream/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/SoraStream/src/main/kotlin/com/hexated/Extractors.kt b/SoraStream/src/main/kotlin/com/hexated/Extractors.kt new file mode 100644 index 0000000..e6c623f --- /dev/null +++ b/SoraStream/src/main/kotlin/com/hexated/Extractors.kt @@ -0,0 +1,528 @@ +package com.hexated + +import com.lagradost.cloudstream3.extractors.Filesim +import com.lagradost.cloudstream3.extractors.GMPlayer +import com.lagradost.cloudstream3.extractors.StreamSB +import com.lagradost.cloudstream3.extractors.Voe +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.APIHolder.getCaptchaToken +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.apmap +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.base64Decode +import com.lagradost.cloudstream3.extractors.Jeniusplay +import com.lagradost.cloudstream3.extractors.PixelDrain +import com.lagradost.cloudstream3.utils.* +import java.math.BigInteger +import java.security.MessageDigest + +open class Playm4u : ExtractorApi() { + override val name = "Playm4u" + override val mainUrl = "https://play9str.playm4u.xyz" + override val requiresReferer = true + private val password = "plhq@@@22" + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val document = app.get(url, referer = referer).document + val script = document.selectFirst("script:containsData(idfile =)")?.data() ?: return + val passScript = document.selectFirst("script:containsData(domain_ref =)")?.data() ?: return + + val pass = passScript.substringAfter("CryptoJS.MD5('").substringBefore("')") + val amount = passScript.substringAfter(".toString()), ").substringBefore("));").toInt() + + val idFile = "idfile".findIn(script) + val idUser = "idUser".findIn(script) + val domainApi = "DOMAIN_API".findIn(script) + val nameKeyV3 = "NameKeyV3".findIn(script) + val dataEnc = caesarShift( + mahoa( + "Win32|$idUser|$idFile|$referer", + md5(pass) + ), amount + ).toHex() + + val captchaKey = + document.select("script[src*=https://www.google.com/recaptcha/api.js?render=]") + .attr("src").substringAfter("render=") + val token = getCaptchaToken( + url, + captchaKey, + referer = referer + ) + + val source = app.post( + domainApi, data = mapOf( + "namekey" to nameKeyV3, + "token" to "$token", + "referrer" to "$referer", + "data" to "$dataEnc|${md5(dataEnc + password)}", + ), referer = "$mainUrl/" + ).parsedSafe() + + callback.invoke( + ExtractorLink( + this.name, + this.name, + source?.data ?: return, + "$mainUrl/", + Qualities.P1080.value, + INFER_TYPE + ) + ) + + subtitleCallback.invoke( + SubtitleFile( + source.sub?.substringBefore("|")?.toLanguage() ?: return, + source.sub.substringAfter("|"), + ) + ) + + } + + private fun caesarShift(str: String, amount: Int): String { + var output = "" + val adjustedAmount = if (amount < 0) amount + 26 else amount + for (element in str) { + var c = element + if (c.isLetter()) { + val code = c.code + c = when (code) { + in 65..90 -> ((code - 65 + adjustedAmount) % 26 + 65).toChar() + in 97..122 -> ((code - 97 + adjustedAmount) % 26 + 97).toChar() + else -> c + } + } + output += c + } + return output + } + + private fun mahoa(input: String, key: String): String { + val a = CryptoJS.encrypt(key, input) + return a.replace("U2FsdGVkX1", "") + .replace("/", "|a") + .replace("+", "|b") + .replace("=", "|c") + .replace("|", "-z") + } + + private fun md5(input: String): String { + val md = MessageDigest.getInstance("MD5") + return BigInteger(1, md.digest(input.toByteArray())).toString(16).padStart(32, '0') + } + + private fun String.toHex(): String { + return this.toByteArray().joinToString("") { "%02x".format(it) } + } + + private fun String.findIn(data: String): String { + return "$this\\s*=\\s*[\"'](\\S+)[\"'];".toRegex().find(data)?.groupValues?.get(1) ?: "" + } + + private fun String.toLanguage(): String { + return if (this == "EN") "English" else this + } + + data class Source( + @JsonProperty("data") val data: String? = null, + @JsonProperty("sub") val sub: String? = null, + ) + +} + +open class M4ufree : ExtractorApi() { + override val name = "M4ufree" + override val mainUrl = "https://play.playm4u.xyz" + override val requiresReferer = true + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val document = session.get(url, referer = referer).document + val script = document.selectFirst("script:containsData(idfile =)")?.data() ?: return + + val idFile = "idfile".findIn(script) + val idUser = "idUser".findIn(script) + + val video = session.post( + "https://api-plhq.playm4u.xyz/apidatard/$idUser/$idFile", + data = mapOf("referrer" to "$referer"), + headers = mapOf( + "Accept" to "*/*", + "X-Requested-With" to "XMLHttpRequest", + ) + ).text.let { AppUtils.tryParseJson(it) }?.data + + callback.invoke( + ExtractorLink( + this.name, + this.name, + video ?: return, + referer ?: "", + Qualities.P720.value, + INFER_TYPE + ) + ) + + } + + private fun String.findIn(data: String): String? { + return "$this\\s*=\\s*[\"'](\\S+)[\"'];".toRegex().find(data)?.groupValues?.get(1) + } + + data class Source( + @JsonProperty("data") val data: String? = null, + ) + +} + +open class VCloud : ExtractorApi() { + override val name: String = "V-Cloud" + override val mainUrl: String = "https://v-cloud.bio" + override val requiresReferer = true + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val res = app.get(url) + val doc = res.document + val changedLink = doc.selectFirst("script:containsData(url =)")?.data()?.let { + val regex = """url\s*=\s*['"](.*)['"];""".toRegex() + val doc2 = app.get(regex.find(it)?.groupValues?.get(1) ?: return).text + regex.find(doc2)?.groupValues?.get(1)?.substringAfter("r=") + } + val header = doc.selectFirst("div.card-header")?.text() + app.get( + base64Decode(changedLink ?: return), cookies = res.cookies, headers = mapOf( + "Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" + ) + ).document.select("p.text-success ~ a").apmap { + val link = it.attr("href") + if (link.contains("workers.dev") || it.text().contains("[Server : 1]") || link.contains( + "/dl.php?" + ) + ) { + callback.invoke( + ExtractorLink( + this.name, + this.name, + link, + "", + getIndexQuality(header), + INFER_TYPE + ) + ) + } else { + val direct = if (link.contains("gofile.io")) app.get(link).url else link + loadExtractor(direct, referer, subtitleCallback, callback) + } + } + + } + + private fun getIndexQuality(str: String?): Int { + return Regex("(\\d{3,4})[pP]").find(str ?: "")?.groupValues?.getOrNull(1)?.toIntOrNull() + ?: Qualities.Unknown.value + } + +} + +open class Streamruby : ExtractorApi() { + override val name = "Streamruby" + override val mainUrl = "https://streamruby.com" + override val requiresReferer = true + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val id = "/e/(\\w+)".toRegex().find(url)?.groupValues?.get(1) ?: return + val response = app.post( + "$mainUrl/dl", data = mapOf( + "op" to "embed", + "file_code" to id, + "auto" to "1", + "referer" to "", + ), referer = referer + ) + val script = if (!getPacked(response.text).isNullOrEmpty()) { + getAndUnpack(response.text) + } else { + response.document.selectFirst("script:containsData(sources:)")?.data() + } + val m3u8 = Regex("file:\\s*\"(.*?m3u8.*?)\"").find(script ?: return)?.groupValues?.getOrNull(1) + M3u8Helper.generateM3u8( + name, + m3u8 ?: return, + mainUrl + ).forEach(callback) + } + +} + +open class Uploadever : ExtractorApi() { + override val name = "Uploadever" + override val mainUrl = "https://uploadever.in" + override val requiresReferer = true + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + var res = app.get(url, referer = referer).document + val formUrl = res.select("form").attr("action") + var formData = res.select("form input").associate { it.attr("name") to it.attr("value") } + .filterKeys { it != "go" } + .toMutableMap() + val formReq = app.post(formUrl, data = formData) + + res = formReq.document + val captchaKey = + res.select("script[src*=https://www.google.com/recaptcha/api.js?render=]").attr("src") + .substringAfter("render=") + val token = getCaptchaToken(url, captchaKey, referer = "$mainUrl/") + formData = res.select("form#down input").associate { it.attr("name") to it.attr("value") } + .toMutableMap() + formData["adblock_detected"] = "0" + formData["referer"] = url + res = app.post( + formReq.url, + data = formData + mapOf("g-recaptcha-response" to "$token"), + cookies = formReq.cookies + ).document + val video = res.select("div.download-button a.btn.btn-dow.recaptchav2").attr("href") + + callback.invoke( + ExtractorLink( + this.name, + this.name, + video, + "", + Qualities.Unknown.value, + INFER_TYPE + ) + ) + + } + +} + +open class Netembed : ExtractorApi() { + override var name: String = "Netembed" + override var mainUrl: String = "https://play.netembed.xyz" + override val requiresReferer = true + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val response = app.get(url, referer = referer) + val script = getAndUnpack(response.text) + val m3u8 = Regex("((https:|http:)//.*\\.m3u8)").find(script)?.groupValues?.getOrNull(1) ?: return + + M3u8Helper.generateM3u8(this.name, m3u8, "$mainUrl/").forEach(callback) + } +} + +open class Ridoo : ExtractorApi() { + override val name = "Ridoo" + override var mainUrl = "https://ridoo.net" + override val requiresReferer = true + open val defaulQuality = Qualities.P1080.value + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val response = app.get(url, referer = referer) + val script = if (!getPacked(response.text).isNullOrEmpty()) { + getAndUnpack(response.text) + } else { + response.document.selectFirst("script:containsData(sources:)")?.data() + } + val m3u8 = Regex("file:\\s*\"(.*?m3u8.*?)\"").find(script ?: return)?.groupValues?.getOrNull(1) + val quality = "qualityLabels.*\"(\\d{3,4})[pP]\"".toRegex().find(script)?.groupValues?.get(1) + callback.invoke( + ExtractorLink( + this.name, + this.name, + m3u8 ?: return, + mainUrl, + quality?.toIntOrNull() ?: defaulQuality, + INFER_TYPE + ) + ) + } + +} + +open class Gdmirrorbot : ExtractorApi() { + override val name = "Gdmirrorbot" + override val mainUrl = "https://gdmirrorbot.nl" + override val requiresReferer = true + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + app.get(url, referer = referer).document.select("ul#videoLinks li").apmap { + loadExtractor(it.attr("data-link"), "$mainUrl/", subtitleCallback, callback) + } + } + +} + +open class Streamvid : ExtractorApi() { + override val name = "Streamvid" + override val mainUrl = "https://streamvid.net" + override val requiresReferer = true + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val response = app.get(url, referer = referer) + val script = if (!getPacked(response.text).isNullOrEmpty()) { + getAndUnpack(response.text) + } else { + response.document.selectFirst("script:containsData(sources:)")?.data() + } + val m3u8 = + Regex("src:\\s*\"(.*?m3u8.*?)\"").find(script ?: return)?.groupValues?.getOrNull(1) + M3u8Helper.generateM3u8( + name, + m3u8 ?: return, + mainUrl + ).forEach(callback) + } + +} + +open class Embedrise : ExtractorApi() { + override val name = "Embedrise" + override val mainUrl = "https://embedrise.com" + override val requiresReferer = true + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val res = app.get(url, referer = referer).document + val title = res.select("title").text() + val video = res.select("video#player source").attr("src") + + callback.invoke( + ExtractorLink( + this.name, + this.name, + video, + "$mainUrl/", + getIndexQuality(title), + INFER_TYPE + ) + ) + + } + +} + +class FilemoonNl : Ridoo() { + override val name = "FilemoonNl" + override var mainUrl = "https://filemoon.nl" + override val defaulQuality = Qualities.Unknown.value +} + +class Alions : Ridoo() { + override val name = "Alions" + override var mainUrl = "https://alions.pro" + override val defaulQuality = Qualities.Unknown.value +} + +class Streamwish : Filesim() { + override val name = "Streamwish" + override var mainUrl = "https://streamwish.to" +} + +class UqloadsXyz : Filesim() { + override val name = "Uqloads" + override var mainUrl = "https://uqloads.xyz" +} + +class FilelionsTo : Filesim() { + override val name = "Filelions" + override var mainUrl = "https://filelions.to" +} + +class Pixeldra : PixelDrain() { + override val mainUrl = "https://pixeldra.in" +} + +class TravelR : GMPlayer() { + override val name = "TravelR" + override val mainUrl = "https://travel-russia.xyz" +} + +class Mwish : Filesim() { + override val name = "Mwish" + override var mainUrl = "https://mwish.pro" +} + +class Animefever : Filesim() { + override val name = "Animefever" + override var mainUrl = "https://animefever.fun" +} + +class Multimovies : Ridoo() { + override val name = "Multimovies" + override var mainUrl = "https://multimovies.cloud" +} + +class MultimoviesSB : StreamSB() { + override var name = "Multimovies" + override var mainUrl = "https://multimovies.website" +} + +class Yipsu : Voe() { + override val name = "Yipsu" + override var mainUrl = "https://yip.su" +} + +class Embedwish : Filesim() { + override val name = "Embedwish" + override var mainUrl = "https://embedwish.com" +} +class Flaswish : Ridoo() { + override val name = "Flaswish" + override var mainUrl = "https://flaswish.com" + override val defaulQuality = Qualities.Unknown.value +} + +class Comedyshow : Jeniusplay() { + override val mainUrl = "https://comedyshow.to" + override val name = "Comedyshow" +} \ No newline at end of file diff --git a/SoraStream/src/main/kotlin/com/hexated/SoraExtractor.kt b/SoraStream/src/main/kotlin/com/hexated/SoraExtractor.kt new file mode 100644 index 0000000..a5dd613 --- /dev/null +++ b/SoraStream/src/main/kotlin/com/hexated/SoraExtractor.kt @@ -0,0 +1,2523 @@ +package com.hexated + +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.nicehttp.Requests +import com.lagradost.nicehttp.Session +import com.lagradost.cloudstream3.extractors.helper.AesHelper.cryptoAESHandler +import com.lagradost.nicehttp.RequestBodyTypes +import kotlinx.coroutines.delay +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.select.Elements + +val session = Session(Requests().baseClient) + +object SoraExtractor : SoraStream() { + + suspend fun invokeGoku( + title: String? = null, + year: Int? = null, + season: Int? = null, + lastSeason: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val headers = mapOf("X-Requested-With" to "XMLHttpRequest") + + fun Document.getServers(): List> { + return this.select("a").map { it.attr("data-id") to it.text() } + } + + val media = + app.get("$gokuAPI/ajax/movie/search?keyword=$title", headers = headers).document.select( + "div.item" + ).find { ele -> + val url = ele.selectFirst("a.movie-link")?.attr("href") + val titleMedia = ele.select("h3.movie-name").text() + val titleSlug = title.createSlug() + val yearMedia = ele.select("div.info-split > div:first-child").text().toIntOrNull() + val lastSeasonMedia = + ele.select("div.info-split > div:nth-child(2)").text().substringAfter("SS") + .substringBefore("/").trim().toIntOrNull() + (titleMedia.equals(title, true) || titleMedia.createSlug() + .equals(titleSlug) || url?.contains("$titleSlug-") == true) && (if (season == null) { + yearMedia == year && url?.contains("/movie/") == true + } else { + lastSeasonMedia == lastSeason && url?.contains("/series/") == true + }) + } ?: return + + val serversId = if (season == null) { + val movieId = app.get( + fixUrl( + media.selectFirst("a")?.attr("href") + ?: return, gokuAPI + ) + ).url.substringAfterLast("/") + app.get( + "$gokuAPI/ajax/movie/episode/servers/$movieId", + headers = headers + ).document.getServers() + } else { + val seasonId = app.get( + "$gokuAPI/ajax/movie/seasons/${ + media.selectFirst("a.btn-wl")?.attr("data-id") ?: return + }", headers = headers + ).document.select("a.ss-item").find { it.ownText().equals("Season $season", true) } + ?.attr("data-id") + val episodeId = app.get( + "$gokuAPI/ajax/movie/season/episodes/${seasonId ?: return}", + headers = headers + ).document.select("div.item").find { + it.selectFirst("strong")?.text().equals("Eps $episode:", true) + }?.selectFirst("a")?.attr("data-id") + + app.get( + "$gokuAPI/ajax/movie/episode/servers/${episodeId ?: return}", + headers = headers + ).document.getServers() + } + + serversId.apmap { (id, name) -> + val iframe = + app.get("$gokuAPI/ajax/movie/episode/server/sources/$id", headers = headers) + .parsedSafe()?.data?.link + ?: return@apmap + loadCustomExtractor( + name, + iframe, + "$gokuAPI/", + subtitleCallback, + callback, + ) + } + } + + suspend fun invokeVidSrc( + id: Int? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit + ) { + val url = if (season == null) { + "$vidSrcAPI/embed/movie?tmdb=$id" + } else { + "$vidSrcAPI/embed/tv?tmdb=$id&season=$season&episode=$episode" + } + + val iframedoc = + app.get(url).document.select("iframe#player_iframe").attr("src").let { httpsify(it) } + val doc = app.get(iframedoc, referer = url).document + + val index = doc.select("body").attr("data-i") + val hash = doc.select("div#hidden").attr("data-h") + val srcrcp = deobfstr(hash, index) + + val script = app.get( + httpsify(srcrcp), + referer = iframedoc + ).document.selectFirst("script:containsData(Playerjs)")?.data() + val video = script?.substringAfter("file:\"#9")?.substringBefore("\"") + ?.replace(Regex("/@#@\\S+?=?="), "")?.let { base64Decode(it) } + + callback.invoke( + ExtractorLink( + "Vidsrc", "Vidsrc", video + ?: return, "https://vidsrc.stream/", Qualities.P1080.value, INFER_TYPE + ) + ) + } + + suspend fun invokeDreamfilm( + title: String? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val fixTitle = title.createSlug() + val url = if (season == null) { + "$dreamfilmAPI/$fixTitle" + } else { + "$dreamfilmAPI/series/$fixTitle/season-$season/episode-$episode" + } + + val doc = app.get(url).document + doc.select("div#videosen a").apmap { + val iframe = app.get(it.attr("href")).document.selectFirst("div.card-video iframe") + ?.attr("data-src") + loadCustomExtractor( + null, + iframe + ?: return@apmap, + "$dreamfilmAPI/", + subtitleCallback, + callback, + Qualities.P1080.value + ) + } + } + + suspend fun invokeMultimovies( + apiUrl: String, + title: String? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val fixTitle = title.createSlug() + val url = if (season == null) { + "$apiUrl/movies/$fixTitle" + } else { + "$apiUrl/episodes/$fixTitle-${season}x${episode}" + } + val req = app.get(url) + val directUrl = getBaseUrl(req.url) + val iframe = req.document.selectFirst("div.pframe iframe")?.attr("src") ?: return + if (!iframe.contains("youtube")) { + loadExtractor(iframe, "$directUrl/", subtitleCallback) { link -> + if (link.quality == Qualities.Unknown.value) { + callback.invoke( + ExtractorLink( + link.source, + link.name, + link.url, + link.referer, + Qualities.P1080.value, + link.type, + link.headers, + link.extractorData + ) + ) + } + } + } + } + + suspend fun invokeAoneroom( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ) { + val headers = + mapOf("Authorization" to "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjcyODc3MjQ5OTg4MzA0NzM5NzYsInV0cCI6MSwiZXhwIjoxNzEwMzg4NzczLCJpYXQiOjE3MDI2MTI3NzN9.Myt-gVHfPfQFbFyRX3WXtiiwvRzDwBrXTEKy1l-GDRU") + val subjectId = app.post( + "$aoneroomAPI/wefeed-mobile-bff/subject-api/search", data = mapOf( + "page" to "1", + "perPage" to "10", + "keyword" to "$title", + "subjectType" to if (season == null) "1" else "2", + ), headers = headers + ).parsedSafe()?.data?.items?.find { + it.title.equals(title, true) && it.releaseDate?.substringBefore("-") == "$year" + }?.subjectId + + val data = app.get( + "$aoneroomAPI/wefeed-mobile-bff/subject-api/resource?subjectId=${subjectId ?: return}&page=1&perPage=20&all=0&startPosition=1&endPosition=1&pagerMode=0&resolution=480", + headers = headers + ).parsedSafe()?.data?.list?.findLast { + it.se == (season ?: 0) && it.ep == (episode ?: 0) + } + + callback.invoke( + ExtractorLink( + "Aoneroom", "Aoneroom", data?.resourceLink + ?: return, "", data.resolution ?: Qualities.Unknown.value, INFER_TYPE + ) + ) + + data.extCaptions?.map { sub -> + subtitleCallback.invoke( + SubtitleFile( + sub.lanName ?: return@map, + sub.url ?: return@map, + ) + ) + } + + } + + suspend fun invokeWatchCartoon( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val fixTitle = title.createSlug() + val url = if (season == null) { + "$watchCartoonAPI/movies/$fixTitle-$year" + } else { + "$watchCartoonAPI/episode/$fixTitle-season-$season-episode-$episode" + } + + val req = app.get(url) + val host = getBaseUrl(req.url) + val doc = req.document + + val id = doc.select("link[rel=shortlink]").attr("href").substringAfterLast("=") + doc.select("div.form-group.list-server option").apmap { + val server = app.get( + "$host/ajax-get-link-stream/?server=${it.attr("value")}&filmId=$id", + headers = mapOf("X-Requested-With" to "XMLHttpRequest") + ).text + loadExtractor(server, "$host/", subtitleCallback) { link -> + if (link.quality == Qualities.Unknown.value) { + callback.invoke( + ExtractorLink( + "WatchCartoon", + "WatchCartoon", + link.url, + link.referer, + Qualities.P720.value, + link.type, + link.headers, + link.extractorData + ) + ) + } + } + } + } + + suspend fun invokeNetmovies( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val fixTitle = title.createSlug() + val url = if (season == null) { + "$netmoviesAPI/movies/$fixTitle-$year" + } else { + "$netmoviesAPI/episodes/$fixTitle-${season}x${episode}" + } + invokeWpmovies(null, url, subtitleCallback, callback) + } + + suspend fun invokeZshow( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val fixTitle = title.createSlug() + val url = if (season == null) { + "$zshowAPI/movie/$fixTitle-$year" + } else { + "$zshowAPI/episode/$fixTitle-season-$season-episode-$episode" + } + invokeWpmovies("ZShow", url, subtitleCallback, callback, encrypt = true) + } + + suspend fun invokeMMovies( + title: String? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val fixTitle = title.createSlug() + val url = if (season == null) { + "$mMoviesAPI/movies/$fixTitle" + } else { + "$mMoviesAPI/episodes/$fixTitle-${season}x${episode}" + } + + invokeWpmovies( + null, + url, + subtitleCallback, + callback, + true, + hasCloudflare = true, + ) + } + + private suspend fun invokeWpmovies( + name: String? = null, + url: String? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + fixIframe: Boolean = false, + encrypt: Boolean = false, + hasCloudflare: Boolean = false, + interceptor: Interceptor? = null, + ) { + fun String.fixBloat(): String { + return this.replace("\"", "").replace("\\", "") + } + + val res = app.get(url ?: return, interceptor = if (hasCloudflare) interceptor else null) + val referer = getBaseUrl(res.url) + val document = res.document + document.select("ul#playeroptionsul > li").map { + Triple(it.attr("data-post"), it.attr("data-nume"), it.attr("data-type")) + }.apmap { (id, nume, type) -> + delay(1000) + val json = app.post( + url = "$referer/wp-admin/admin-ajax.php", + data = mapOf( + "action" to "doo_player_ajax", + "post" to id, + "nume" to nume, + "type" to type + ), + headers = mapOf("Accept" to "*/*", "X-Requested-With" to "XMLHttpRequest"), + referer = url, + interceptor = if (hasCloudflare) interceptor else null + ) + val source = tryParseJson(json.text)?.let { + when { + encrypt -> { + val meta = tryParseJson(it.embed_url)?.meta ?: return@apmap + val key = generateWpKey(it.key ?: return@apmap, meta) + cryptoAESHandler(it.embed_url, key.toByteArray(), false)?.fixBloat() + } + + fixIframe -> Jsoup.parse(it.embed_url).select("IFRAME").attr("SRC") + else -> it.embed_url + } + } ?: return@apmap + when { + !source.contains("youtube") -> { + loadCustomExtractor(name, source, "$referer/", subtitleCallback, callback) + } + } + } + } + + suspend fun invokeDoomovies( + title: String? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val res = app.get("$doomoviesAPI/movies/${title.createSlug()}/") + val host = getBaseUrl(res.url) + val document = res.document + document.select("ul#playeroptionsul > li") + .filter { element -> element.select("span.flag img").attr("src").contains("/en.") } + .map { + Triple( + it.attr("data-post"), + it.attr("data-nume"), + it.attr("data-type") + ) + }.apmap { (id, nume, type) -> + val source = app.get( + "$host/wp-json/dooplayer/v2/${id}/${type}/${nume}", + headers = mapOf("X-Requested-With" to "XMLHttpRequest"), + referer = "$host/" + ).parsed().embed_url + if (!source.contains("youtube")) { + if (source.startsWith("https://voe.sx")) { + val req = app.get(source, referer = "$host/") + val server = getBaseUrl(req.url) + val script = req.text.substringAfter("wc0 = '").substringBefore("'") + val video = + tryParseJson>(base64Decode(script))?.get("file") + M3u8Helper.generateM3u8( + "Voe", + video ?: return@apmap, + "$server/", + headers = mapOf("Origin" to server) + ).forEach(callback) + } else { + loadExtractor(source, "$host/", subtitleCallback, callback) + } + } + } + } + + suspend fun invokeNoverse( + title: String? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit + ) { + val fixTitle = title.createSlug() + val url = if (season == null) { + "$noverseAPI/movie/$fixTitle/download/" + } else { + "$noverseAPI/serie/$fixTitle/season-$season" + } + + val doc = app.get(url).document + + val links = if (season == null) { + doc.select("table.table-striped tbody tr").map { + it.select("a").attr("href") to it.selectFirst("td")?.text() + } + } else { + doc.select("table.table-striped tbody tr") + .find { it.text().contains("Episode $episode") }?.select("td")?.map { + it.select("a").attr("href") to it.select("a").text() + } + } ?: return + + delay(4000) + links.map { (link, quality) -> + val name = quality?.replace(Regex("\\d{3,4}p"), "Noverse")?.replace(".", " ") + ?: "Noverse" + callback.invoke( + ExtractorLink( + "Noverse", + name, + link, + "", + getQualityFromName("${quality?.substringBefore("p")?.trim()}p"), + ) + ) + } + + } + + suspend fun invokeFilmxy( + imdbId: String? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val url = if (season == null) { + "${filmxyAPI}/movie/$imdbId" + } else { + "${filmxyAPI}/tv/$imdbId" + } + val filmxyCookies = getFilmxyCookies(url) + val doc = app.get(url, cookies = filmxyCookies).document + val script = doc.selectFirst("script:containsData(var isSingle)")?.data() ?: return + + val sourcesData = + Regex("listSE\\s*=\\s?(.*?),[\\n|\\s]").find(script)?.groupValues?.get(1).let { + tryParseJson>>>(it) + } + val sourcesDetail = + Regex("linkDetails\\s*=\\s?(.*?),[\\n|\\s]").find(script)?.groupValues?.get(1).let { + tryParseJson>>(it) + } + val subSourcesData = + Regex("dSubtitles\\s*=\\s?(.*?),[\\n|\\s]").find(script)?.groupValues?.get(1).let { + tryParseJson>>>(it) + } + + val (seasonSlug, episodeSlug) = getEpisodeSlug(season, episode) + + val sources = if (season == null) { + sourcesData?.get("movie")?.get("movie") + } else { + sourcesData?.get("s$seasonSlug")?.get("e$episodeSlug") + } ?: return + val subSources = if (season == null) { + subSourcesData?.get("movie")?.get("movie") + } else { + subSourcesData?.get("s$seasonSlug")?.get("e$episodeSlug") + } + + val scriptUser = doc.select("script").find { it.data().contains("var userNonce") }?.data() + ?: return + val userNonce = + Regex("var\\suserNonce.*?[\"|'](\\S+?)[\"|'];").find(scriptUser)?.groupValues?.get(1) + val userId = + Regex("var\\suser_id.*?[\"|'](\\S+?)[\"|'];").find(scriptUser)?.groupValues?.get(1) + + val listSources = sources.withIndex().groupBy { it.index / 2 } + .map { entry -> entry.value.map { it.value } } + + listSources.apmap { src -> + val linkIDs = src.joinToString("") { + "&linkIDs%5B%5D=$it" + }.replace("\"", "") + val json = app.post( + "$filmxyAPI/wp-admin/admin-ajax.php", + requestBody = "action=get_vid_links$linkIDs&user_id=$userId&nonce=$userNonce".toRequestBody(), + referer = url, + headers = mapOf( + "Accept" to "*/*", + "DNT" to "1", + "Content-Type" to "application/x-www-form-urlencoded; charset=UTF-8", + "Origin" to filmxyAPI, + "X-Requested-With" to "XMLHttpRequest", + ), + cookies = filmxyCookies + ).text.let { tryParseJson>(it) } + + src.map { source -> + val link = json?.get(source) + val quality = sourcesDetail?.get(source)?.get("resolution") + val server = sourcesDetail?.get(source)?.get("server") + val size = sourcesDetail?.get(source)?.get("size") + + callback.invoke( + ExtractorLink( + "Filmxy", "Filmxy $server [$size]", link + ?: return@map, "$filmxyAPI/", getQualityFromName(quality) + ) + ) + } + } + + subSources?.mapKeys { sub -> + subtitleCallback.invoke( + SubtitleFile( + SubtitleHelper.fromTwoLettersToLanguage(sub.key) + ?: return@mapKeys, "https://www.mysubs.org/get-subtitle/${sub.value}" + ) + ) + } + + } + + suspend fun invokeDramaday( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + fun String.getQuality(): String? = + Regex("""\d{3,4}[pP]""").find(this)?.groupValues?.getOrNull(0) + + fun String.getTag(): String? = + Regex("""\d{3,4}[pP]\s*(.*)""").find(this)?.groupValues?.getOrNull(1) + + val slug = title.createSlug() + val epsSlug = getEpisodeSlug(season, episode) + val url = if (season == null) { + "$dramadayAPI/$slug-$year/" + } else { + "$dramadayAPI/$slug/" + } + val res = app.get(url).document + + val servers = if (season == null) { + val player = res.select("div.tabs__pane p a[href*=https://ouo]").attr("href") + val ouo = bypassOuo(player) + app.get( + ouo + ?: return + ).document.select("article p:matches(\\d{3,4}[pP]) + p:has(a)").flatMap { ele -> + val entry = ele.previousElementSibling()?.text() ?: "" + ele.select("a").map { + Triple(entry.getQuality(), entry.getTag(), it.attr("href")) + }.filter { + it.third.startsWith("https://pixeldrain.com") || it.third.startsWith("https://krakenfiles.com") + } + } + } else { + val data = res.select("tbody tr:has(td[data-order=${epsSlug.second}])") + val qualities = + data.select("td:nth-child(2)").attr("data-order").split("
").map { it } + val iframe = data.select("a[href*=https://ouo]").map { it.attr("href") } + qualities.zip(iframe).map { + Triple(it.first.getQuality(), it.first.getTag(), it.second) + } + } + + servers.filter { it.first == "720p" || it.first == "1080p" }.apmap { + val server = if (it.third.startsWith("https://ouo")) bypassOuo(it.third) else it.third + loadCustomTagExtractor( + it.second, + server + ?: return@apmap, + "$dramadayAPI/", + subtitleCallback, + callback, + getQualityFromName(it.first) + ) + } + + } + + suspend fun invokeKimcartoon( + title: String? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val fixTitle = title.createSlug() + val doc = if (season == null || season == 1) { + app.get("$kimcartoonAPI/Cartoon/$fixTitle").document + } else { + val res = app.get("$kimcartoonAPI/Cartoon/$fixTitle-Season-$season") + if (res.url == "$kimcartoonAPI/") app.get("$kimcartoonAPI/Cartoon/$fixTitle-Season-0$season").document else res.document + } + + val iframe = if (season == null) { + doc.select("table.listing tr td a").firstNotNullOf { it.attr("href") } + } else { + doc.select("table.listing tr td a").find { + it.attr("href").contains(Regex("(?i)Episode-0*$episode")) + }?.attr("href") + } ?: return + val servers = + app.get(fixUrl(iframe, kimcartoonAPI)).document.select("#selectServer > option") + .map { fixUrl(it.attr("value"), kimcartoonAPI) } + + servers.apmap { + app.get(it).document.select("#my_video_1").attr("src").let { iframe -> + if (iframe.isNotEmpty()) { + loadExtractor(iframe, "$kimcartoonAPI/", subtitleCallback, callback) + } + } + } + } + + suspend fun invokeDumpStream( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ) { + val (id, type) = getDumpIdAndType(title, year, season) + val json = fetchDumpEpisodes("$id", "$type", episode) ?: return + + json.subtitlingList?.map { sub -> + subtitleCallback.invoke( + SubtitleFile( + getVipLanguage( + sub.languageAbbr + ?: return@map + ), sub.subtitlingUrl ?: return@map + ) + ) + } + } + + suspend fun invokeVidsrcto( + imdbId: String?, + season: Int?, + episode: Int?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val url = if (season == null) { + "$vidsrctoAPI/embed/movie/$imdbId" + } else { + "$vidsrctoAPI/embed/tv/$imdbId/$season/$episode" + } + + val mediaId = app.get(url).document.selectFirst("ul.episodes li a")?.attr("data-id") + ?: return + + app.get( + "$vidsrctoAPI/ajax/embed/episode/$mediaId/sources", headers = mapOf( + "X-Requested-With" to "XMLHttpRequest" + ) + ).parsedSafe()?.result?.apmap { + val encUrl = app.get("$vidsrctoAPI/ajax/embed/source/${it.id}") + .parsedSafe()?.result?.url + loadExtractor( + vidsrctoDecrypt( + encUrl + ?: return@apmap + ), "$vidsrctoAPI/", subtitleCallback, callback + ) + } + + val subtitles = app.get("$vidsrctoAPI/ajax/embed/episode/$mediaId/subtitles").text + tryParseJson>(subtitles)?.map { + subtitleCallback.invoke(SubtitleFile(it.label ?: "", it.file ?: return@map)) + } + + } + + suspend fun invokeKisskh( + title: String? = null, + season: Int? = null, + episode: Int? = null, + isAnime: Boolean = false, + lastSeason: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val slug = title.createSlug() ?: return + val type = when { + isAnime -> "3" + season == null -> "2" + else -> "1" + } + val res = app.get( + "$kissKhAPI/api/DramaList/Search?q=$title&type=$type", + referer = "$kissKhAPI/" + ).text.let { + tryParseJson>(it) + } ?: return + + val (id, contentTitle) = if (res.size == 1) { + res.first().id to res.first().title + } else { + val data = res.find { + val slugTitle = it.title.createSlug() ?: return + when { + season == null -> slugTitle == slug + lastSeason == 1 -> slugTitle.contains(slug) + else -> (slugTitle.contains(slug) && it.title?.contains( + "Season $season", + true + ) == true) + } + } ?: res.find { it.title.equals(title) } + data?.id to data?.title + } + + val resDetail = app.get( + "$kissKhAPI/api/DramaList/Drama/$id?isq=false", referer = "$kissKhAPI/Drama/${ + getKisskhTitle(contentTitle) + }?id=$id" + ).parsedSafe() ?: return + + val epsId = if (season == null) { + resDetail.episodes?.first()?.id + } else { + resDetail.episodes?.find { it.number == episode }?.id + } + + app.get( + "$kissKhAPI/api/DramaList/Episode/$epsId.png?err=false&ts=&time=", + referer = "$kissKhAPI/Drama/${getKisskhTitle(contentTitle)}/Episode-${episode ?: 0}?id=$id&ep=$epsId&page=0&pageSize=100" + ).parsedSafe()?.let { source -> + listOf(source.video, source.thirdParty).apmap { link -> + if (link?.contains(".m3u8") == true) { + M3u8Helper.generateM3u8( + "Kisskh", + link, + "$kissKhAPI/", + headers = mapOf("Origin" to kissKhAPI) + ).forEach(callback) + } else { + loadExtractor( + link?.substringBefore("=http") + ?: return@apmap null, "$kissKhAPI/", subtitleCallback, callback + ) + } + } + } + + app.get("$kissKhAPI/api/Sub/$epsId").text.let { resSub -> + tryParseJson>(resSub)?.map { sub -> + subtitleCallback.invoke( + SubtitleFile( + getLanguage(sub.label ?: return@map), sub.src + ?: return@map + ) + ) + } + } + + + } + + suspend fun invokeAnimes( + title: String? = null, + epsTitle: String? = null, + date: String?, + airedDate: String?, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + + val (aniId, malId) = convertTmdbToAnimeId( + title, + date, + airedDate, + if (season == null) TvType.AnimeMovie else TvType.Anime + ) + + val malsync = app.get("$malsyncAPI/mal/anime/${malId ?: return}") + .parsedSafe()?.sites + + val zoroIds = malsync?.zoro?.keys?.map { it } + val aniwaveId = malsync?.nineAnime?.firstNotNullOf { it.value["url"] } + + argamap( + { + invokeAnimetosho(malId, season, episode, subtitleCallback, callback) + }, + { + invokeHianime(zoroIds, episode, subtitleCallback, callback) + }, + { + invokeAniwave(aniwaveId, episode, subtitleCallback, callback) + }, + { + if (season != null) invokeCrunchyroll( + aniId, + malId, + epsTitle, + season, + episode, + subtitleCallback, + callback + ) + } + ) + } + + private suspend fun invokeAniwave( + url: String? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val res = app.get(url ?: return).document + val id = res.select("div#watch-main").attr("data-id") + val episodeId = + app.get("$aniwaveAPI/ajax/episode/list/$id?vrf=${AniwaveUtils.encodeVrf(id)}") + .parsedSafe()?.asJsoup() + ?.selectFirst("ul.ep-range li a[data-num=${episode ?: 1}]")?.attr("data-ids") + ?: return + val servers = + app.get("$aniwaveAPI/ajax/server/list/$episodeId?vrf=${AniwaveUtils.encodeVrf(episodeId)}") + .parsedSafe()?.asJsoup() + ?.select("div.servers > div[data-type!=sub] ul li") ?: return + + servers.apmap { + val linkId = it.attr("data-link-id") + val iframe = + app.get("$aniwaveAPI/ajax/server/$linkId?vrf=${AniwaveUtils.encodeVrf(linkId)}") + .parsedSafe()?.result?.decrypt() + val audio = if (it.attr("data-cmid").endsWith("softsub")) "Raw" else "English Dub" + loadCustomExtractor( + "${it.text()} [$audio]", + iframe ?: return@apmap, + "$aniwaveAPI/", + subtitleCallback, + callback, + ) + } + } + + private suspend fun invokeAnimetosho( + malId: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + fun Elements.getLinks(): List> { + return this.flatMap { ele -> + ele.select("div.links a:matches(KrakenFiles|GoFile)").map { + Triple( + it.attr("href"), + ele.select("div.size").text(), + getIndexQuality(ele.select("div.link a").text()) + ) + } + } + } + + val (seasonSLug, episodeSlug) = getEpisodeSlug(season, episode) + val jikan = app.get("$jikanAPI/anime/$malId/full").parsedSafe()?.data + val aniId = jikan?.external?.find { it.name == "AniDB" }?.url?.substringAfterLast("=") + val res = + app.get("$animetoshoAPI/series/${jikan?.title?.createSlug()}.$aniId?filter[0][t]=nyaa_class&filter[0][v]=trusted").document + + val servers = if (season == null) { + res.select("div.home_list_entry:has(div.links)").getLinks() + } else { + res.select("div.home_list_entry:has(div.link a:matches([\\.\\s]$episodeSlug[\\.\\s]|S${seasonSLug}E$episodeSlug))") + .getLinks() + } + + servers.filter { it.third in arrayOf(Qualities.P1080.value, Qualities.P720.value) }.apmap { + loadCustomTagExtractor( + it.second, + it.first, + "$animetoshoAPI/", + subtitleCallback, + callback, + it.third + ) + } + + } + + private suspend fun invokeHianime( + animeIds: List? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val headers = mapOf( + "X-Requested-With" to "XMLHttpRequest", + ) + animeIds?.apmap { id -> + val episodeId = app.get( + "$hianimeAPI/ajax/v2/episode/list/${id ?: return@apmap}", + headers = headers + ).parsedSafe()?.html?.let { + Jsoup.parse(it) + }?.select("div.ss-list a")?.find { it.attr("data-number") == "${episode ?: 1}" } + ?.attr("data-id") + + val servers = app.get( + "$hianimeAPI/ajax/v2/episode/servers?episodeId=${episodeId ?: return@apmap}", + headers = headers + ).parsedSafe()?.html?.let { Jsoup.parse(it) } + ?.select("div.item.server-item")?.map { + Triple( + it.text(), + it.attr("data-id"), + it.attr("data-type"), + ) + } + + servers?.apmap servers@{ server -> + val iframe = app.get( + "$hianimeAPI/ajax/v2/episode/sources?id=${server.second ?: return@servers}", + headers = headers + ).parsedSafe()?.link + ?: return@servers + val audio = if (server.third == "sub") "Raw" else "English Dub" + loadCustomExtractor( + "${server.first} [$audio]", + iframe, + "$hianimeAPI/", + subtitleCallback, + callback, + ) + } + } + } + + suspend fun invokeLing( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val fixTitle = title?.replace("–", "-") + val url = if (season == null) { + "$lingAPI/en/videos/films/?title=$fixTitle" + } else { + "$lingAPI/en/videos/serials/?title=$fixTitle" + } + + val scriptData = app.get(url).document.select("div.blk.padding_b0 div.col-sm-30").map { + Triple( + it.selectFirst("div.video-body h5")?.text(), + it.selectFirst("div.video-body > p")?.text(), + it.selectFirst("div.video-body a")?.attr("href"), + ) + } + + val script = if (scriptData.size == 1) { + scriptData.first() + } else { + scriptData.find { + it.first?.contains( + "$fixTitle", + true + ) == true && it.second?.contains("$year") == true + } + } + + val doc = app.get(fixUrl(script?.third ?: return, lingAPI)).document + val iframe = (if (season == null) { + doc.selectFirst("a.video-js.vjs-default-skin")?.attr("data-href") + } else { + doc.select("div.blk div#tab_$season li")[episode!!.minus(1)].select("h5 a") + .attr("data-href") + })?.let { fixUrl(it, lingAPI) } + + val source = app.get(iframe ?: return) + val link = Regex("((https:|http:)//.*\\.mp4)").find(source.text)?.value ?: return + callback.invoke( + ExtractorLink( + "Ling", + "Ling", + "$link/index.m3u8", + "$lingAPI/", + Qualities.P720.value, + INFER_TYPE + ) + ) + + source.document.select("div#player-tracks track").map { + subtitleCallback.invoke( + SubtitleFile( + SubtitleHelper.fromTwoLettersToLanguage(it.attr("srclang")) + ?: return@map null, it.attr("src") + ) + ) + } + + } + + suspend fun invokeUhdmovies( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit + ) { + val fixTitle = title.createSlug() + val (seasonSlug, episodeSlug) = getEpisodeSlug(season, episode) + + val url = if (season == null) { + "$uhdmoviesAPI/download-$fixTitle-$year" + } else { + "$uhdmoviesAPI/download-$fixTitle" + } + + val detailDoc = app.get(url).document + + val iSelector = if (season == null) { + "div.entry-content p:has(:matches($year))" + } else { + "div.entry-content p:has(:matches((?i)(?:S\\s*$seasonSlug|Season\\s*$seasonSlug)))" + } + val iframeList = detailDoc.select(iSelector).mapNotNull { + if (season == null) { + it.text() to it.nextElementSibling()?.select("a")?.attr("href") + } else { + it.text() to it.nextElementSibling()?.select("a")?.find { child -> + child.select("span").text().equals("Episode $episode", true) + }?.attr("href") + } + }.filter { it.first.contains(Regex("(2160p)|(1080p)")) }.reversed().takeLast(3) + + iframeList.apmap { (quality, link) -> + val driveLink = bypassHrefli(link ?: return@apmap) + val base = getBaseUrl(driveLink ?: return@apmap) + val driveReq = app.get(driveLink) + val driveRes = driveReq.document + val bitLink = driveRes.select("a.btn.btn-outline-success").attr("href") + val insLink = + driveRes.select("a.btn.btn-danger:contains(Instant Download)").attr("href") + val downloadLink = when { + insLink.isNotEmpty() -> extractInstantUHD(insLink) + driveRes.select("button.btn.btn-success").text() + .contains("Direct Download", true) -> extractDirectUHD(driveLink, driveReq) + + bitLink.isNullOrEmpty() -> { + val backupIframe = driveRes.select("a.btn.btn-outline-warning").attr("href") + extractBackupUHD(backupIframe ?: return@apmap) + } + + else -> { + extractMirrorUHD(bitLink, base) + } + } + + val tags = getUhdTags(quality) + val qualities = getIndexQuality(quality) + val size = getIndexSize(quality) + callback.invoke( + ExtractorLink( + "UHDMovies", "UHDMovies $tags [$size]", downloadLink + ?: return@apmap, "", qualities + ) + ) + } + } + + suspend fun invokeDotmovies( + title: String? = null, + year: Int? = null, + season: Int? = null, + lastSeason: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + invokeWpredis( + title, + year, + season, + lastSeason, + episode, + subtitleCallback, + callback, + dotmoviesAPI + ) + } + + suspend fun invokeVegamovies( + title: String? = null, + year: Int? = null, + season: Int? = null, + lastSeason: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + invokeWpredis( + title, + year, + season, + lastSeason, + episode, + subtitleCallback, + callback, + vegaMoviesAPI + ) + } + + private suspend fun invokeWpredis( + title: String? = null, + year: Int? = null, + season: Int? = null, + lastSeason: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + api: String + ) { + val (seasonSlug, episodeSlug) = getEpisodeSlug(season, episode) + var res = app.get("$api/search/$title").document + val match = when (season) { + null -> "$year" + 1 -> "Season 1" + else -> "Season 1 – $lastSeason" + } + val media = + res.selectFirst("div.blog-items article:has(h3.entry-title:matches((?i)$title.*$match)) a") + ?.attr("href") + + res = app.get(media ?: return).document + val hTag = if (season == null) "h5" else "h3" + val aTag = if (season == null) "Download Now" else "V-Cloud" + val sTag = if (season == null) "" else "(Season $season|S$seasonSlug)" + val entries = res.select("div.entry-content > $hTag:matches((?i)$sTag.*(1080p|2160p))") + .filter { element -> !element.text().contains("Download", true) }.takeLast(2) + entries.apmap { + val tags = + """(?:1080p|2160p)(.*)""".toRegex().find(it.text())?.groupValues?.get(1)?.trim() + val href = it.nextElementSibling()?.select("a:contains($aTag)")?.attr("href") + val selector = + if (season == null) "p a:contains(V-Cloud)" else "h4:matches(0?$episode) + p a:contains(V-Cloud)" + val server = app.get( + href ?: return@apmap, interceptor = wpRedisInterceptor + ).document.selectFirst("div.entry-content > $selector") + ?.attr("href") ?: return@apmap + + loadCustomTagExtractor( + tags, + server, + "$api/", + subtitleCallback, + callback, + getIndexQuality(it.text()) + ) + } + } + + suspend fun invokeHdmovies4u( + title: String? = null, + imdbId: String? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + fun String.decodeLink(): String { + return base64Decode(this.substringAfterLast("/")) + } + val (seasonSlug, episodeSlug) = getEpisodeSlug(season, episode) + val media = + app.get("$hdmovies4uAPI/?s=${if (season == null) imdbId else title}").document.let { + val selector = if (season == null) "a" else "a:matches((?i)$title.*Season $season)" + it.selectFirst("div.gridxw.gridxe $selector")?.attr("href") + } + val selector = if (season == null) "1080p|2160p" else "(?i)Episode.*(?:1080p|2160p)" + app.get(media ?: return).document.select("section h4:matches($selector)").apmap { ele -> + val (tags, size) = ele.select("span").map { + it.text() + }.let { it[it.lastIndex - 1] to it.last().substringAfter("-").trim() } + val link = ele.nextElementSibling()?.select("a:contains(DriveTOT)")?.attr("href") + val iframe = bypassBqrecipes(link?.decodeLink() ?: return@apmap).let { + if (it?.contains("/pack/") == true) { + val href = + app.get(it).document.select("table tbody tr:contains(S${seasonSlug}E${episodeSlug}) a") + .attr("href") + bypassBqrecipes(href.decodeLink()) + } else { + it + } + } + invokeDrivetot(iframe ?: return@apmap, tags, size, subtitleCallback, callback) + } + } + + suspend fun invokeFDMovies( + title: String? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit + ) { + val fixTitle = title.createSlug() + val url = if (season == null) { + "$fdMoviesAPI/movies/$fixTitle" + } else { + "$fdMoviesAPI/episodes/$fixTitle-s${season}xe${episode}/" + } + + val request = app.get(url) + if (!request.isSuccessful) return + + val iframe = request.document.select("div#download tbody tr").map { + FDMovieIFrame( + it.select("a").attr("href"), + it.select("strong.quality").text(), + it.select("td:nth-child(4)").text(), + it.select("img").attr("src") + ) + }.filter { + it.quality.contains(Regex("(?i)(1080p|4k)")) && it.type.contains(Regex("(gdtot|oiya|rarbgx)")) + } + iframe.apmap { (link, quality, size, type) -> + val qualities = getFDoviesQuality(quality) + val fdLink = bypassFdAds(link) + val videoLink = when { + type.contains("gdtot") -> { + val gdBotLink = extractGdbot(fdLink ?: return@apmap null) + extractGdflix(gdBotLink ?: return@apmap null) + } + + type.contains("oiya") || type.contains("rarbgx") -> { + val oiyaLink = extractOiya(fdLink ?: return@apmap null) + if (oiyaLink?.contains("gdtot") == true) { + val gdBotLink = extractGdbot(oiyaLink) + extractGdflix(gdBotLink ?: return@apmap null) + } else { + oiyaLink + } + } + + else -> { + return@apmap null + } + } + + callback.invoke( + ExtractorLink( + "FDMovies", "FDMovies [$size]", videoLink + ?: return@apmap null, "", getQualityFromName(qualities) + ) + ) + } + + } + + suspend fun invokeM4uhd( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val slugTitle = title?.createSlug() + val (seasonSlug, episodeSlug) = getEpisodeSlug(season, episode) + val req = app.get("$m4uhdAPI/search/$slugTitle", timeout = 20) + val referer = getBaseUrl(req.url) + + val media = req.document.select("div.row div.item a").map { it.attr("href") } + val mediaUrl = if (media.size == 1) { + media.first() + } else { + media.find { + if(season == null) it.startsWith("movies/$slugTitle-$year.") else it.startsWith("tv-series/$slugTitle-$year.") + } + } + + val link = fixUrl(mediaUrl ?: return, referer) + val request = app.get(link, timeout = 20) + var cookies = request.cookies + val headers = mapOf("Accept" to "*/*", "X-Requested-With" to "XMLHttpRequest") + + val doc = request.document + val token = doc.selectFirst("meta[name=csrf-token]")?.attr("content") + val m4uData = if (season == null) { + doc.select("div.le-server span").map { it.attr("data") } + } else { + val idepisode = + doc.selectFirst("button[class=episode]:matches(S$seasonSlug-E$episodeSlug)") + ?.attr("idepisode") + ?: return + val requestEmbed = app.post( + "$referer/ajaxtv", data = mapOf( + "idepisode" to idepisode, "_token" to "$token" + ), referer = link, headers = headers, cookies = cookies, timeout = 20 + ) + cookies = requestEmbed.cookies + requestEmbed.document.select("div.le-server span").map { it.attr("data") } + } + + m4uData.apmap { data -> + val iframe = app.post( + "$referer/ajax", + data = mapOf("m4u" to data, "_token" to "$token"), + referer = link, + headers = headers, + cookies = cookies, + timeout = 20 + ).document.select("iframe").attr("src") + + loadExtractor(iframe, referer, subtitleCallback, callback) + } + + } + + suspend fun invokeTvMovies( + title: String? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit + ) { + val fixTitle = title.createSlug() + val url = if (season == null) { + "$tvMoviesAPI/show/$fixTitle" + } else { + "$tvMoviesAPI/show/index-of-$fixTitle" + } + + val server = getTvMoviesServer(url, season, episode) ?: return + val videoData = extractCovyn(server.second ?: return) + val quality = + Regex("(\\d{3,4})p").find(server.first)?.groupValues?.getOrNull(1)?.toIntOrNull() + + callback.invoke( + ExtractorLink( + "TVMovies", "TVMovies [${videoData?.second}]", videoData?.first + ?: return, "", quality ?: Qualities.Unknown.value + ) + ) + + + } + + private suspend fun invokeCrunchyroll( + aniId: Int? = null, + malId: Int? = null, + epsTitle: String? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val id = getCrunchyrollId("${aniId ?: return}") + ?: getCrunchyrollIdFromMalSync("${malId ?: return}") + val audioLocal = listOf( + "ja-JP", + "en-US", + "zh-CN", + ) + val token = getCrunchyrollToken() + val headers = mapOf("Authorization" to "${token.tokenType} ${token.accessToken}") + val seasonIdData = app.get( + "$crunchyrollAPI/content/v2/cms/series/${id ?: return}/seasons", + headers = headers + ).parsedSafe()?.data?.let { s -> + if (s.size == 1) { + s.firstOrNull() + } else { + s.find { + when (epsTitle) { + "One Piece" -> it.season_number == 13 + "Hunter x Hunter" -> it.season_number == 5 + else -> it.season_number == season + } + } ?: s.find { it.season_number?.plus(1) == season } + } + } + val seasonId = seasonIdData?.versions?.filter { it.audio_locale in audioLocal } + ?.map { it.guid to it.audio_locale } + ?: listOf(seasonIdData?.id to "ja-JP") + + seasonId.apmap { (sId, audioL) -> + val streamsLink = app.get( + "$crunchyrollAPI/content/v2/cms/seasons/${sId ?: return@apmap}/episodes", + headers = headers + ).parsedSafe()?.data?.find { + it.title.equals(epsTitle, true) || it.slug_title.equals( + epsTitle.createSlug(), + true + ) || it.episode_number == episode + }?.streams_link?.substringAfter("/videos/")?.substringBefore("/streams") ?: return@apmap + val sources = app.get( + "$crunchyrollAPI/cms/v2${token.bucket}/videos/$streamsLink/streams?Policy=${token.policy}&Signature=${token.signature}&Key-Pair-Id=${token.key_pair_id}", + headers = headers + ).parsedSafe() + + listOf("adaptive_hls", "vo_adaptive_hls").map { hls -> + val name = if (hls == "adaptive_hls") "Crunchyroll" else "Vrv" + val audio = if (audioL == "en-US") "English Dub" else "Raw" + val source = sources?.streams?.let { + if (hls == "adaptive_hls") it.adaptive_hls else it.vo_adaptive_hls + } + M3u8Helper.generateM3u8( + "$name [$audio]", source?.get("")?.get("url") + ?: return@map, "https://static.crunchyroll.com/" + ).forEach(callback) + } + + sources?.subtitles?.map { sub -> + subtitleCallback.invoke( + SubtitleFile( + "${fixCrunchyrollLang(sub.key) ?: sub.key} [ass]", sub.value["url"] + ?: return@map null + ) + ) + } + } + } + + suspend fun invokeRStream( + id: Int? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit + ) { + val url = if (season == null) { + "$rStreamAPI/e/?tmdb=$id" + } else { + "$rStreamAPI/e/?tmdb=$id&s=$season&e=$episode" + } + + val res = app.get( + "$url&apikey=whXgvN4kVyoubGwqXpw26Oy3PVryl8dm", + referer = "https://watcha.movie/" + ).text + val link = Regex("\"file\":\"(http.*?)\"").find(res)?.groupValues?.getOrNull(1) + + callback.invoke( + ExtractorLink( + "RStream", + "RStream", + link ?: return, + "$rStreamAPI/", + Qualities.P1080.value, + INFER_TYPE + ) + ) + } + + suspend fun invokeFlixon( + tmdbId: Int? = null, + imdbId: String? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit, + onionUrl: String = "https://onionplay.se/" + ) { + val request = if (season == null) { + val res = app.get("$flixonAPI/$imdbId", referer = onionUrl) + if (res.text.contains("BEGIN PGP SIGNED MESSAGE")) app.get( + "$flixonAPI/$imdbId-1", + referer = onionUrl + ) else res + } else { + app.get("$flixonAPI/$tmdbId-$season-$episode", referer = onionUrl) + } + + val script = request.document.selectFirst("script:containsData(= \"\";)")?.data() + val collection = script?.substringAfter("= [")?.substringBefore("];") + val num = script?.substringAfterLast("(value) -")?.substringBefore(");")?.trim()?.toInt() + ?: return + + val iframe = collection?.split(",")?.map { it.trim().toInt() }?.map { nums -> + nums.minus(num).toChar() + }?.joinToString("")?.let { Jsoup.parse(it) }?.selectFirst("button.redirect") + ?.attr("onclick")?.substringAfter("('")?.substringBefore("')") + + delay(1000) + val unPacker = app.get( + iframe + ?: return, referer = "$flixonAPI/" + ).document.selectFirst("script:containsData(JuicyCodes.Run)")?.data() + ?.substringAfter("JuicyCodes.Run(")?.substringBefore(");")?.split("+") + ?.joinToString("") { it.replace("\"", "").trim() } + ?.let { getAndUnpack(base64Decode(it)) } + + val link = Regex("[\"']file[\"']:[\"'](.+?)[\"'],").find( + unPacker + ?: return + )?.groupValues?.getOrNull(1) + + callback.invoke( + ExtractorLink( + "Flixon", + "Flixon", + link + ?: return, + "https://onionplay.stream/", + Qualities.P720.value, + link.contains(".m3u8") + ) + ) + + } + + suspend fun invokeSmashyStream( + tmdbId: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ) { + val url = if (season == null) { + "$smashyStreamAPI/playere.php?tmdb=$tmdbId" + } else { + "$smashyStreamAPI/playere.php?tmdb=$tmdbId&season=$season&episode=$episode" + } + + app.get( + url, + referer = "https://smashystream.xyz/" + ).document.select("div#_default-servers a.server").map { + it.attr("data-url") to it.text() + }.apmap { + when (it.second) { + "Player F" -> { + invokeSmashyFfix(it.second, it.first, url, subtitleCallback, callback) + } + + "Player SU" -> { + invokeSmashySu(it.second, it.first, url, callback) + } + + else -> return@apmap + } + } + + } + + suspend fun invokeNepu( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit + ) { + val slug = title?.createSlug() + val headers = mapOf( + "X-Requested-With" to "XMLHttpRequest" + ) + val data = app.get("$nepuAPI/ajax/posts?q=$title", headers = headers, referer = "$nepuAPI/") + .parsedSafe()?.data + + val media = + data?.find { it.url?.startsWith(if (season == null) "/movie/$slug-$year-" else "/serie/$slug-$year-") == true } + ?: data?.find { + (it.name.equals( + title, + true + ) && it.type == if (season == null) "Movie" else "Serie") + } + + if (media?.url == null) return + val mediaUrl = if (season == null) { + media.url + } else { + "${media.url}/season/$season/episode/$episode" + } + + val dataId = app.get(fixUrl(mediaUrl, nepuAPI)).document.selectFirst("a[data-embed]")?.attr("data-embed") ?: return + val res = app.post( + "$nepuAPI/ajax/embed", data = mapOf( + "id" to dataId + ), referer = mediaUrl, headers = headers + ).text + + val m3u8 = "(http[^\"]+)".toRegex().find(res)?.groupValues?.get(1) + + callback.invoke( + ExtractorLink( + "Nepu", + "Nepu", + m3u8 ?: return, + "$nepuAPI/", + Qualities.P1080.value, + INFER_TYPE + ) + ) + + } + + suspend fun invokeMoflix( + tmdbId: Int? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit + ) { + val id = (if (season == null) { + "tmdb|movie|$tmdbId" + } else { + "tmdb|series|$tmdbId" + }).let { base64Encode(it.toByteArray()) } + + val loaderUrl = "$moflixAPI/api/v1/titles/$id?loader=titlePage" + val url = if (season == null) { + loaderUrl + } else { + val mediaId = app.get(loaderUrl, referer = "$moflixAPI/").parsedSafe()?.title?.id + "$moflixAPI/api/v1/titles/$mediaId/seasons/$season/episodes/$episode?loader=episodePage" + } + + val res = app.get(url, referer = "$moflixAPI/").parsedSafe() + (res?.episode ?: res?.title)?.videos?.filter { it.category.equals("full", true) } + ?.apmap { iframe -> + val response = app.get(iframe.src ?: return@apmap, referer = "$moflixAPI/") + val host = getBaseUrl(iframe.src) + val doc = response.document.selectFirst("script:containsData(sources:)")?.data() + val script = if (doc.isNullOrEmpty()) { + getAndUnpack(response.text) + } else { + doc + } + val m3u8 = Regex("file:\\s*\"(.*?m3u8.*?)\"").find( + script ?: return@apmap + )?.groupValues?.getOrNull(1) + if (m3u8?.haveDub("$host/") == false) return@apmap + callback.invoke( + ExtractorLink( + "Moflix", + "Moflix [${iframe.name}]", + m3u8 ?: return@apmap, + "$host/", + iframe.quality?.filter { it.isDigit() }?.toIntOrNull() + ?: Qualities.Unknown.value, + INFER_TYPE + ) + ) + } + + } + + //TODO only subs + suspend fun invokeWatchsomuch( + imdbId: String? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + ) { + val id = imdbId?.removePrefix("tt") + val epsId = app.post( + "$watchSomuchAPI/Watch/ajMovieTorrents.aspx", + data = mapOf( + "index" to "0", + "mid" to "$id", + "wsk" to "30fb68aa-1c71-4b8c-b5d4-4ca9222cfb45", + "lid" to "", + "liu" to "" + ), + headers = mapOf("X-Requested-With" to "XMLHttpRequest") + ).parsedSafe()?.movie?.torrents?.let { eps -> + if (season == null) { + eps.firstOrNull()?.id + } else { + eps.find { it.episode == episode && it.season == season }?.id + } + } ?: return + + val (seasonSlug, episodeSlug) = getEpisodeSlug(season, episode) + + val subUrl = if (season == null) { + "$watchSomuchAPI/Watch/ajMovieSubtitles.aspx?mid=$id&tid=$epsId&part=" + } else { + "$watchSomuchAPI/Watch/ajMovieSubtitles.aspx?mid=$id&tid=$epsId&part=S${seasonSlug}E${episodeSlug}" + } + + app.get(subUrl).parsedSafe()?.subtitles?.map { sub -> + subtitleCallback.invoke( + SubtitleFile( + sub.label ?: "", fixUrl( + sub.url + ?: return@map null, watchSomuchAPI + ) + ) + ) + } + + + } + + suspend fun invokeShinobiMovies( + apiUrl: String, + api: String, + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit, + ) { + invokeIndex( + apiUrl, + api, + title, + year, + season, + episode, + callback, + ) + } + + private suspend fun invokeIndex( + apiUrl: String, + api: String, + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit, + password: String = "", + ) { + val passHeaders = mapOf("Authorization" to password) + val query = getIndexQuery(title, year, season, episode).let { + if (api in mkvIndex) "$it mkv" else it + } + val body = + """{"q":"$query","password":null,"page_token":null,"page_index":0}""".toRequestBody( + RequestBodyTypes.JSON.toMediaTypeOrNull() + ) + val data = mapOf("q" to query, "page_token" to "", "page_index" to "0") + val search = if (api in encodedIndex) { + decodeIndexJson( + if (api in lockedIndex) app.post( + "${apiUrl}search", + data = data, + headers = passHeaders, + referer = apiUrl, + timeout = 120L + ).text else app.post("${apiUrl}search", data = data, referer = apiUrl).text + ) + } else { + app.post("${apiUrl}search", requestBody = body, referer = apiUrl, timeout = 120L).text + } + val media = if (api in untrimmedIndex) searchIndex( + title, + season, + episode, + year, + search, + false + ) else searchIndex(title, season, episode, year, search) + media?.apmap { file -> + val pathBody = + """{"id":"${file.id ?: return@apmap null}"}""".toRequestBody(RequestBodyTypes.JSON.toMediaTypeOrNull()) + val pathData = mapOf( + "id" to file.id, + ) + val path = (if (api in encodedIndex) { + if (api in lockedIndex) { + app.post( + "${apiUrl}id2path", + data = pathData, + headers = passHeaders, + referer = apiUrl, + timeout = 120L + ) + } else { + app.post("${apiUrl}id2path", data = pathData, referer = apiUrl, timeout = 120L) + } + } else { + app.post( + "${apiUrl}id2path", + requestBody = pathBody, + referer = apiUrl, + timeout = 120L + ) + }).text.let { path -> + if (api in ddomainIndex) { + val worker = app.get( + "${fixUrl(path, apiUrl).encodeUrl()}?a=view", + referer = if (api in needRefererIndex) apiUrl else "", + timeout = 120L + ).document.selectFirst("script:containsData(downloaddomain)")?.data() + ?.substringAfter("\"downloaddomain\":\"")?.substringBefore("\",")?.let { + "$it/0:" + } + fixUrl(path, worker ?: return@apmap null) + } else { + fixUrl(path, apiUrl) + } + }.encodeUrl() + + val size = "%.2f GB".format( + bytesToGigaBytes( + file.size?.toDouble() + ?: return@apmap null + ) + ) + val quality = getIndexQuality(file.name) + val tags = getIndexQualityTags(file.name) + + callback.invoke( + ExtractorLink( + api, + "$api $tags [$size]", + path, + if (api in needRefererIndex) apiUrl else "", + quality, + ) + ) + + } + + } + + suspend fun invokeGdbotMovies( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit, + ) { + val query = getIndexQuery(title, null, season, episode) + val files = app.get("$gdbot/search?q=$query").document.select("ul.divide-y li").map { + Triple(it.select("a").attr("href"), it.select("a").text(), it.select("span").text()) + }.filter { + matchingIndex( + it.second, + null, + title, + year, + season, + episode, + ) + }.sortedByDescending { + it.third.getFileSize() + } + + files.let { file -> + listOfNotNull( + file.find { it.second.contains("2160p", true) }, + file.find { it.second.contains("1080p", true) }) + }.apmap { file -> + val videoUrl = extractGdflix(file.first) + val quality = getIndexQuality(file.second) + val tags = getIndexQualityTags(file.second) + val size = Regex("(\\d+\\.?\\d+\\sGB|MB)").find(file.third)?.groupValues?.get(0)?.trim() + + callback.invoke( + ExtractorLink( + "GdbotMovies", + "GdbotMovies $tags [$size]", + videoUrl ?: return@apmap null, + "", + quality, + ) + ) + + } + + } + + suspend fun invokeDahmerMovies( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit, + ) { + val url = if (season == null) { + "$dahmerMoviesAPI/movies/${title?.replace(":", "")} ($year)/" + } else { + "$dahmerMoviesAPI/tvs/${title?.replace(":", " -")}/Season $season/" + } + + val request = app.get(url, timeout = 60L) + if (!request.isSuccessful) return + val paths = request.document.select("a").map { + it.text() to it.attr("href") + }.filter { + if (season == null) { + it.first.contains(Regex("(?i)(1080p|2160p)")) + } else { + val (seasonSlug, episodeSlug) = getEpisodeSlug(season, episode) + it.first.contains(Regex("(?i)S${seasonSlug}E${episodeSlug}")) + } + }.ifEmpty { return } + + paths.map { + val quality = getIndexQuality(it.first) + val tags = getIndexQualityTags(it.first) + callback.invoke( + ExtractorLink( + "DahmerMovies", + "DahmerMovies $tags", + (url + it.second).encodeUrl(), + "", + quality, + ) + ) + + } + + } + + suspend fun invoke2embed( + imdbId: String?, + season: Int?, + episode: Int?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val url = if (season == null) { + "$twoEmbedAPI/embed/$imdbId" + } else { + "$twoEmbedAPI/embedtv/$imdbId&s=$season&e=$episode" + } + + val framesrc = app.get(url).document.selectFirst("iframe#iframesrc")?.attr("data-src") + ?: return + val ref = getBaseUrl(framesrc) + val id = framesrc.substringAfter("id=").substringBefore("&") + loadExtractor("https://uqloads.xyz/e/$id", "$ref/", subtitleCallback, callback) + + } + + suspend fun invokeGhostx( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit, + ) { + invokeGpress( + title, + year, + season, + episode, + callback, + BuildConfig.GHOSTX_API, + "Ghostx", + base64Decode("X3NtUWFtQlFzRVRi"), + base64Decode("X3NCV2NxYlRCTWFU") + ) + } + + private suspend fun invokeGpress( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit, + api: String, + name: String, + mediaSelector: String, + episodeSelector: String, + ) { + fun String.decrypt(key: String): List? { + return tryParseJson>(base64Decode(this).xorDecrypt(key)) + } + + val slug = getEpisodeSlug(season, episode) + val query = if (season == null) { + title + } else { + "$title Season $season" + } + val savedCookies = mapOf( + base64Decode("X2lkZW50aXR5Z29tb3ZpZXM3") to base64Decode("NTJmZGM3MGIwMDhjMGIxZDg4MWRhYzBmMDFjY2E4MTllZGQ1MTJkZTAxY2M4YmJjMTIyNGVkNGFhZmI3OGI1MmElM0EyJTNBJTdCaSUzQTAlM0JzJTNBMTglM0ElMjJfaWRlbnRpdHlnb21vdmllczclMjIlM0JpJTNBMSUzQnMlM0E1MiUzQSUyMiU1QjIwNTAzNjYlMkMlMjJIblZSUkFPYlRBU09KRXI0NVl5Q004d2lIb2wwVjFrbyUyMiUyQzI1OTIwMDAlNUQlMjIlM0IlN0Q="), + ) + + var res = app.get("$api/search/$query", timeout = 20) + val cookies = savedCookies + res.cookies + val doc = res.document + val media = doc.select("div.$mediaSelector").map { + Triple(it.attr("data-filmName"), it.attr("data-year"), it.select("a").attr("href")) + }.let { el -> + if (el.size == 1) { + el.firstOrNull() + } else { + el.find { + if (season == null) { + (it.first.equals(title, true) || it.first.equals( + "$title ($year)", + true + )) && it.second.equals("$year") + } else { + it.first.equals("$title - Season $season", true) + } + } + } ?: el.find { it.first.contains("$title", true) && it.second.equals("$year") } + } ?: return + + val iframe = if (season == null) { + media.third + } else { + app.get(fixUrl(media.third, api), cookies = cookies, timeout = 20) + .document.selectFirst("div#$episodeSelector a:contains(Episode ${slug.second})") + ?.attr("href") + } + + res = app.get(fixUrl(iframe ?: return, api), cookies = cookies, timeout = 20) + val url = res.document.select("meta[property=og:url]").attr("content") + val headers = mapOf("X-Requested-With" to "XMLHttpRequest") + val qualities = intArrayOf(2160, 1440, 1080, 720, 480, 360) + + val (serverId, episodeId) = if (season == null) { + url.substringAfterLast("/") to "0" + } else { + url.substringBeforeLast("/").substringAfterLast("/") to url.substringAfterLast("/") + .substringBefore("-") + } + val serverRes = app.get( + "$api/user/servers/$serverId?ep=$episodeId", + cookies = cookies, + referer = url, + headers = headers, + timeout = 20 + ) + val script = getAndUnpack(serverRes.text) + val key = """key\s*=\s*(\d+)""".toRegex().find(script)?.groupValues?.get(1) ?: return + serverRes.document.select("ul li").apmap { el -> + val server = el.attr("data-value") + val encryptedData = app.get( + "$url?server=$server&_=$unixTimeMS", + cookies = cookies, + referer = url, + headers = headers, + timeout = 20 + ).text + val links = encryptedData.decrypt(key) + links?.forEach { video -> + qualities.filter { it <= video.max.toInt() }.forEach { + callback( + ExtractorLink( + name, + name, + video.src.split("360", limit = 3).joinToString(it.toString()), + "$api/", + it, + ) + ) + } + } + } + + } + + suspend fun invokeShowflix( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + api: String = "https://parse.showflix.online" + ) { + val where = if (season == null) "movieName" else "seriesName" + val classes = if (season == null) "movies" else "series" + val body = """ + { + "where": { + "$where": { + "${'$'}regex": "$title", + "${'$'}options": "i" + } + }, + "order": "-updatedAt", + "_method": "GET", + "_ApplicationId": "SHOWFLIXAPPID", + "_JavaScriptKey": "SHOWFLIXMASTERKEY", + "_ClientVersion": "js3.4.1", + "_InstallationId": "58f0e9ca-f164-42e0-a683-a1450ccf0221" + } + """.trimIndent().toRequestBody(RequestBodyTypes.JSON.toMediaTypeOrNull()) + + val data = + app.post("$api/parse/classes/$classes", requestBody = body).text + val iframes = if (season == null) { + val result = tryParseJson(data)?.resultsMovies?.find { + it.movieName.equals("$title ($year)", true) + } + listOf( + "https://streamwish.to/e/${result?.streamwish}", + "https://filelions.to/v/${result?.filelions}.html", + "https://streamruby.com/e/${result?.streamruby}.html", + ) + } else { + val result = tryParseJson(data)?.resultsSeries?.find { + it.seriesName.equals(title, true) + } + listOf( + result?.streamwish?.get("Season $season")?.get(episode!!), + result?.filelions?.get("Season $season")?.get(episode!!), + result?.streamruby?.get("Season $season")?.get(episode!!), + ) + } + + iframes.apmap { iframe -> + loadExtractor(iframe ?: return@apmap, "$showflixAPI/", subtitleCallback, callback) + } + + } + + suspend fun invokeZoechip( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit, + ) { + val slug = title?.createSlug() + val url = if (season == null) { + "$zoechipAPI/film/${title?.createSlug()}-$year" + } else { + "$zoechipAPI/episode/$slug-season-$season-episode-$episode" + } + + val id = app.get(url).document.selectFirst("div#show_player_ajax")?.attr("movie-id") ?: return + + val server = app.post( + "$zoechipAPI/wp-admin/admin-ajax.php", data = mapOf( + "action" to "lazy_player", + "movieID" to id, + ), referer = url, headers = mapOf( + "X-Requested-With" to "XMLHttpRequest" + ) + ).document.selectFirst("ul.nav a:contains(Filemoon)")?.attr("data-server") + + val res = app.get(server ?: return, referer = "$zoechipAPI/") + val host = getBaseUrl(res.url) + val script = res.document.select("script:containsData(function(p,a,c,k,e,d))").last()?.data() + val unpacked = getAndUnpack(script ?: return) + + val m3u8 = Regex("file:\\s*\"(.*?m3u8.*?)\"").find(unpacked)?.groupValues?.getOrNull(1) + + M3u8Helper.generateM3u8( + "Zoechip", + m3u8 ?: return, + "$host/", + ).forEach(callback) + + } + + suspend fun invokeCinemaTv( + imdbId: String? = null, + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ) { + val id = imdbId?.removePrefix("tt") + val slug = title?.createSlug() + val url = if (season == null) { + "$cinemaTvAPI/movies/play/$id-$slug-$year" + } else { + "$cinemaTvAPI/shows/play/$id-$slug-$year" + } + + val headers = mapOf( + "x-requested-with" to "XMLHttpRequest", + ) + val doc = app.get(url, headers = headers).document + val script = doc.selectFirst("script:containsData(hash:)")?.data() + val hash = Regex("hash:\\s*['\"](\\S+)['\"]").find(script ?: return)?.groupValues?.get(1) + val expires = Regex("expires:\\s*(\\d+)").find(script)?.groupValues?.get(1) + val episodeId = (if (season == null) { + """id_movie:\s*(\d+)""" + } else { + """episode:\s*['"]$episode['"],[\n\s]+id_episode:\s*(\d+),[\n\s]+season:\s*['"]$season['"]""" + }).let { it.toRegex().find(script)?.groupValues?.get(1) } + + val videoUrl = if (season == null) { + "$cinemaTvAPI/api/v1/security/movie-access?id_movie=$episodeId&hash=$hash&expires=$expires" + } else { + "$cinemaTvAPI/api/v1/security/episode-access?id_episode=$episodeId&hash=$hash&expires=$expires" + } + + val sources = app.get( + videoUrl, + referer = url, + headers = headers + ).parsedSafe() + + sources?.streams?.mapKeys { source -> + callback.invoke( + ExtractorLink( + "CinemaTv", + "CinemaTv", + source.value, + "$cinemaTvAPI/", + getQualityFromName(source.key), + true + ) + ) + } + + sources?.subtitles?.map { sub -> + val file = sub.file.toString() + subtitleCallback.invoke( + SubtitleFile( + sub.language ?: return@map, + if (file.startsWith("[")) return@map else fixUrl(file, cinemaTvAPI), + ) + ) + } + + } + + suspend fun invokeNinetv( + tmdbId: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val url = if (season == null) { + "$nineTvAPI/movie/$tmdbId" + } else { + "$nineTvAPI/tv/$tmdbId-$season-$episode" + } + + val iframe = app.get(url, referer = "https://pressplay.top/").document.selectFirst("iframe") + ?.attr("src") + + loadExtractor(iframe ?: return, "$nineTvAPI/", subtitleCallback, callback) + + } + + suspend fun invokeNowTv( + tmdbId: Int? = null, + imdbId: String? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit, + referer: String = "https://bflix.gs/" + ) { + suspend fun String.isSuccess(): Boolean { + return app.get(this, referer = referer).isSuccessful + } + + val slug = getEpisodeSlug(season, episode) + var url = + if (season == null) "$nowTvAPI/$tmdbId.mp4" else "$nowTvAPI/tv/$tmdbId/s${season}e${slug.second}.mp4" + if (!url.isSuccess()) { + url = if (season == null) { + val temp = "$nowTvAPI/$imdbId.mp4" + if (temp.isSuccess()) temp else "$nowTvAPI/$tmdbId-1.mp4" + } else { + "$nowTvAPI/tv/$imdbId/s${season}e${slug.second}.mp4" + } + if (!app.get(url, referer = referer).isSuccessful) return + } + callback.invoke( + ExtractorLink( + "NowTv", + "NowTv", + url, + referer, + Qualities.P1080.value, + ) + ) + } + + suspend fun invokeRidomovies( + tmdbId: Int? = null, + imdbId: String? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ) { + val mediaSlug = app.get("$ridomoviesAPI/core/api/search?q=$imdbId") + .parsedSafe()?.data?.items?.find { + it.contentable?.tmdbId == tmdbId || it.contentable?.imdbId == imdbId + }?.slug ?: return + + val id = season?.let { + val episodeUrl = "$ridomoviesAPI/tv/$mediaSlug/season-$it/episode-$episode" + app.get(episodeUrl).text.substringAfterLast("""postid\":\"""").substringBefore("""\""") + } ?: mediaSlug + + val url = + "$ridomoviesAPI/core/api/${if (season == null) "movies" else "episodes"}/$id/videos" + app.get(url).parsedSafe()?.data?.apmap { link -> + val iframe = Jsoup.parse(link.url ?: return@apmap).select("iframe").attr("data-src") + if (iframe.startsWith("https://closeload.top")) { + val unpacked = getAndUnpack(app.get(iframe, referer = "$ridomoviesAPI/").text) + val video = Regex("=\"(aHR.*?)\";").find(unpacked)?.groupValues?.get(1) + callback.invoke( + ExtractorLink( + "Ridomovies", + "Ridomovies", + base64Decode(video ?: return@apmap), + "${getBaseUrl(iframe)}/", + Qualities.P1080.value, + isM3u8 = true + ) + ) + } else { + loadExtractor(iframe, "$ridomoviesAPI/", subtitleCallback, callback) + } + } + } + + suspend fun invokeAllMovieland( + imdbId: String? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit, + host: String = "https://guinsters286nedril.com", + ) { + val res = app.get( + "$host/play/$imdbId", + referer = "$allmovielandAPI/" + ).document.selectFirst("script:containsData(player =)")?.data()?.substringAfter("{") + ?.substringBefore(";")?.substringBefore(")") + val json = tryParseJson("{${res ?: return}") + val headers = mapOf("X-CSRF-TOKEN" to "${json?.key}") + + val serverRes = app.get( + fixUrl( + json?.file + ?: return, host + ), headers = headers, referer = "$allmovielandAPI/" + ).text.replace(Regex(""",\s*\[]"""), "") + val servers = tryParseJson>(serverRes).let { server -> + if (season == null) { + server?.map { it.file to it.title } + } else { + server?.find { it.id.equals("$season") }?.folder?.find { it.episode.equals("$episode") }?.folder?.map { + it.file to it.title + } + } + } + + servers?.apmap { (server, lang) -> + val path = app.post( + "${host}/playlist/${server ?: return@apmap}.txt", + headers = headers, + referer = "$allmovielandAPI/" + ).text + M3u8Helper.generateM3u8("Allmovieland [$lang]", path, "$allmovielandAPI/") + .forEach(callback) + } + + } + + suspend fun invokeEmovies( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ) { + val slug = title.createSlug() + val url = if (season == null) { + "$emoviesAPI/watch-$slug-$year-1080p-hd-online-free/watching.html" + } else { + val first = "$emoviesAPI/watch-$slug-season-$season-$year-1080p-hd-online-free.html" + val second = "$emoviesAPI/watch-$slug-$year-1080p-hd-online-free.html" + if (app.get(first).isSuccessful) first else second + } + + val res = app.get(url).document + val id = (if (season == null) { + res.selectFirst("select#selectServer option[sv=oserver]")?.attr("value") + } else { + res.select("div.le-server a").find { + val num = + Regex("Episode (\\d+)").find(it.text())?.groupValues?.get(1)?.toIntOrNull() + num == episode + }?.attr("href") + })?.substringAfter("id=")?.substringBefore("&") + + val server = app.get( + "$emoviesAPI/ajax/v4_get_sources?s=oserver&id=${id ?: return}&_=${unixTimeMS}", + headers = mapOf("X-Requested-With" to "XMLHttpRequest") + ).parsedSafe()?.value + + val script = app.get( + server + ?: return, referer = "$emoviesAPI/" + ).document.selectFirst("script:containsData(sources:)")?.data() + ?: return + val sources = Regex("sources:\\s*\\[(.*)],").find(script)?.groupValues?.get(1)?.let { + tryParseJson>("[$it]") + } + val tracks = Regex("tracks:\\s*\\[(.*)],").find(script)?.groupValues?.get(1)?.let { + tryParseJson>("[$it]") + } + + sources?.map { source -> + M3u8Helper.generateM3u8( + "Emovies", source.file + ?: return@map, "https://embed.vodstream.xyz/" + ).forEach(callback) + } + + tracks?.map { track -> + subtitleCallback.invoke( + SubtitleFile( + track.label ?: "", + track.file ?: return@map, + ) + ) + } + + + } + + suspend fun invokeSFMovies( + tmdbId: Int? = null, + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit, + ) { + val headers = + mapOf("Authorization" to "Bearer 44d784c55e9a1e3dbb586f24b18b1cbcd1521673bd6178ef385890d2f989681fe22d05e291e2e0f03fce99cbc50cd520219e52cc6e30c944a559daf53a129af18349ec98f6a0e4e66b8d370a354f4f7fbd49df0ab806d533a3db71eecc7f75131a59ce8cffc5e0cc38e8af5919c23c0d904fbe31995308f065f0ff9cd1eda488") + val data = app.get( + "${BuildConfig.SFMOVIES_API}/api/mains?filters[title][\$contains]=$title", + headers = headers + ).parsedSafe()?.data + val media = data?.find { + it.attributes?.contentId.equals("$tmdbId") || (it.attributes?.title.equals( + title, + true + ) || it.attributes?.releaseDate?.substringBefore("-").equals("$year")) + } + val video = if (season == null || episode == null) { + media?.attributes?.video + } else { + media?.attributes?.seriess?.get(season - 1)?.get(episode - 1)?.svideos + } ?: return + callback.invoke( + ExtractorLink( + "SFMovies", + "SFMovies", + fixUrl(video, getSfServer()), + "", + Qualities.P1080.value, + INFER_TYPE + ) + ) + } + +} + diff --git a/SoraStream/src/main/kotlin/com/hexated/SoraParser.kt b/SoraStream/src/main/kotlin/com/hexated/SoraParser.kt new file mode 100644 index 0000000..029d0af --- /dev/null +++ b/SoraStream/src/main/kotlin/com/hexated/SoraParser.kt @@ -0,0 +1,483 @@ +package com.hexated + +import com.fasterxml.jackson.annotation.JsonProperty +import org.jsoup.Jsoup +import org.jsoup.nodes.Document + +data class CrunchyrollAccessToken( + val accessToken: String? = null, + val tokenType: String? = null, + val bucket: String? = null, + val policy: String? = null, + val signature: String? = null, + val key_pair_id: String? = null, +) + +data class FDMovieIFrame( + val link: String, + val quality: String, + val size: String, + val type: String, +) + +data class AniIds(var id: Int? = null, var idMal: Int? = null) + +data class TmdbDate( + val today: String, + val nextWeek: String, +) + +data class AniwaveResponse( + val result: String +) { + fun asJsoup(): Document { + return Jsoup.parse(result) + } +} + +data class AniwaveServer( + val result: Result +) { + data class Result( + val url: String + ) { + fun decrypt(): String { + return AniwaveUtils.decodeVrf(url) + } + } +} + +data class MoflixResponse( + @JsonProperty("title") val title: Episode? = null, + @JsonProperty("episode") val episode: Episode? = null, +) { + data class Episode( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("videos") val videos: ArrayList? = arrayListOf(), + ) { + data class Videos( + @JsonProperty("name") val name: String? = null, + @JsonProperty("category") val category: String? = null, + @JsonProperty("src") val src: String? = null, + @JsonProperty("quality") val quality: String? = null, + ) + } +} + +data class AniMedia( + @JsonProperty("id") var id: Int? = null, + @JsonProperty("idMal") var idMal: Int? = null +) + +data class AniPage(@JsonProperty("media") var media: java.util.ArrayList = arrayListOf()) + +data class AniData(@JsonProperty("Page") var Page: AniPage? = AniPage()) + +data class AniSearch(@JsonProperty("data") var data: AniData? = AniData()) + +data class GpressSources( + @JsonProperty("src") val src: String, + @JsonProperty("file") val file: String? = null, + @JsonProperty("label") val label: Int? = null, + @JsonProperty("max") val max: String, +) + +data class UHDBackupUrl( + @JsonProperty("url") val url: String? = null, +) + +data class ResponseHash( + @JsonProperty("embed_url") val embed_url: String, + @JsonProperty("key") val key: String? = null, + @JsonProperty("type") val type: String? = null, +) + +data class KisskhSources( + @JsonProperty("Video") val video: String?, + @JsonProperty("ThirdParty") val thirdParty: String?, +) + +data class KisskhSubtitle( + @JsonProperty("src") val src: String?, + @JsonProperty("label") val label: String?, +) + +data class KisskhEpisodes( + @JsonProperty("id") val id: Int?, + @JsonProperty("number") val number: Int?, +) + +data class KisskhDetail( + @JsonProperty("episodes") val episodes: ArrayList? = arrayListOf(), +) + +data class KisskhResults( + @JsonProperty("id") val id: Int?, + @JsonProperty("title") val title: String?, +) + +data class DriveBotLink( + @JsonProperty("url") val url: String? = null, +) + +data class DirectDl( + @JsonProperty("download_url") val download_url: String? = null, +) + +data class Safelink( + @JsonProperty("safelink") val safelink: String? = null, +) + +data class FDAds( + @JsonProperty("linkr") val linkr: String? = null, +) + +data class ZShowEmbed( + @JsonProperty("m") val meta: String? = null, +) + +data class WatchsomuchTorrents( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("movieId") val movieId: Int? = null, + @JsonProperty("season") val season: Int? = null, + @JsonProperty("episode") val episode: Int? = null, +) + +data class WatchsomuchMovies( + @JsonProperty("torrents") val torrents: ArrayList? = arrayListOf(), +) + +data class WatchsomuchResponses( + @JsonProperty("movie") val movie: WatchsomuchMovies? = null, +) + +data class WatchsomuchSubtitles( + @JsonProperty("url") val url: String? = null, + @JsonProperty("label") val label: String? = null, +) + +data class WatchsomuchSubResponses( + @JsonProperty("subtitles") val subtitles: ArrayList? = arrayListOf(), +) + +data class IndexMedia( + @JsonProperty("id") val id: String? = null, + @JsonProperty("driveId") val driveId: String? = null, + @JsonProperty("mimeType") val mimeType: String? = null, + @JsonProperty("size") val size: String? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("modifiedTime") val modifiedTime: String? = null, +) + +data class IndexData( + @JsonProperty("files") val files: ArrayList? = arrayListOf(), +) + +data class IndexSearch( + @JsonProperty("data") val data: IndexData? = null, +) + +data class JikanExternal( + @JsonProperty("name") val name: String? = null, + @JsonProperty("url") val url: String? = null, +) + +data class JikanData( + @JsonProperty("title") val title: String? = null, + @JsonProperty("external") val external: ArrayList? = arrayListOf(), +) + +data class JikanResponse( + @JsonProperty("data") val data: JikanData? = null, +) + +data class VidsrctoResult( + @JsonProperty("id") val id: String? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("url") val url: String? = null, +) + +data class VidsrctoResponse( + @JsonProperty("result") val result: VidsrctoResult? = null, +) + +data class VidsrctoSources( + @JsonProperty("result") val result: ArrayList? = arrayListOf(), +) + +data class VidsrctoSubtitles( + @JsonProperty("label") val label: String? = null, + @JsonProperty("file") val file: String? = null, +) + +data class AnilistExternalLinks( + @JsonProperty("id") var id: Int? = null, + @JsonProperty("site") var site: String? = null, + @JsonProperty("url") var url: String? = null, + @JsonProperty("type") var type: String? = null, +) + +data class AnilistMedia(@JsonProperty("externalLinks") var externalLinks: ArrayList = arrayListOf()) + +data class AnilistData(@JsonProperty("Media") var Media: AnilistMedia? = AnilistMedia()) + +data class AnilistResponses(@JsonProperty("data") var data: AnilistData? = AnilistData()) + +data class CrunchyrollToken( + @JsonProperty("access_token") val accessToken: String? = null, + @JsonProperty("token_type") val tokenType: String? = null, + @JsonProperty("cms") val cms: Cms? = null, +) { + data class Cms( + @JsonProperty("bucket") var bucket: String? = null, + @JsonProperty("policy") var policy: String? = null, + @JsonProperty("signature") var signature: String? = null, + @JsonProperty("key_pair_id") var key_pair_id: String? = null, + ) +} + +data class CrunchyrollVersions( + @JsonProperty("audio_locale") val audio_locale: String? = null, + @JsonProperty("guid") val guid: String? = null, +) + +data class CrunchyrollData( + @JsonProperty("id") val id: String? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("slug_title") val slug_title: String? = null, + @JsonProperty("season_number") val season_number: Int? = null, + @JsonProperty("episode_number") val episode_number: Int? = null, + @JsonProperty("versions") val versions: ArrayList? = null, + @JsonProperty("streams_link") val streams_link: String? = null, +) + +data class CrunchyrollResponses( + @JsonProperty("data") val data: ArrayList? = arrayListOf(), +) + +data class CrunchyrollSourcesResponses( + @JsonProperty("streams") val streams: Streams? = Streams(), + @JsonProperty("subtitles") val subtitles: HashMap>? = hashMapOf(), +) { + data class Streams( + @JsonProperty("adaptive_hls") val adaptive_hls: HashMap>? = hashMapOf(), + @JsonProperty("vo_adaptive_hls") val vo_adaptive_hls: HashMap>? = hashMapOf(), + ) +} + +data class MALSyncSites( + @JsonProperty("Zoro") val zoro: HashMap>? = hashMapOf(), + @JsonProperty("9anime") val nineAnime: HashMap>? = hashMapOf(), +) + +data class MALSyncResponses( + @JsonProperty("Sites") val sites: MALSyncSites? = null, +) + +data class HianimeResponses( + @JsonProperty("html") val html: String? = null, + @JsonProperty("link") val link: String? = null, +) + +data class MalSyncRes( + @JsonProperty("Sites") val Sites: Map>>? = null, +) + +data class GokuData( + @JsonProperty("link") val link: String? = null, +) + +data class GokuServer( + @JsonProperty("data") val data: GokuData? = GokuData(), +) + +data class AllMovielandEpisodeFolder( + @JsonProperty("title") val title: String? = null, + @JsonProperty("id") val id: String? = null, + @JsonProperty("file") val file: String? = null, +) + +data class AllMovielandSeasonFolder( + @JsonProperty("episode") val episode: String? = null, + @JsonProperty("id") val id: String? = null, + @JsonProperty("folder") val folder: ArrayList? = arrayListOf(), +) + +data class AllMovielandServer( + @JsonProperty("title") val title: String? = null, + @JsonProperty("id") val id: String? = null, + @JsonProperty("file") val file: String? = null, + @JsonProperty("folder") val folder: ArrayList? = arrayListOf(), +) + +data class AllMovielandPlaylist( + @JsonProperty("file") val file: String? = null, + @JsonProperty("key") val key: String? = null, + @JsonProperty("href") val href: String? = null, +) + +data class DumpMedia( + @JsonProperty("id") val id: String? = null, + @JsonProperty("domainType") val domainType: Int? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("releaseTime") val releaseTime: String? = null, +) + +data class DumpQuickSearchData( + @JsonProperty("searchResults") val searchResults: ArrayList? = arrayListOf(), +) + +data class SubtitlingList( + @JsonProperty("languageAbbr") val languageAbbr: String? = null, + @JsonProperty("language") val language: String? = null, + @JsonProperty("subtitlingUrl") val subtitlingUrl: String? = null, +) + +data class DefinitionList( + @JsonProperty("code") val code: String? = null, + @JsonProperty("description") val description: String? = null, +) + +data class EpisodeVo( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("seriesNo") val seriesNo: Int? = null, + @JsonProperty("definitionList") val definitionList: ArrayList? = arrayListOf(), + @JsonProperty("subtitlingList") val subtitlingList: ArrayList? = arrayListOf(), +) + +data class DumpMediaDetail( + @JsonProperty("episodeVo") val episodeVo: ArrayList? = arrayListOf(), +) + +data class EMovieServer( + @JsonProperty("value") val value: String? = null, +) + +data class EMovieSources( + @JsonProperty("file") val file: String? = null, +) + +data class EMovieTraks( + @JsonProperty("file") val file: String? = null, + @JsonProperty("label") val label: String? = null, +) + +data class ShowflixResultsMovies( + @JsonProperty("movieName") val movieName: String? = null, + @JsonProperty("streamwish") val streamwish: String? = null, + @JsonProperty("filelions") val filelions: String? = null, + @JsonProperty("streamruby") val streamruby: String? = null, +) + +data class ShowflixResultsSeries( + @JsonProperty("seriesName") val seriesName: String? = null, + @JsonProperty("streamwish") val streamwish: HashMap>? = hashMapOf(), + @JsonProperty("filelions") val filelions: HashMap>? = hashMapOf(), + @JsonProperty("streamruby") val streamruby: HashMap>? = hashMapOf(), +) + +data class ShowflixSearchMovies( + @JsonProperty("results") val resultsMovies: ArrayList? = arrayListOf(), +) + +data class ShowflixSearchSeries( + @JsonProperty("results") val resultsSeries: ArrayList? = arrayListOf(), +) + +data class SFMoviesSeriess( + @JsonProperty("title") var title: String? = null, + @JsonProperty("svideos") var svideos: String? = null, +) + +data class SFMoviesAttributes( + @JsonProperty("title") var title: String? = null, + @JsonProperty("video") var video: String? = null, + @JsonProperty("releaseDate") var releaseDate: String? = null, + @JsonProperty("seriess") var seriess: ArrayList>? = arrayListOf(), + @JsonProperty("contentId") var contentId: String? = null, +) + +data class SFMoviesData( + @JsonProperty("id") var id: Int? = null, + @JsonProperty("attributes") var attributes: SFMoviesAttributes? = SFMoviesAttributes() +) + +data class SFMoviesSearch( + @JsonProperty("data") var data: ArrayList? = arrayListOf(), +) + +data class RidoContentable( + @JsonProperty("imdbId") var imdbId: String? = null, + @JsonProperty("tmdbId") var tmdbId: Int? = null, +) + +data class RidoItems( + @JsonProperty("slug") var slug: String? = null, + @JsonProperty("contentable") var contentable: RidoContentable? = null, +) + +data class RidoData( + @JsonProperty("url") var url: String? = null, + @JsonProperty("items") var items: ArrayList? = arrayListOf(), +) + +data class RidoResponses( + @JsonProperty("data") var data: ArrayList? = arrayListOf(), +) + +data class RidoSearch( + @JsonProperty("data") var data: RidoData? = null, +) + +data class SmashySources( + @JsonProperty("sourceUrls") var sourceUrls: ArrayList? = arrayListOf(), + @JsonProperty("subtitleUrls") var subtitleUrls: String? = null, +) + +data class AoneroomResponse( + @JsonProperty("data") val data: Data? = null, +) { + data class Data( + @JsonProperty("items") val items: ArrayList? = arrayListOf(), + @JsonProperty("list") val list: ArrayList? = arrayListOf(), + ) { + data class Items( + @JsonProperty("subjectId") val subjectId: String? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("releaseDate") val releaseDate: String? = null, + ) + + data class List( + @JsonProperty("resourceLink") val resourceLink: String? = null, + @JsonProperty("extCaptions") val extCaptions: ArrayList? = arrayListOf(), + @JsonProperty("se") val se: Int? = null, + @JsonProperty("ep") val ep: Int? = null, + @JsonProperty("resolution") val resolution: Int? = null, + ) { + data class ExtCaptions( + @JsonProperty("lanName") val lanName: String? = null, + @JsonProperty("url") val url: String? = null, + ) + } + } +} + +data class CinemaTvResponse( + @JsonProperty("streams") val streams: HashMap? = null, + @JsonProperty("subtitles") val subtitles: ArrayList? = arrayListOf(), +) { + data class Subtitles( + @JsonProperty("language") val language: String? = null, + @JsonProperty("file") val file: Any? = null, + ) +} + +data class NepuSearch( + @JsonProperty("data") val data: ArrayList? = arrayListOf(), +) { + data class Data( + @JsonProperty("url") val url: String? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("type") val type: String? = null, + ) +} \ No newline at end of file diff --git a/SoraStream/src/main/kotlin/com/hexated/SoraStream.kt b/SoraStream/src/main/kotlin/com/hexated/SoraStream.kt new file mode 100644 index 0000000..be0a43d --- /dev/null +++ b/SoraStream/src/main/kotlin/com/hexated/SoraStream.kt @@ -0,0 +1,854 @@ +package com.hexated + +import com.fasterxml.jackson.annotation.JsonProperty +import com.hexated.SoraExtractor.invoke2embed +import com.hexated.SoraExtractor.invokeAllMovieland +import com.hexated.SoraExtractor.invokeAnimes +import com.hexated.SoraExtractor.invokeAoneroom +import com.hexated.SoraExtractor.invokeFilmxy +import com.hexated.SoraExtractor.invokeKimcartoon +import com.hexated.SoraExtractor.invokeVidSrc +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer +import com.lagradost.cloudstream3.metaproviders.TmdbProvider +import com.hexated.SoraExtractor.invokeDahmerMovies +import com.hexated.SoraExtractor.invokeDoomovies +import com.hexated.SoraExtractor.invokeDotmovies +import com.hexated.SoraExtractor.invokeDramaday +import com.hexated.SoraExtractor.invokeDreamfilm +import com.hexated.SoraExtractor.invokeFDMovies +import com.hexated.SoraExtractor.invokeFlixon +import com.hexated.SoraExtractor.invokeGoku +import com.hexated.SoraExtractor.invokeKisskh +import com.hexated.SoraExtractor.invokeLing +import com.hexated.SoraExtractor.invokeM4uhd +import com.hexated.SoraExtractor.invokeNinetv +import com.hexated.SoraExtractor.invokeNowTv +import com.hexated.SoraExtractor.invokeRStream +import com.hexated.SoraExtractor.invokeRidomovies +import com.hexated.SoraExtractor.invokeSmashyStream +import com.hexated.SoraExtractor.invokeDumpStream +import com.hexated.SoraExtractor.invokeEmovies +import com.hexated.SoraExtractor.invokeHdmovies4u +import com.hexated.SoraExtractor.invokeMultimovies +import com.hexated.SoraExtractor.invokeNetmovies +import com.hexated.SoraExtractor.invokeShowflix +import com.hexated.SoraExtractor.invokeTvMovies +import com.hexated.SoraExtractor.invokeUhdmovies +import com.hexated.SoraExtractor.invokeVegamovies +import com.hexated.SoraExtractor.invokeVidsrcto +import com.hexated.SoraExtractor.invokeCinemaTv +import com.hexated.SoraExtractor.invokeMoflix +import com.hexated.SoraExtractor.invokeGhostx +import com.hexated.SoraExtractor.invokeNepu +import com.hexated.SoraExtractor.invokeWatchCartoon +import com.hexated.SoraExtractor.invokeWatchsomuch +import com.hexated.SoraExtractor.invokeZoechip +import com.hexated.SoraExtractor.invokeZshow +import com.lagradost.cloudstream3.LoadResponse.Companion.addImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.addTMDbId +import com.lagradost.cloudstream3.network.CloudflareKiller +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.ExtractorLink +import kotlin.math.roundToInt + +open class SoraStream : TmdbProvider() { + override var name = "SoraStream" + override val hasMainPage = true + override val instantLinkLoading = true + override val useMetaLoadResponse = true + override val hasQuickSearch = true + override val supportedTypes = setOf( + TvType.Movie, + TvType.TvSeries, + TvType.Anime, + ) + + val wpRedisInterceptor by lazy { CloudflareKiller() } + + /** AUTHOR : Hexated & Sora */ + companion object { + /** TOOLS */ + private const val tmdbAPI = "https://api.themoviedb.org/3" + const val gdbot = "https://gdtot.pro" + const val anilistAPI = "https://graphql.anilist.co" + const val malsyncAPI = "https://api.malsync.moe" + const val jikanAPI = "https://api.jikan.moe/v4" + + private const val apiKey = BuildConfig.TMDB_API + + /** ALL SOURCES */ + const val twoEmbedAPI = "https://www.2embed.cc" + const val vidSrcAPI = "https://vidsrc.me" + const val dreamfilmAPI = "https://dreamfilmsw.net" + const val noverseAPI = "https://www.nollyverse.com" + const val filmxyAPI = "https://www.filmxy.vip" + const val kimcartoonAPI = "https://kimcartoon.li" + const val hianimeAPI = "https://hianime.to" + const val aniwaveAPI = "https://aniwave.to" + const val crunchyrollAPI = "https://beta-api.crunchyroll.com" + const val kissKhAPI = "https://kisskh.co" + const val lingAPI = "https://ling-online.net" + const val m4uhdAPI = "https://ww1.streamm4u.ws" + const val rStreamAPI = "https://remotestream.cc" + const val flixonAPI = "https://flixon.lol" + const val smashyStreamAPI = "https://embed.smashystream.com" + const val watchSomuchAPI = "https://watchsomuch.tv" // sub only + const val cinemaTvAPI = BuildConfig.CINEMATV_API + const val nineTvAPI = "https://moviesapi.club" + const val nowTvAPI = "https://myfilestorage.xyz" + const val gokuAPI = "https://goku.sx" + const val zshowAPI = BuildConfig.ZSHOW_API + const val ridomoviesAPI = "https://ridomovies.tv" + const val emoviesAPI = "https://emovies.si" + const val multimoviesAPI = "https://multimovies.top" + const val multimovies2API = "https://multimovies.click" + const val netmoviesAPI = "https://netmovies.to" + const val allmovielandAPI = "https://allmovieland.fun" + const val doomoviesAPI = "https://doomovies.net" + const val vidsrctoAPI = "https://vidsrc.to" + const val dramadayAPI = "https://dramaday.me" + const val animetoshoAPI = "https://animetosho.org" + const val showflixAPI = "https://showflix.lol" + const val aoneroomAPI = "https://api3.aoneroom.com" + const val mMoviesAPI = "https://multimovies.uno" + const val watchCartoonAPI = "https://www1.watchcartoononline.bz" + const val moflixAPI = "https://moflix-stream.xyz" + const val zoechipAPI = "https://zoechip.org" + const val nepuAPI = "https://nepu.to" + const val fdMoviesAPI = "https://freedrivemovie.com" + const val uhdmoviesAPI = "https://uhdmovies.asia" + const val hdmovies4uAPI = "https://hdmovies4u.day" + const val vegaMoviesAPI = "https://vegamovies.ong" + const val dotmoviesAPI = "https://luxmovies.biz" + const val tvMoviesAPI = "https://www.tvseriesnmovies.com" + const val dahmerMoviesAPI = "https://odd-bird-1319.zwuhygoaqe.workers.dev" + + fun getType(t: String?): TvType { + return when (t) { + "movie" -> TvType.Movie + else -> TvType.TvSeries + } + } + + fun getStatus(t: String?): ShowStatus { + return when (t) { + "Returning Series" -> ShowStatus.Ongoing + else -> ShowStatus.Completed + } + } + + } + + override val mainPage = mainPageOf( + "$tmdbAPI/trending/all/day?api_key=$apiKey®ion=US" to "Trending", + "$tmdbAPI/movie/popular?api_key=$apiKey®ion=US" to "Popular Movies", + "$tmdbAPI/tv/popular?api_key=$apiKey®ion=US&with_original_language=en" to "Popular TV Shows", + "$tmdbAPI/tv/airing_today?api_key=$apiKey®ion=US&with_original_language=en" to "Airing Today TV Shows", + "$tmdbAPI/discover/tv?api_key=$apiKey&with_networks=213" to "Netflix", + "$tmdbAPI/discover/tv?api_key=$apiKey&with_networks=1024" to "Amazon", + "$tmdbAPI/discover/tv?api_key=$apiKey&with_networks=2739" to "Disney+", + "$tmdbAPI/discover/tv?api_key=$apiKey&with_networks=453" to "Hulu", + "$tmdbAPI/discover/tv?api_key=$apiKey&with_networks=2552" to "Apple TV+", + "$tmdbAPI/discover/tv?api_key=$apiKey&with_networks=49" to "HBO", + "$tmdbAPI/discover/tv?api_key=$apiKey&with_networks=4330" to "Paramount+", + "$tmdbAPI/discover/tv?api_key=$apiKey&with_networks=3353" to "Peacock", + "$tmdbAPI/movie/top_rated?api_key=$apiKey®ion=US" to "Top Rated Movies", + "$tmdbAPI/tv/top_rated?api_key=$apiKey®ion=US" to "Top Rated TV Shows", + "$tmdbAPI/movie/upcoming?api_key=$apiKey®ion=US" to "Upcoming Movies", + "$tmdbAPI/discover/tv?api_key=$apiKey&with_original_language=ko" to "Korean Shows", + "$tmdbAPI/discover/tv?api_key=$apiKey&with_keywords=210024|222243&sort_by=popularity.desc&air_date.lte=${getDate().today}&air_date.gte=${getDate().today}" to "Airing Today Anime", + "$tmdbAPI/discover/tv?api_key=$apiKey&with_keywords=210024|222243&sort_by=popularity.desc&air_date.lte=${getDate().nextWeek}&air_date.gte=${getDate().today}" to "On The Air Anime", + "$tmdbAPI/discover/tv?api_key=$apiKey&with_keywords=210024|222243" to "Anime", + "$tmdbAPI/discover/movie?api_key=$apiKey&with_keywords=210024|222243" to "Anime Movies", + ) + + private fun getImageUrl(link: String?): String? { + if (link == null) return null + return if (link.startsWith("/")) "https://image.tmdb.org/t/p/w500/$link" else link + } + + private fun getOriImageUrl(link: String?): String? { + if (link == null) return null + return if (link.startsWith("/")) "https://image.tmdb.org/t/p/original/$link" else link + } + + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + val adultQuery = + if (settingsForProvider.enableAdult) "" else "&without_keywords=190370|13059|226161|195669" + val type = if (request.data.contains("/movie")) "movie" else "tv" + val home = app.get("${request.data}$adultQuery&page=$page") + .parsedSafe()?.results?.mapNotNull { media -> + media.toSearchResponse(type) + } ?: throw ErrorLoadingException("Invalid Json reponse") + return newHomePageResponse(request.name, home) + } + + private fun Media.toSearchResponse(type: String? = null): SearchResponse? { + return newMovieSearchResponse( + title ?: name ?: originalTitle ?: return null, + Data(id = id, type = mediaType ?: type).toJson(), + TvType.Movie, + ) { + this.posterUrl = getImageUrl(posterPath) + } + } + + override suspend fun quickSearch(query: String): List? = search(query) + + override suspend fun search(query: String): List? { + return app.get("$tmdbAPI/search/multi?api_key=$apiKey&language=en-US&query=$query&page=1&include_adult=${settingsForProvider.enableAdult}") + .parsedSafe()?.results?.mapNotNull { media -> + media.toSearchResponse() + } + } + + override suspend fun load(url: String): LoadResponse? { + val data = parseJson(url) + val type = getType(data.type) + val append = "alternative_titles,credits,external_ids,keywords,videos,recommendations" + val resUrl = if (type == TvType.Movie) { + "$tmdbAPI/movie/${data.id}?api_key=$apiKey&append_to_response=$append" + } else { + "$tmdbAPI/tv/${data.id}?api_key=$apiKey&append_to_response=$append" + } + val res = app.get(resUrl).parsedSafe() + ?: throw ErrorLoadingException("Invalid Json Response") + + val title = res.title ?: res.name ?: return null + val poster = getOriImageUrl(res.posterPath) + val bgPoster = getOriImageUrl(res.backdropPath) + val orgTitle = res.originalTitle ?: res.originalName ?: return null + val releaseDate = res.releaseDate ?: res.firstAirDate + val year = releaseDate?.split("-")?.first()?.toIntOrNull() + val rating = res.vote_average.toString().toRatingInt() + val genres = res.genres?.mapNotNull { it.name } + + val isCartoon = genres?.contains("Animation") ?: false + val isAnime = isCartoon && (res.original_language == "zh" || res.original_language == "ja") + val isAsian = !isAnime && (res.original_language == "zh" || res.original_language == "ko") + val isBollywood = res.production_countries?.any { it.name == "India" } ?: false + + val keywords = res.keywords?.results?.mapNotNull { it.name }.orEmpty() + .ifEmpty { res.keywords?.keywords?.mapNotNull { it.name } } + + val actors = res.credits?.cast?.mapNotNull { cast -> + ActorData( + Actor( + cast.name ?: cast.originalName + ?: return@mapNotNull null, getImageUrl(cast.profilePath) + ), roleString = cast.character + ) + } ?: return null + val recommendations = + res.recommendations?.results?.mapNotNull { media -> media.toSearchResponse() } + + val trailer = res.videos?.results?.map { "https://www.youtube.com/watch?v=${it.key}" } + + return if (type == TvType.TvSeries) { + val lastSeason = res.last_episode_to_air?.season_number + val episodes = res.seasons?.mapNotNull { season -> + app.get("$tmdbAPI/${data.type}/${data.id}/season/${season.seasonNumber}?api_key=$apiKey") + .parsedSafe()?.episodes?.map { eps -> + Episode( + LinkData( + data.id, + res.external_ids?.imdb_id, + res.external_ids?.tvdb_id, + data.type, + eps.seasonNumber, + eps.episodeNumber, + title = title, + year = season.airDate?.split("-")?.first()?.toIntOrNull(), + orgTitle = orgTitle, + isAnime = isAnime, + airedYear = year, + lastSeason = lastSeason, + epsTitle = eps.name, + jpTitle = res.alternative_titles?.results?.find { it.iso_3166_1 == "JP" }?.title, + date = season.airDate, + airedDate = res.releaseDate + ?: res.firstAirDate, + isAsian = isAsian, + isBollywood = isBollywood, + isCartoon = isCartoon + ).toJson(), + name = eps.name + if (isUpcoming(eps.airDate)) " • [UPCOMING]" else "", + season = eps.seasonNumber, + episode = eps.episodeNumber, + posterUrl = getImageUrl(eps.stillPath), + rating = eps.voteAverage?.times(10)?.roundToInt(), + description = eps.overview + ).apply { + this.addDate(eps.airDate) + } + } + }?.flatten() ?: listOf() + newTvSeriesLoadResponse( + title, + url, + if (isAnime) TvType.Anime else TvType.TvSeries, + episodes + ) { + this.posterUrl = poster + this.backgroundPosterUrl = bgPoster + this.year = year + this.plot = res.overview + this.tags = keywords.takeIf { !it.isNullOrEmpty() } ?: genres + this.rating = rating + this.showStatus = getStatus(res.status) + this.recommendations = recommendations + this.actors = actors + this.contentRating = fetchContentRating(data.id, "US") + addTrailer(trailer) + addTMDbId(data.id.toString()) + addImdbId(res.external_ids?.imdb_id) + } + } else { + newMovieLoadResponse( + title, + url, + TvType.Movie, + LinkData( + data.id, + res.external_ids?.imdb_id, + res.external_ids?.tvdb_id, + data.type, + title = title, + year = year, + orgTitle = orgTitle, + isAnime = isAnime, + jpTitle = res.alternative_titles?.results?.find { it.iso_3166_1 == "JP" }?.title, + airedDate = res.releaseDate + ?: res.firstAirDate, + isAsian = isAsian, + isBollywood = isBollywood + ).toJson(), + ) { + this.posterUrl = poster + this.backgroundPosterUrl = bgPoster + this.comingSoon = isUpcoming(releaseDate) + this.year = year + this.plot = res.overview + this.duration = res.runtime + this.tags = keywords.takeIf { !it.isNullOrEmpty() } ?: genres + this.rating = rating + this.recommendations = recommendations + this.actors = actors + this.contentRating = fetchContentRating(data.id, "US") + addTrailer(trailer) + addTMDbId(data.id.toString()) + addImdbId(res.external_ids?.imdb_id) + } + } + } + + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + + val res = parseJson(data) + + argamap( + { + invokeDumpStream( + res.title, + res.year, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + invokeGoku( + res.title, + res.year, + res.season, + res.lastSeason, + res.episode, + subtitleCallback, + callback + ) + }, + { + invokeVidSrc(res.id, res.season, res.episode, callback) + }, + { + if (!res.isAnime) invokeAoneroom( + res.title, res.airedYear + ?: res.year, res.season, res.episode, subtitleCallback, callback + ) + }, + { + if (res.isAnime) invokeAnimes( + res.title, + res.epsTitle, + res.date, + res.airedDate, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeDreamfilm( + res.title, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, +// { +// invokeNoverse(res.title, res.season, res.episode, callback) +// }, + { + if (!res.isAnime) invokeFilmxy( + res.imdbId, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime && res.isCartoon) invokeKimcartoon( + res.title, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime && res.isCartoon) invokeWatchCartoon( + res.title, + res.year, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeVidsrcto( + res.imdbId, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (res.isAsian || res.isAnime) invokeKisskh( + res.title, + res.season, + res.episode, + res.isAnime, + res.lastSeason, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeGhostx( + res.title, + res.year, + res.season, + res.episode, + callback + ) + }, + { + if (!res.isAnime) invokeLing( + res.title, res.airedYear + ?: res.year, res.season, res.episode, subtitleCallback, callback + ) + }, + { + if (!res.isAnime) invokeUhdmovies( + res.title, + res.year, + res.season, + res.episode, + callback + ) + }, + { + if (!res.isAnime) invokeFDMovies(res.title, res.season, res.episode, callback) + }, + { + if (!res.isAnime) invokeM4uhd( + res.title, res.airedYear + ?: res.year, res.season, res.episode, subtitleCallback, callback + ) + }, + { + if (!res.isAnime) invokeTvMovies(res.title, res.season, res.episode, callback) + }, + { + if (!res.isAnime) invokeRStream(res.id, res.season, res.episode, callback) + }, + { + if (!res.isAnime) invokeFlixon( + res.id, + res.imdbId, + res.season, + res.episode, + callback + ) + }, + { + if (!res.isAnime) invokeSmashyStream( + res.id, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeWatchsomuch( + res.imdbId, + res.season, + res.episode, + subtitleCallback + ) + }, + { + if (!res.isAnime) invokeNinetv( + res.id, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + invokeDahmerMovies(res.title, res.year, res.season, res.episode, callback) + }, + { + invokeCinemaTv( + res.imdbId, res.title, res.airedYear + ?: res.year, res.season, res.episode, subtitleCallback, callback + ) + }, + { + if (!res.isAnime) invokeNowTv(res.id, res.imdbId, res.season, res.episode, callback) + }, + { + if (!res.isAnime) invokeRidomovies( + res.id, + res.imdbId, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeAllMovieland(res.imdbId, res.season, res.episode, callback) + }, + { + if (!res.isAnime) invokeEmovies( + res.title, + res.year, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeVegamovies( + res.title, + res.year, + res.season, + res.lastSeason, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime && res.isBollywood) invokeDotmovies( + res.title, + res.year, + res.season, + res.lastSeason, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (res.isBollywood) invokeMultimovies( + multimoviesAPI, + res.title, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (res.isBollywood) invokeMultimovies( + multimovies2API, + res.title, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + invokeNetmovies( + res.title, + res.year, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime && res.season == null) invokeDoomovies( + res.title, + subtitleCallback, + callback + ) + }, + { + if (res.isAsian) invokeDramaday( + res.title, + res.year, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invoke2embed( + res.imdbId, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeHdmovies4u( + res.title, + res.imdbId, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + invokeZshow( + res.title, + res.year, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeShowflix( + res.title, + res.year, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeMoflix(res.id, res.season, res.episode, callback) + }, + { + if (!res.isAnime) invokeZoechip(res.title, res.year, res.season, res.episode, callback) + }, + { + if (!res.isAnime) invokeNepu( + res.title, + res.airedYear ?: res.year, + res.season, + res.episode, + callback + ) + } + ) + + return true + } + + data class LinkData( + val id: Int? = null, + val imdbId: String? = null, + val tvdbId: Int? = null, + val type: String? = null, + val season: Int? = null, + val episode: Int? = null, + val aniId: String? = null, + val animeId: String? = null, + val title: String? = null, + val year: Int? = null, + val orgTitle: String? = null, + val isAnime: Boolean = false, + val airedYear: Int? = null, + val lastSeason: Int? = null, + val epsTitle: String? = null, + val jpTitle: String? = null, + val date: String? = null, + val airedDate: String? = null, + val isAsian: Boolean = false, + val isBollywood: Boolean = false, + val isCartoon: Boolean = false, + ) + + data class Data( + val id: Int? = null, + val type: String? = null, + val aniId: String? = null, + val malId: Int? = null, + ) + + data class Results( + @JsonProperty("results") val results: ArrayList? = arrayListOf(), + ) + + data class Media( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("original_title") val originalTitle: String? = null, + @JsonProperty("media_type") val mediaType: String? = null, + @JsonProperty("poster_path") val posterPath: String? = null, + ) + + data class Genres( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("name") val name: String? = null, + ) + + data class Keywords( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("name") val name: String? = null, + ) + + data class KeywordResults( + @JsonProperty("results") val results: ArrayList? = arrayListOf(), + @JsonProperty("keywords") val keywords: ArrayList? = arrayListOf(), + ) + + data class Seasons( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("season_number") val seasonNumber: Int? = null, + @JsonProperty("air_date") val airDate: String? = null, + ) + + data class Cast( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("original_name") val originalName: String? = null, + @JsonProperty("character") val character: String? = null, + @JsonProperty("known_for_department") val knownForDepartment: String? = null, + @JsonProperty("profile_path") val profilePath: String? = null, + ) + + data class Episodes( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("overview") val overview: String? = null, + @JsonProperty("air_date") val airDate: String? = null, + @JsonProperty("still_path") val stillPath: String? = null, + @JsonProperty("vote_average") val voteAverage: Double? = null, + @JsonProperty("episode_number") val episodeNumber: Int? = null, + @JsonProperty("season_number") val seasonNumber: Int? = null, + ) + + data class MediaDetailEpisodes( + @JsonProperty("episodes") val episodes: ArrayList? = arrayListOf(), + ) + + data class Trailers( + @JsonProperty("key") val key: String? = null, + ) + + data class ResultsTrailer( + @JsonProperty("results") val results: ArrayList? = arrayListOf(), + ) + + data class AltTitles( + @JsonProperty("iso_3166_1") val iso_3166_1: String? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("type") val type: String? = null, + ) + + data class ResultsAltTitles( + @JsonProperty("results") val results: ArrayList? = arrayListOf(), + ) + + data class ExternalIds( + @JsonProperty("imdb_id") val imdb_id: String? = null, + @JsonProperty("tvdb_id") val tvdb_id: Int? = null, + ) + + data class Credits( + @JsonProperty("cast") val cast: ArrayList? = arrayListOf(), + ) + + data class ResultsRecommendations( + @JsonProperty("results") val results: ArrayList? = arrayListOf(), + ) + + data class LastEpisodeToAir( + @JsonProperty("episode_number") val episode_number: Int? = null, + @JsonProperty("season_number") val season_number: Int? = null, + ) + + data class ProductionCountries( + @JsonProperty("name") val name: String? = null, + ) + + data class MediaDetail( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("imdb_id") val imdbId: String? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("original_title") val originalTitle: String? = null, + @JsonProperty("original_name") val originalName: String? = null, + @JsonProperty("poster_path") val posterPath: String? = null, + @JsonProperty("backdrop_path") val backdropPath: String? = null, + @JsonProperty("release_date") val releaseDate: String? = null, + @JsonProperty("first_air_date") val firstAirDate: String? = null, + @JsonProperty("overview") val overview: String? = null, + @JsonProperty("runtime") val runtime: Int? = null, + @JsonProperty("vote_average") val vote_average: Any? = null, + @JsonProperty("original_language") val original_language: String? = null, + @JsonProperty("status") val status: String? = null, + @JsonProperty("genres") val genres: ArrayList? = arrayListOf(), + @JsonProperty("keywords") val keywords: KeywordResults? = null, + @JsonProperty("last_episode_to_air") val last_episode_to_air: LastEpisodeToAir? = null, + @JsonProperty("seasons") val seasons: ArrayList? = arrayListOf(), + @JsonProperty("videos") val videos: ResultsTrailer? = null, + @JsonProperty("external_ids") val external_ids: ExternalIds? = null, + @JsonProperty("credits") val credits: Credits? = null, + @JsonProperty("recommendations") val recommendations: ResultsRecommendations? = null, + @JsonProperty("alternative_titles") val alternative_titles: ResultsAltTitles? = null, + @JsonProperty("production_countries") val production_countries: ArrayList? = arrayListOf(), + ) + +} diff --git a/SoraStream/src/main/kotlin/com/hexated/SoraStreamLite.kt b/SoraStream/src/main/kotlin/com/hexated/SoraStreamLite.kt new file mode 100644 index 0000000..e261fc2 --- /dev/null +++ b/SoraStream/src/main/kotlin/com/hexated/SoraStreamLite.kt @@ -0,0 +1,347 @@ +package com.hexated + +import com.hexated.SoraExtractor.invoke2embed +import com.hexated.SoraExtractor.invokeAllMovieland +import com.hexated.SoraExtractor.invokeAnimes +import com.hexated.SoraExtractor.invokeAoneroom +import com.hexated.SoraExtractor.invokeDoomovies +import com.hexated.SoraExtractor.invokeDramaday +import com.hexated.SoraExtractor.invokeDreamfilm +import com.hexated.SoraExtractor.invokeFilmxy +import com.hexated.SoraExtractor.invokeFlixon +import com.hexated.SoraExtractor.invokeGoku +import com.hexated.SoraExtractor.invokeKimcartoon +import com.hexated.SoraExtractor.invokeKisskh +import com.hexated.SoraExtractor.invokeLing +import com.hexated.SoraExtractor.invokeM4uhd +import com.hexated.SoraExtractor.invokeNinetv +import com.hexated.SoraExtractor.invokeNowTv +import com.hexated.SoraExtractor.invokeRStream +import com.hexated.SoraExtractor.invokeRidomovies +import com.hexated.SoraExtractor.invokeSmashyStream +import com.hexated.SoraExtractor.invokeDumpStream +import com.hexated.SoraExtractor.invokeEmovies +import com.hexated.SoraExtractor.invokeMultimovies +import com.hexated.SoraExtractor.invokeNetmovies +import com.hexated.SoraExtractor.invokeShowflix +import com.hexated.SoraExtractor.invokeVidSrc +import com.hexated.SoraExtractor.invokeVidsrcto +import com.hexated.SoraExtractor.invokeCinemaTv +import com.hexated.SoraExtractor.invokeMoflix +import com.hexated.SoraExtractor.invokeGhostx +import com.hexated.SoraExtractor.invokeNepu +import com.hexated.SoraExtractor.invokeWatchCartoon +import com.hexated.SoraExtractor.invokeWatchsomuch +import com.hexated.SoraExtractor.invokeZoechip +import com.hexated.SoraExtractor.invokeZshow +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.argamap +import com.lagradost.cloudstream3.utils.AppUtils +import com.lagradost.cloudstream3.utils.ExtractorLink + +class SoraStreamLite : SoraStream() { + override var name = "SoraStream-Lite" + + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + + val res = AppUtils.parseJson(data) + + argamap( + { + if (!res.isAnime) invokeMoflix(res.id, res.season, res.episode, callback) + }, + { + if (!res.isAnime) invokeWatchsomuch( + res.imdbId, + res.season, + res.episode, + subtitleCallback + ) + }, + { + invokeDumpStream( + res.title, + res.year, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeNinetv( + res.id, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + invokeGoku( + res.title, + res.year, + res.season, + res.lastSeason, + res.episode, + subtitleCallback, + callback + ) + }, + { + invokeVidSrc(res.id, res.season, res.episode, callback) + }, + { + if (!res.isAnime && res.isCartoon) invokeWatchCartoon( + res.title, + res.year, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (res.isAnime) invokeAnimes( + res.title, + res.epsTitle, + res.date, + res.airedDate, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeDreamfilm( + res.title, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeFilmxy( + res.imdbId, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeGhostx( + res.title, + res.year, + res.season, + res.episode, + callback + ) + }, + { + if (!res.isAnime && res.isCartoon) invokeKimcartoon( + res.title, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeSmashyStream( + res.id, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeVidsrcto( + res.imdbId, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (res.isAsian || res.isAnime) invokeKisskh( + res.title, + res.season, + res.episode, + res.isAnime, + res.lastSeason, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeLing( + res.title, res.airedYear + ?: res.year, res.season, res.episode, subtitleCallback, callback + ) + }, + { + if (!res.isAnime) invokeM4uhd( + res.title, res.airedYear + ?: res.year, res.season, res.episode, subtitleCallback, callback + ) + }, + { + if (!res.isAnime) invokeRStream(res.id, res.season, res.episode, callback) + }, + { + if (!res.isAnime) invokeFlixon( + res.id, + res.imdbId, + res.season, + res.episode, + callback + ) + }, + { + invokeCinemaTv( + res.imdbId, res.title, res.airedYear + ?: res.year, res.season, res.episode, subtitleCallback, callback + ) + }, + { + if (!res.isAnime) invokeNowTv(res.id, res.imdbId, res.season, res.episode, callback) + }, + { + if (!res.isAnime) invokeAoneroom( + res.title, res.airedYear + ?: res.year, res.season, res.episode, subtitleCallback, callback + ) + }, + { + if (!res.isAnime) invokeRidomovies( + res.id, + res.imdbId, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeEmovies( + res.title, + res.year, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (res.isBollywood) invokeMultimovies( + multimoviesAPI, + res.title, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (res.isBollywood) invokeMultimovies( + multimovies2API, + res.title, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + invokeNetmovies( + res.title, + res.year, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeAllMovieland(res.imdbId, res.season, res.episode, callback) + }, + { + if (!res.isAnime && res.season == null) invokeDoomovies( + res.title, + subtitleCallback, + callback + ) + }, + { + if (res.isAsian) invokeDramaday( + res.title, + res.year, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invoke2embed( + res.imdbId, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + invokeZshow( + res.title, + res.year, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeShowflix( + res.title, + res.year, + res.season, + res.episode, + subtitleCallback, + callback + ) + }, + { + if (!res.isAnime) invokeZoechip( + res.title, + res.year, + res.season, + res.episode, + callback + ) + }, + { + if (!res.isAnime) invokeNepu( + res.title, + res.airedYear ?: res.year, + res.season, + res.episode, + callback + ) + } + ) + + return true + } + +} \ No newline at end of file diff --git a/SoraStream/src/main/kotlin/com/hexated/SoraStreamPlugin.kt b/SoraStream/src/main/kotlin/com/hexated/SoraStreamPlugin.kt new file mode 100644 index 0000000..f0f54b5 --- /dev/null +++ b/SoraStream/src/main/kotlin/com/hexated/SoraStreamPlugin.kt @@ -0,0 +1,40 @@ + +package com.hexated + +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin +import com.lagradost.cloudstream3.plugins.Plugin +import android.content.Context + +@CloudstreamPlugin +class SoraStreamPlugin: Plugin() { + override fun load(context: Context) { + // All providers should be added in this manner. Please don't edit the providers list directly. + registerMainAPI(SoraStream()) + registerMainAPI(SoraStreamLite()) + registerExtractorAPI(Animefever()) + registerExtractorAPI(Multimovies()) + registerExtractorAPI(MultimoviesSB()) + registerExtractorAPI(Yipsu()) + registerExtractorAPI(Mwish()) + registerExtractorAPI(TravelR()) + registerExtractorAPI(Playm4u()) + registerExtractorAPI(VCloud()) + registerExtractorAPI(Pixeldra()) + registerExtractorAPI(M4ufree()) + registerExtractorAPI(Streamruby()) + registerExtractorAPI(Streamwish()) + registerExtractorAPI(FilelionsTo()) + registerExtractorAPI(Embedwish()) + registerExtractorAPI(UqloadsXyz()) + registerExtractorAPI(Uploadever()) + registerExtractorAPI(Netembed()) + registerExtractorAPI(Flaswish()) + registerExtractorAPI(Comedyshow()) + registerExtractorAPI(Ridoo()) + registerExtractorAPI(Streamvid()) + registerExtractorAPI(Embedrise()) + registerExtractorAPI(Gdmirrorbot()) + registerExtractorAPI(FilemoonNl()) + registerExtractorAPI(Alions()) + } +} \ No newline at end of file diff --git a/SoraStream/src/main/kotlin/com/hexated/SoraUtils.kt b/SoraStream/src/main/kotlin/com/hexated/SoraUtils.kt new file mode 100644 index 0000000..f6b3f13 --- /dev/null +++ b/SoraStream/src/main/kotlin/com/hexated/SoraUtils.kt @@ -0,0 +1,1569 @@ +package com.hexated + +import android.util.Base64 +import com.hexated.DumpUtils.queryApi +import com.hexated.SoraStream.Companion.anilistAPI +import com.hexated.SoraStream.Companion.crunchyrollAPI +import com.hexated.SoraStream.Companion.filmxyAPI +import com.hexated.SoraStream.Companion.gdbot +import com.hexated.SoraStream.Companion.hdmovies4uAPI +import com.hexated.SoraStream.Companion.malsyncAPI +import com.hexated.SoraStream.Companion.tvMoviesAPI +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.APIHolder.getCaptchaToken +import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.nicehttp.NiceResponse +import com.lagradost.nicehttp.RequestBodyTypes +import com.lagradost.nicehttp.requestCreator +import kotlinx.coroutines.delay +import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import org.jsoup.nodes.Document +import java.math.BigInteger +import java.net.* +import java.nio.charset.StandardCharsets +import java.security.* +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import java.text.SimpleDateFormat +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.collections.ArrayList +import kotlin.math.min + +var filmxyCookies: Map? = null +var sfServer: String? = null + +val encodedIndex = arrayOf( + "GamMovies", + "JSMovies", + "BlackMovies", + "CodexMovies", + "RinzryMovies", + "EdithxMovies", + "XtremeMovies", + "PapaonMovies[1]", + "PapaonMovies[2]", + "JmdkhMovies", + "RubyMovies", + "ShinobiMovies", + "VitoenMovies", +) + +val lockedIndex = arrayOf( + "CodexMovies", + "EdithxMovies", +) + +val mkvIndex = arrayOf( + "EdithxMovies", + "JmdkhMovies", +) + +val untrimmedIndex = arrayOf( + "PapaonMovies[1]", + "PapaonMovies[2]", + "EdithxMovies", +) + +val needRefererIndex = arrayOf( + "ShinobiMovies", +) + +val ddomainIndex = arrayOf( + "RinzryMovies", + "ShinobiMovies" +) + +val mimeType = arrayOf( + "video/x-matroska", + "video/mp4", + "video/x-msvideo" +) + +fun Document.getMirrorLink(): String? { + return this.select("div.mb-4 a").randomOrNull() + ?.attr("href") +} + +fun Document.getMirrorServer(server: Int): String { + return this.select("div.text-center a:contains(Server $server)").attr("href") +} + +suspend fun extractMirrorUHD(url: String, ref: String): String? { + var baseDoc = app.get(fixUrl(url, ref)).document + var downLink = baseDoc.getMirrorLink() + run lit@{ + (1..2).forEach { + if (downLink != null) return@lit + val server = baseDoc.getMirrorServer(it.plus(1)) + baseDoc = app.get(fixUrl(server, ref)).document + downLink = baseDoc.getMirrorLink() + } + } + return if (downLink?.contains("workers.dev") == true) downLink else base64Decode( + downLink?.substringAfter( + "download?url=" + ) ?: return null + ) +} + +suspend fun extractInstantUHD(url: String): String? { + val host = getBaseUrl(url) + val body = FormBody.Builder() + .addEncoded("keys", url.substringAfter("url=")) + .build() + return app.post( + "$host/api", requestBody = body, headers = mapOf( + "x-token" to URI(url).host + ), referer = "$host/" + ).parsedSafe>()?.get("url") +} + +suspend fun extractDirectUHD(url: String, niceResponse: NiceResponse): String? { + val document = niceResponse.document + val script = document.selectFirst("script:containsData(cf_token)")?.data() ?: return null + val actionToken = script.substringAfter("\"key\", \"").substringBefore("\");") + val cfToken = script.substringAfter("cf_token = \"").substringBefore("\";") + val body = FormBody.Builder() + .addEncoded("action", "direct") + .addEncoded("key", actionToken) + .addEncoded("action_token", cfToken) + .build() + val cookies = mapOf("PHPSESSID" to "${niceResponse.cookies["PHPSESSID"]}") + val direct = app.post( + url, + requestBody = body, + cookies = cookies, + referer = url, + headers = mapOf( + "x-token" to "driveleech.org" + ) + ).parsedSafe>()?.get("url") + + return app.get( + direct ?: return null, cookies = cookies, + referer = url + ).text.substringAfter("worker_url = '").substringBefore("';") + +} + +suspend fun extractBackupUHD(url: String): String? { + val resumeDoc = app.get(url) + + val script = resumeDoc.document.selectFirst("script:containsData(FormData.)")?.data() + + val ssid = resumeDoc.cookies["PHPSESSID"] + val baseIframe = getBaseUrl(url) + val fetchLink = + script?.substringAfter("fetch('")?.substringBefore("',")?.let { fixUrl(it, baseIframe) } + val token = script?.substringAfter("'token', '")?.substringBefore("');") + + val body = FormBody.Builder() + .addEncoded("token", "$token") + .build() + val cookies = mapOf("PHPSESSID" to "$ssid") + + val result = app.post( + fetchLink ?: return null, + requestBody = body, + headers = mapOf( + "Accept" to "*/*", + "Origin" to baseIframe, + "Sec-Fetch-Site" to "same-origin" + ), + cookies = cookies, + referer = url + ).text + return tryParseJson(result)?.url +} + +suspend fun extractGdbot(url: String): String? { + val headers = mapOf( + "Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + ) + val res = app.get( + "$gdbot/", headers = headers + ) + val token = res.document.selectFirst("input[name=_token]")?.attr("value") + val cookiesSet = res.headers.filter { it.first == "set-cookie" } + val xsrf = + cookiesSet.find { it.second.contains("XSRF-TOKEN") }?.second?.substringAfter("XSRF-TOKEN=") + ?.substringBefore(";") + val session = + cookiesSet.find { it.second.contains("gdtot_proxy_session") }?.second?.substringAfter("gdtot_proxy_session=") + ?.substringBefore(";") + + val cookies = mapOf( + "gdtot_proxy_session" to "$session", + "XSRF-TOKEN" to "$xsrf" + ) + val requestFile = app.post( + "$gdbot/file", data = mapOf( + "link" to url, + "_token" to "$token" + ), headers = headers, referer = "$gdbot/", cookies = cookies + ).document + + return requestFile.selectFirst("div.mt-8 a.float-right")?.attr("href") +} + +suspend fun extractDirectDl(url: String): String? { + val iframe = app.get(url).document.selectFirst("li.flex.flex-col.py-6 a:contains(Direct DL)") + ?.attr("href") + val request = app.get(iframe ?: return null) + val driveDoc = request.document + val token = driveDoc.select("section#generate_url").attr("data-token") + val uid = driveDoc.select("section#generate_url").attr("data-uid") + + val ssid = request.cookies["PHPSESSID"] + val body = + """{"type":"DOWNLOAD_GENERATE","payload":{"uid":"$uid","access_token":"$token"}}""".toRequestBody( + RequestBodyTypes.JSON.toMediaTypeOrNull() + ) + + val json = app.post( + "https://rajbetmovies.com/action", requestBody = body, headers = mapOf( + "Accept" to "application/json, text/plain, */*", + "Cookie" to "PHPSESSID=$ssid", + "X-Requested-With" to "xmlhttprequest" + ), referer = request.url + ).text + return tryParseJson(json)?.download_url +} + +suspend fun extractDrivebot(url: String): String? { + val iframeDrivebot = + app.get(url).document.selectFirst("li.flex.flex-col.py-6 a:contains(Drivebot)") + ?.attr("href") ?: return null + return getDrivebotLink(iframeDrivebot) +} + +suspend fun extractGdflix(url: String): String? { + val iframeGdflix = + if (!url.contains("gdflix")) app.get(url).document.selectFirst("li.flex.flex-col.py-6 a:contains(GDFlix Direct)") + ?.attr("href") ?: return null else url + val base = getBaseUrl(iframeGdflix) + + val req = app.get(iframeGdflix).document.selectFirst("script:containsData(replace)")?.data() + ?.substringAfter("replace(\"") + ?.substringBefore("\")")?.let { + app.get(fixUrl(it, base)) + } ?: return null + + val iframeDrivebot2 = req.document.selectFirst("a.btn.btn-outline-warning")?.attr("href") + return getDrivebotLink(iframeDrivebot2) + +// val reqUrl = req.url +// val ssid = req.cookies["PHPSESSID"] +// val script = req.document.selectFirst("script:containsData(formData =)")?.data() +// val key = Regex("append\\(\"key\", \"(\\S+?)\"\\);").find(script ?: return null)?.groupValues?.get(1) +// +// val body = FormBody.Builder() +// .addEncoded("action", "direct") +// .addEncoded("key", "$key") +// .addEncoded("action_token", "cf_token") +// .build() +// +// val gdriveUrl = app.post( +// reqUrl, requestBody = body, +// cookies = mapOf("PHPSESSID" to "$ssid"), +// headers = mapOf( +// "x-token" to URI(reqUrl).host +// ) +// ).parsedSafe()?.url +// +// return getDirectGdrive(gdriveUrl ?: return null) + +} + +suspend fun getDrivebotLink(url: String?): String? { + val driveDoc = app.get(url ?: return null) + + val ssid = driveDoc.cookies["PHPSESSID"] + val script = driveDoc.document.selectFirst("script:containsData(var formData)")?.data() + + val baseUrl = getBaseUrl(url) + val token = script?.substringAfter("'token', '")?.substringBefore("');") + val link = + script?.substringAfter("fetch('")?.substringBefore("',").let { "$baseUrl$it" } + + val body = FormBody.Builder() + .addEncoded("token", "$token") + .build() + val cookies = mapOf("PHPSESSID" to "$ssid") + + val file = app.post( + link, + requestBody = body, + headers = mapOf( + "Accept" to "*/*", + "Origin" to baseUrl, + "Sec-Fetch-Site" to "same-origin" + ), + cookies = cookies, + referer = url + ).parsedSafe()?.url ?: return null + + return if (file.startsWith("http")) file else app.get( + fixUrl( + file, + baseUrl + ) + ).document.selectFirst("script:containsData(window.open)") + ?.data()?.substringAfter("window.open('")?.substringBefore("')") +} + +suspend fun extractOiya(url: String): String? { + return app.get(url).document.selectFirst("div.wp-block-button a")?.attr("href") +} + +fun deobfstr(hash: String, index: String): String { + var result = "" + for (i in hash.indices step 2) { + val j = hash.substring(i, i + 2) + result += (j.toInt(16) xor index[(i / 2) % index.length].code).toChar() + } + return result +} + +suspend fun extractCovyn(url: String?): Pair? { + val request = session.get(url ?: return null, referer = "${tvMoviesAPI}/") + val filehosting = session.baseClient.cookieJar.loadForRequest(url.toHttpUrl()) + .find { it.name == "filehosting" }?.value + val headers = mapOf( + "Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Connection" to "keep-alive", + "Cookie" to "filehosting=$filehosting", + ) + + val iframe = request.document.findTvMoviesIframe() + delay(10500) + val request2 = session.get( + iframe ?: return null, referer = url, headers = headers + ) + + val iframe2 = request2.document.findTvMoviesIframe() + delay(10500) + val request3 = session.get( + iframe2 ?: return null, referer = iframe, headers = headers + ) + + val response = request3.document + val videoLink = response.selectFirst("button.btn.btn--primary")?.attr("onclick") + ?.substringAfter("location = '")?.substringBefore("';")?.let { + app.get( + it, referer = iframe2, headers = headers + ).url + } + val size = response.selectFirst("ul.row--list li:contains(Filesize) span:last-child") + ?.text() + + return Pair(videoLink, size) +} + +suspend fun getDirectGdrive(url: String): String { + val fixUrl = if (url.contains("&export=download")) { + url + } else { + "https://drive.google.com/uc?id=${ + Regex("(?:\\?id=|/d/)(\\S+)/").find("$url/")?.groupValues?.get(1) + }&export=download" + } + + val doc = app.get(fixUrl).document + val form = doc.select("form#download-form").attr("action") + val uc = doc.select("input#uc-download-link").attr("value") + return app.post( + form, data = mapOf( + "uc-download-link" to uc + ) + ).url + +} + +suspend fun invokeSmashyFfix( + name: String, + url: String, + ref: String, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, +) { + val json = app.get(url, referer = ref, headers = mapOf("X-Requested-With" to "XMLHttpRequest")) + .parsedSafe() + json?.sourceUrls?.map { + M3u8Helper.generateM3u8( + "Smashy [$name]", + it, + "" + ).forEach(callback) + } + + json?.subtitleUrls?.split(",")?.map { sub -> + val lang = "\\[(.*)]".toRegex().find(sub)?.groupValues?.get(1) + val subUrl = sub.replace("[$lang]", "").trim() + subtitleCallback.invoke( + SubtitleFile( + lang ?: return@map, + subUrl + ) + ) + } + +} + +suspend fun invokeSmashySu( + name: String, + url: String, + ref: String, + callback: (ExtractorLink) -> Unit, +) { + val json = app.get(url, referer = ref, headers = mapOf("X-Requested-With" to "XMLHttpRequest")) + .parsedSafe() + json?.sourceUrls?.firstOrNull()?.removeSuffix(",")?.split(",")?.forEach { links -> + val quality = Regex("\\[(\\S+)]").find(links)?.groupValues?.getOrNull(1) ?: return@forEach + val trimmedLink = links.removePrefix("[$quality]").trim() + callback.invoke( + ExtractorLink( + "Smashy [$name]", + "Smashy [$name]", + trimmedLink, + "", + getQualityFromName(quality), + INFER_TYPE + ) + ) + } +} + +suspend fun getDumpIdAndType(title: String?, year: Int?, season: Int?): Pair { + val res = tryParseJson( + queryApi( + "POST", + "${BuildConfig.DUMP_API}/search/searchWithKeyWord", + mapOf( + "searchKeyWord" to "$title", + "size" to "50", + ) + ) + )?.searchResults + + val media = if (res?.size == 1) { + res.firstOrNull() + } else { + res?.find { + when (season) { + null -> { + it.name.equals( + title, + true + ) && it.releaseTime == "$year" && it.domainType == 0 + } + + 1 -> { + it.name?.contains( + "$title", + true + ) == true && (it.releaseTime == "$year" || it.name.contains( + "Season $season", + true + )) && it.domainType == 1 + } + + else -> { + it.name?.contains(Regex("(?i)$title\\s?($season|${season.toRomanNumeral()}|Season\\s$season)")) == true && it.releaseTime == "$year" && it.domainType == 1 + } + } + } + } + + return media?.id to media?.domainType + +} + +suspend fun fetchDumpEpisodes(id: String, type: String, episode: Int?): EpisodeVo? { + return tryParseJson( + queryApi( + "GET", + "${BuildConfig.DUMP_API}/movieDrama/get", + mapOf( + "category" to type, + "id" to id, + ) + ) + )?.episodeVo?.find { + it.seriesNo == (episode ?: 0) + } +} + +suspend fun invokeDrivetot( + url: String, + tags: String? = null, + size: String? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, +) { + val res = app.get(url) + val data = res.document.select("form input").associate { it.attr("name") to it.attr("value") } + app.post(res.url, data = data, cookies = res.cookies).document.select("div.card-body a") + .apmap { ele -> + val href = base64Decode(ele.attr("href").substringAfterLast("/")).let { + if (it.contains("hubcloud.lol")) it.replace("hubcloud.lol", "hubcloud.in") else it + } + loadExtractor(href, "$hdmovies4uAPI/", subtitleCallback) { link -> + callback.invoke( + ExtractorLink( + link.source, + "${link.name} $tags [$size]", + link.url, + link.referer, + link.quality, + link.type, + link.headers, + link.extractorData + ) + ) + } + } +} + +suspend fun bypassBqrecipes(url: String): String? { + var res = app.get(url) + var location = res.text.substringAfter(".replace('").substringBefore("');") + var cookies = res.cookies + res = app.get(location, cookies = cookies) + cookies = cookies + res.cookies + val document = res.document + location = document.select("form#recaptcha").attr("action") + val data = + document.select("form#recaptcha input").associate { it.attr("name") to it.attr("value") } + res = app.post(location, data = data, cookies = cookies) + location = res.document.selectFirst("a#messagedown")?.attr("href") ?: return null + cookies = (cookies + res.cookies).minus("var") + return app.get(location, cookies = cookies, allowRedirects = false).headers["location"] +} + +suspend fun bypassOuo(url: String?): String? { + var res = session.get(url ?: return null) + run lit@{ + (1..2).forEach { _ -> + if (res.headers["location"] != null) return@lit + val document = res.document + val nextUrl = document.select("form").attr("action") + val data = document.select("form input").mapNotNull { + it.attr("name") to it.attr("value") + }.toMap().toMutableMap() + val captchaKey = + document.select("script[src*=https://www.google.com/recaptcha/api.js?render=]") + .attr("src").substringAfter("render=") + val token = getCaptchaToken(url, captchaKey) + data["x-token"] = token ?: "" + res = session.post( + nextUrl, + data = data, + headers = mapOf("content-type" to "application/x-www-form-urlencoded"), + allowRedirects = false + ) + } + } + + return res.headers["location"] +} + +suspend fun bypassFdAds(url: String?): String? { + val directUrl = + app.get(url ?: return null, verify = false).document.select("a#link").attr("href") + .substringAfter("/go/") + .let { base64Decode(it) } + val doc = app.get(directUrl, verify = false).document + val lastDoc = app.post( + doc.select("form#landing").attr("action"), + data = mapOf("go" to doc.select("form#landing input").attr("value")), + verify = false + ).document + val json = lastDoc.select("form#landing input[name=newwpsafelink]").attr("value") + .let { base64Decode(it) } + val finalJson = + tryParseJson(json)?.linkr?.substringAfter("redirect=")?.let { base64Decode(it) } + return tryParseJson(finalJson)?.safelink +} + +suspend fun bypassHrefli(url: String): String? { + fun Document.getFormUrl(): String { + return this.select("form#landing").attr("action") + } + + fun Document.getFormData(): Map { + return this.select("form#landing input").associate { it.attr("name") to it.attr("value") } + } + + val host = getBaseUrl(url) + var res = app.get(url).document + var formUrl = res.getFormUrl() + var formData = res.getFormData() + + res = app.post(formUrl, data = formData).document + formUrl = res.getFormUrl() + formData = res.getFormData() + + res = app.post(formUrl, data = formData).document + val skToken = res.selectFirst("script:containsData(?go=)")?.data()?.substringAfter("?go=") + ?.substringBefore("\"") ?: return null + val driveUrl = app.get( + "$host?go=$skToken", cookies = mapOf( + skToken to "${formData["_wp_http2"]}" + ) + ).document.selectFirst("meta[http-equiv=refresh]")?.attr("content")?.substringAfter("url=") + val path = app.get(driveUrl ?: return null).text.substringAfter("replace(\"") + .substringBefore("\")") + if (path == "/404") return null + return fixUrl(path, getBaseUrl(driveUrl)) +} + +suspend fun getTvMoviesServer(url: String, season: Int?, episode: Int?): Pair? { + + val req = app.get(url) + if (!req.isSuccessful) return null + val doc = req.document + + return if (season == null) { + doc.select("table.wp-block-table tr:last-child td:first-child").text() to + doc.selectFirst("table.wp-block-table tr a")?.attr("href").let { link -> + app.get(link ?: return null).document.select("div#text-url a") + .mapIndexed { index, element -> + element.attr("href") to element.parent()?.textNodes()?.getOrNull(index) + ?.text() + }.filter { it.second?.contains("Subtitles", true) == false } + .map { it.first } + }.lastOrNull() + } else { + doc.select("div.vc_tta-panels div#Season-$season table.wp-block-table tr:last-child td:first-child") + .text() to + doc.select("div.vc_tta-panels div#Season-$season table.wp-block-table tr a") + .mapNotNull { ele -> + app.get(ele.attr("href")).document.select("div#text-url a") + .mapIndexed { index, element -> + element.attr("href") to element.parent()?.textNodes() + ?.getOrNull(index)?.text() + }.find { it.second?.contains("Episode $episode", true) == true }?.first + }.lastOrNull() + } +} + +suspend fun getSfServer() = sfServer ?: fetchSfServer().also { sfServer = it } + +suspend fun fetchSfServer(): String { + return app.get("https://raw.githubusercontent.com/hexated/cloudstream-resources/main/sfmovies_server").text +} + +suspend fun getFilmxyCookies(url: String) = + filmxyCookies ?: fetchFilmxyCookies(url).also { filmxyCookies = it } + +suspend fun fetchFilmxyCookies(url: String): Map { + + val defaultCookies = + mutableMapOf("G_ENABLED_IDPS" to "google", "true_checker" to "1", "XID" to "1") + session.get( + url, + headers = mapOf( + "Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" + ), + cookies = defaultCookies, + ) + val phpsessid = session.baseClient.cookieJar.loadForRequest(url.toHttpUrl()) + .first { it.name == "PHPSESSID" }.value + defaultCookies["PHPSESSID"] = phpsessid + + val userNonce = + app.get( + "$filmxyAPI/login/?redirect_to=$filmxyAPI/", + cookies = defaultCookies + ).document.select("script") + .find { it.data().contains("var userNonce") }?.data()?.let { + Regex("var\\suserNonce.*?\"(\\S+?)\";").find(it)?.groupValues?.get(1) + } + + val cookieUrl = "${filmxyAPI}/wp-admin/admin-ajax.php" + + session.post( + cookieUrl, + data = mapOf( + "action" to "guest_login", + "nonce" to "$userNonce", + ), + headers = mapOf( + "X-Requested-With" to "XMLHttpRequest", + ), + cookies = defaultCookies + ) + val cookieJar = session.baseClient.cookieJar.loadForRequest(cookieUrl.toHttpUrl()) + .associate { it.name to it.value }.toMutableMap() + + return cookieJar.plus(defaultCookies) +} + +fun Document.findTvMoviesIframe(): String? { + return this.selectFirst("script:containsData(var seconds)")?.data()?.substringAfter("href='") + ?.substringBefore("'>") +} + +//modified code from https://github.com/jmir1/aniyomi-extensions/blob/master/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/AccessTokenInterceptor.kt +suspend fun getCrunchyrollToken(): CrunchyrollAccessToken { + val client = app.baseClient.newBuilder() + .proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("cr-unblocker.us.to", 1080))) + .build() + + Authenticator.setDefault(object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication { + return PasswordAuthentication("crunblocker", "crunblocker".toCharArray()) + } + }) + + val request = requestCreator( + method = "POST", + url = "$crunchyrollAPI/auth/v1/token", + headers = mapOf( + "User-Agent" to "Crunchyroll/3.26.1 Android/11 okhttp/4.9.2", + "Content-Type" to "application/x-www-form-urlencoded", + "Authorization" to "Basic ${BuildConfig.CRUNCHYROLL_BASIC_TOKEN}" + ), + data = mapOf( + "refresh_token" to app.get(BuildConfig.CRUNCHYROLL_REFRESH_TOKEN).text, + "grant_type" to "refresh_token", + "scope" to "offline_access" + ) + ) + + val token = tryParseJson(client.newCall(request).execute().body.string()) + val headers = mapOf("Authorization" to "${token?.tokenType} ${token?.accessToken}") + val cms = + app.get("$crunchyrollAPI/index/v2", headers = headers).parsedSafe()?.cms + return CrunchyrollAccessToken( + token?.accessToken, + token?.tokenType, + cms?.bucket, + cms?.policy, + cms?.signature, + cms?.key_pair_id, + ) +} + +suspend fun getCrunchyrollId(aniId: String?): String? { + val query = """ + query media(${'$'}id: Int, ${'$'}type: MediaType, ${'$'}isAdult: Boolean) { + Media(id: ${'$'}id, type: ${'$'}type, isAdult: ${'$'}isAdult) { + id + externalLinks { + id + site + url + type + } + } + } + """.trimIndent().trim() + + val variables = mapOf( + "id" to aniId, + "isAdult" to false, + "type" to "ANIME", + ) + + val data = mapOf( + "query" to query, + "variables" to variables + ).toJson().toRequestBody(RequestBodyTypes.JSON.toMediaTypeOrNull()) + + val externalLinks = app.post(anilistAPI, requestBody = data) + .parsedSafe()?.data?.Media?.externalLinks + + return (externalLinks?.find { it.site == "VRV" } + ?: externalLinks?.find { it.site == "Crunchyroll" })?.url?.let { + app.get(it).url.substringAfter("/series/").substringBefore("/") + } +} + +suspend fun getCrunchyrollIdFromMalSync(aniId: String?): String? { + val res = app.get("$malsyncAPI/mal/anime/$aniId").parsedSafe()?.Sites + val vrv = res?.get("Vrv")?.map { it.value }?.firstOrNull()?.get("url") + val crunchyroll = res?.get("Vrv")?.map { it.value }?.firstOrNull()?.get("url") + val regex = Regex("series/(\\w+)/?") + return regex.find("$vrv")?.groupValues?.getOrNull(1) + ?: regex.find("$crunchyroll")?.groupValues?.getOrNull(1) +} + +suspend fun String.haveDub(referer: String) : Boolean { + return app.get(this,referer=referer).text.contains("TYPE=AUDIO") +} + +suspend fun convertTmdbToAnimeId( + title: String?, + date: String?, + airedDate: String?, + type: TvType +): AniIds { + val sDate = date?.split("-") + val sAiredDate = airedDate?.split("-") + + val year = sDate?.firstOrNull()?.toIntOrNull() + val airedYear = sAiredDate?.firstOrNull()?.toIntOrNull() + val season = getSeason(sDate?.get(1)?.toIntOrNull()) + val airedSeason = getSeason(sAiredDate?.get(1)?.toIntOrNull()) + + return if (type == TvType.AnimeMovie) { + tmdbToAnimeId(title, airedYear, "", type) + } else { + val ids = tmdbToAnimeId(title, year, season, type) + if (ids.id == null && ids.idMal == null) tmdbToAnimeId( + title, + airedYear, + airedSeason, + type + ) else ids + } +} + +suspend fun tmdbToAnimeId(title: String?, year: Int?, season: String?, type: TvType): AniIds { + val query = """ + query ( + ${'$'}page: Int = 1 + ${'$'}search: String + ${'$'}sort: [MediaSort] = [POPULARITY_DESC, SCORE_DESC] + ${'$'}type: MediaType + ${'$'}season: MediaSeason + ${'$'}seasonYear: Int + ${'$'}format: [MediaFormat] + ) { + Page(page: ${'$'}page, perPage: 20) { + media( + search: ${'$'}search + sort: ${'$'}sort + type: ${'$'}type + season: ${'$'}season + seasonYear: ${'$'}seasonYear + format_in: ${'$'}format + ) { + id + idMal + } + } + } + """.trimIndent().trim() + + val variables = mapOf( + "search" to title, + "sort" to "SEARCH_MATCH", + "type" to "ANIME", + "season" to season?.uppercase(), + "seasonYear" to year, + "format" to listOf(if (type == TvType.AnimeMovie) "MOVIE" else "TV") + ).filterValues { value -> value != null && value.toString().isNotEmpty() } + + val data = mapOf( + "query" to query, + "variables" to variables + ).toJson().toRequestBody(RequestBodyTypes.JSON.toMediaTypeOrNull()) + + val res = app.post(anilistAPI, requestBody = data) + .parsedSafe()?.data?.Page?.media?.firstOrNull() + return AniIds(res?.id, res?.idMal) + +} + +fun generateWpKey(r: String, m: String): String { + val rList = r.split("\\x").toTypedArray() + var n = "" + val decodedM = String(base64Decode(m.split("").reversed().joinToString("")).toCharArray()) + for (s in decodedM.split("|")) { + n += "\\x" + rList[Integer.parseInt(s) + 1] + } + return n +} + +suspend fun loadCustomTagExtractor( + tag: String? = null, + url: String, + referer: String? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + quality: Int? = null, +) { + loadExtractor(url, referer, subtitleCallback) { link -> + callback.invoke( + ExtractorLink( + link.source, + "${link.name} $tag", + link.url, + link.referer, + when (link.type) { + ExtractorLinkType.M3U8 -> link.quality + else -> quality ?: link.quality + }, + link.type, + link.headers, + link.extractorData + ) + ) + } +} + +suspend fun loadCustomExtractor( + name: String? = null, + url: String, + referer: String? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + quality: Int? = null, +) { + loadExtractor(url, referer, subtitleCallback) { link -> + callback.invoke( + ExtractorLink( + name ?: link.source, + name ?: link.name, + link.url, + link.referer, + when { + link.name == "VidSrc" -> Qualities.P1080.value + link.type == ExtractorLinkType.M3U8 -> link.quality + else -> quality ?: link.quality + }, + link.type, + link.headers, + link.extractorData + ) + ) + } +} + +fun getSeason(month: Int?): String? { + val seasons = arrayOf( + "Winter", "Winter", "Spring", "Spring", "Spring", "Summer", + "Summer", "Summer", "Fall", "Fall", "Fall", "Winter" + ) + if (month == null) return null + return seasons[month - 1] +} + +fun getEpisodeSlug( + season: Int? = null, + episode: Int? = null, +): Pair { + return if (season == null && episode == null) { + "" to "" + } else { + (if (season!! < 10) "0$season" else "$season") to (if (episode!! < 10) "0$episode" else "$episode") + } +} + +fun getTitleSlug(title: String? = null): Pair { + val slug = title.createSlug() + return slug?.replace("-", "\\W") to title?.replace(" ", "_") +} + +fun getIndexQuery( + title: String? = null, + year: Int? = null, + season: Int? = null, + episode: Int? = null +): String { + val (seasonSlug, episodeSlug) = getEpisodeSlug(season, episode) + return (if (season == null) { + "$title ${year ?: ""}" + } else { + "$title S${seasonSlug}E${episodeSlug}" + }).trim() +} + +fun searchIndex( + title: String? = null, + season: Int? = null, + episode: Int? = null, + year: Int? = null, + response: String, + isTrimmed: Boolean = true, +): List? { + val files = tryParseJson(response)?.data?.files?.filter { media -> + matchingIndex( + media.name ?: return null, + media.mimeType ?: return null, + title ?: return null, + year, + season, + episode + ) + }?.distinctBy { it.name }?.sortedByDescending { it.size?.toLongOrNull() ?: 0 } ?: return null + + return if (isTrimmed) { + files.let { file -> + listOfNotNull( + file.find { it.name?.contains("2160p", true) == true }, + file.find { it.name?.contains("1080p", true) == true } + ) + } + } else { + files + } +} + +fun matchingIndex( + mediaName: String?, + mediaMimeType: String?, + title: String?, + year: Int?, + season: Int?, + episode: Int?, + include720: Boolean = false +): Boolean { + val (wSlug, dwSlug) = getTitleSlug(title) + val (seasonSlug, episodeSlug) = getEpisodeSlug(season, episode) + return (if (season == null) { + mediaName?.contains(Regex("(?i)(?:$wSlug|$dwSlug).*$year")) == true + } else { + mediaName?.contains(Regex("(?i)(?:$wSlug|$dwSlug).*S${seasonSlug}.?E${episodeSlug}")) == true + }) && mediaName?.contains( + if (include720) Regex("(?i)(2160p|1080p|720p)") else Regex("(?i)(2160p|1080p)") + ) == true && ((mediaMimeType in mimeType) || mediaName.contains(Regex("\\.mkv|\\.mp4|\\.avi"))) +} + +fun decodeIndexJson(json: String): String { + val slug = json.reversed().substring(24) + return base64Decode(slug.substring(0, slug.length - 20)) +} + +fun String.xorDecrypt(key: String): String { + val sb = StringBuilder() + var i = 0 + while (i < this.length) { + var j = 0 + while (j < key.length && i < this.length) { + sb.append((this[i].code xor key[j].code).toChar()) + j++ + i++ + } + } + return sb.toString() +} + +fun vidsrctoDecrypt(text: String): String { + val parse = Base64.decode(text.toByteArray(), Base64.URL_SAFE) + val cipher = Cipher.getInstance("RC4") + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec("8z5Ag5wgagfsOuhz".toByteArray(), "RC4"), + cipher.parameters + ) + return decode(cipher.doFinal(parse).toString(Charsets.UTF_8)) +} + +fun String?.createSlug(): String? { + return this?.filter { it.isWhitespace() || it.isLetterOrDigit() } + ?.trim() + ?.replace("\\s+".toRegex(), "-") + ?.lowercase() +} + +fun getLanguage(str: String): String { + return if (str.contains("(in_ID)")) "Indonesian" else str +} + +fun bytesToGigaBytes(number: Double): Double = number / 1024000000 + +fun getKisskhTitle(str: String?): String? { + return str?.replace(Regex("[^a-zA-Z\\d]"), "-") +} + +fun String.getFileSize(): Float? { + val size = Regex("(?i)(\\d+\\.?\\d+\\sGB|MB)").find(this)?.groupValues?.get(0)?.trim() + val num = Regex("(\\d+\\.?\\d+)").find(size ?: return null)?.groupValues?.get(0)?.toFloat() + ?: return null + return when { + size.contains("GB") -> num * 1000000 + else -> num * 1000 + } +} + +fun getUhdTags(str: String?): String { + return Regex("\\d{3,4}[Pp]\\.?(.*?)\\[").find(str ?: "")?.groupValues?.getOrNull(1) + ?.replace(".", " ")?.trim() + ?: str ?: "" +} + +fun getIndexQualityTags(str: String?, fullTag: Boolean = false): String { + return if (fullTag) Regex("(?i)(.*)\\.(?:mkv|mp4|avi)").find(str ?: "")?.groupValues?.get(1) + ?.trim() ?: str ?: "" else Regex("(?i)\\d{3,4}[pP]\\.?(.*?)\\.(mkv|mp4|avi)").find( + str ?: "" + )?.groupValues?.getOrNull(1) + ?.replace(".", " ")?.trim() ?: str ?: "" +} + +fun getIndexQuality(str: String?): Int { + return Regex("(\\d{3,4})[pP]").find(str ?: "")?.groupValues?.getOrNull(1)?.toIntOrNull() + ?: Qualities.Unknown.value +} + +fun getIndexSize(str: String?): String? { + return Regex("(?i)([\\d.]+\\s*(?:gb|mb))").find(str ?: "")?.groupValues?.getOrNull(1)?.trim() +} + +fun getQuality(str: String): Int { + return when (str) { + "360p" -> Qualities.P240.value + "480p" -> Qualities.P360.value + "720p" -> Qualities.P480.value + "1080p" -> Qualities.P720.value + "1080p Ultra" -> Qualities.P1080.value + else -> getQualityFromName(str) + } +} + +fun getGMoviesQuality(str: String): Int { + return when { + str.contains("480P", true) -> Qualities.P480.value + str.contains("720P", true) -> Qualities.P720.value + str.contains("1080P", true) -> Qualities.P1080.value + str.contains("4K", true) -> Qualities.P2160.value + else -> Qualities.Unknown.value + } +} + +fun getFDoviesQuality(str: String): String { + return when { + str.contains("1080P", true) -> "1080P" + str.contains("4K", true) -> "4K" + else -> "" + } +} + +fun getVipLanguage(str: String): String { + return when (str) { + "in_ID" -> "Indonesian" + "pt" -> "Portuguese" + else -> str.split("_").first().let { + SubtitleHelper.fromTwoLettersToLanguage(it).toString() + } + } +} + +fun fixCrunchyrollLang(language: String?): String? { + return SubtitleHelper.fromTwoLettersToLanguage(language ?: return null) + ?: SubtitleHelper.fromTwoLettersToLanguage(language.substringBefore("-")) +} + +fun getDeviceId(length: Int = 16): String { + val allowedChars = ('a'..'f') + ('0'..'9') + return (1..length) + .map { allowedChars.random() } + .joinToString("") +} + +fun String.encodeUrl(): String { + val url = URL(this) + val uri = URI(url.protocol, url.userInfo, url.host, url.port, url.path, url.query, url.ref) + return uri.toURL().toString() +} + +fun getBaseUrl(url: String): String { + return URI(url).let { + "${it.scheme}://${it.host}" + } +} + +fun String.getHost(): String { + return fixTitle(URI(this).host.substringBeforeLast(".").substringAfterLast(".")) +} + +fun isUpcoming(dateString: String?): Boolean { + return try { + val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val dateTime = dateString?.let { format.parse(it)?.time } ?: return false + unixTimeMS < dateTime + } catch (t: Throwable) { + logError(t) + false + } +} + +fun getDate(): TmdbDate { + val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val calender = Calendar.getInstance() + val today = formatter.format(calender.time) + calender.add(Calendar.WEEK_OF_YEAR, 1) + val nextWeek = formatter.format(calender.time) + return TmdbDate(today, nextWeek) +} + +fun decode(input: String): String = URLDecoder.decode(input, "utf-8") + +fun encode(input: String): String = URLEncoder.encode(input, "utf-8").replace("+", "%20") + +fun base64DecodeAPI(api: String): String { + return api.chunked(4).map { base64Decode(it) }.reversed().joinToString("") +} + +fun fixUrl(url: String, domain: String): String { + if (url.startsWith("http")) { + return url + } + if (url.isEmpty()) { + return "" + } + + val startsWithNoHttp = url.startsWith("//") + if (startsWithNoHttp) { + return "https:$url" + } else { + if (url.startsWith('/')) { + return domain + url + } + return "$domain/$url" + } +} + +fun Int.toRomanNumeral(): String = Symbol.closestBelow(this) + .let { symbol -> + if (symbol != null) { + "$symbol${(this - symbol.decimalValue).toRomanNumeral()}" + } else { + "" + } + } + +private enum class Symbol(val decimalValue: Int) { + I(1), + IV(4), + V(5), + IX(9), + X(10); + + companion object { + fun closestBelow(value: Int) = + entries.toTypedArray() + .sortedByDescending { it.decimalValue } + .firstOrNull { value >= it.decimalValue } + } +} + +// steal from https://github.com/aniyomiorg/aniyomi-extensions/blob/master/src/en/aniwave/src/eu/kanade/tachiyomi/animeextension/en/nineanime/AniwaveUtils.kt +// credits to @samfundev +object AniwaveUtils { + + fun encodeVrf(input: String): String { + val rc4Key = SecretKeySpec("ysJhV6U27FVIjjuk".toByteArray(), "RC4") + val cipher = Cipher.getInstance("RC4") + cipher.init(Cipher.DECRYPT_MODE, rc4Key, cipher.parameters) + var vrf = cipher.doFinal(input.toByteArray()) + vrf = Base64.encode(vrf, Base64.URL_SAFE or Base64.NO_WRAP) + vrf = Base64.encode(vrf, Base64.DEFAULT or Base64.NO_WRAP) + vrf = vrfShift(vrf) + vrf = Base64.encode(vrf, Base64.DEFAULT) + vrf = rot13(vrf) + val stringVrf = vrf.toString(Charsets.UTF_8) + return encode(stringVrf) + } + + fun decodeVrf(input: String): String { + var vrf = input.toByteArray() + vrf = Base64.decode(vrf, Base64.URL_SAFE) + val rc4Key = SecretKeySpec("hlPeNwkncH0fq9so".toByteArray(), "RC4") + val cipher = Cipher.getInstance("RC4") + cipher.init(Cipher.DECRYPT_MODE, rc4Key, cipher.parameters) + vrf = cipher.doFinal(vrf) + return decode(vrf.toString(Charsets.UTF_8)) + } + + private fun rot13(vrf: ByteArray): ByteArray { + for (i in vrf.indices) { + val byte = vrf[i] + if (byte in 'A'.code..'Z'.code) { + vrf[i] = ((byte - 'A'.code + 13) % 26 + 'A'.code).toByte() + } else if (byte in 'a'.code..'z'.code) { + vrf[i] = ((byte - 'a'.code + 13) % 26 + 'a'.code).toByte() + } + } + return vrf + } + + private fun vrfShift(vrf: ByteArray): ByteArray { + for (i in vrf.indices) { + val shift = arrayOf(-3, 3, -4, 2, -2, 5, 4, 5)[i % 8] + vrf[i] = vrf[i].plus(shift).toByte() + } + return vrf + } +} + +object DumpUtils { + + private val deviceId = getDeviceId() + + suspend fun queryApi(method: String, url: String, params: Map): String { + return app.custom( + method, + url, + requestBody = if (method == "POST") params.toJson() + .toRequestBody(RequestBodyTypes.JSON.toMediaTypeOrNull()) else null, + params = if (method == "GET") params else emptyMap(), + headers = createHeaders(params) + ).parsedSafe>()?.get("data").let { + cryptoHandler( + it.toString(), + deviceId, + false + ) + } + } + + private fun createHeaders( + params: Map, + currentTime: String = System.currentTimeMillis().toString(), + ): Map { + return mapOf( + "lang" to "en", + "currentTime" to currentTime, + "sign" to getSign(currentTime, params).toString(), + "aesKey" to getAesKey().toString(), + ) + } + + private fun cryptoHandler( + string: String, + secretKeyString: String, + encrypt: Boolean = true + ): String { + val secretKey = SecretKeySpec(secretKeyString.toByteArray(), "AES") + val cipher = Cipher.getInstance("AES/ECB/PKCS5PADDING") + return if (!encrypt) { + cipher.init(Cipher.DECRYPT_MODE, secretKey) + String(cipher.doFinal(base64DecodeArray(string))) + } else { + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + base64Encode(cipher.doFinal(string.toByteArray())) + } + } + + private fun getAesKey(): String? { + val publicKey = + RSAEncryptionHelper.getPublicKeyFromString(BuildConfig.DUMP_KEY) ?: return null + return RSAEncryptionHelper.encryptText(deviceId, publicKey) + } + + private fun getSign(currentTime: String, params: Map): String? { + val chipper = listOf( + currentTime, + params.map { it.value }.reversed().joinToString("") + .let { base64Encode(it.toByteArray()) }).joinToString("") + val enc = cryptoHandler(chipper, deviceId) + return md5(enc) + } + + private fun md5(input: String): String { + val md = MessageDigest.getInstance("MD5") + return BigInteger(1, md.digest(input.toByteArray())).toString(16).padStart(32, '0') + } + +} + +object RSAEncryptionHelper { + + private const val RSA_ALGORITHM = "RSA" + private const val CIPHER_TYPE_FOR_RSA = "RSA/ECB/PKCS1Padding" + + private val keyFactory = KeyFactory.getInstance(RSA_ALGORITHM) + private val cipher = Cipher.getInstance(CIPHER_TYPE_FOR_RSA) + + fun getPublicKeyFromString(publicKeyString: String): PublicKey? = + try { + val keySpec = + X509EncodedKeySpec(Base64.decode(publicKeyString.toByteArray(), Base64.NO_WRAP)) + keyFactory.generatePublic(keySpec) + } catch (exception: Exception) { + exception.printStackTrace() + null + } + + fun getPrivateKeyFromString(privateKeyString: String): PrivateKey? = + try { + val keySpec = + PKCS8EncodedKeySpec(Base64.decode(privateKeyString.toByteArray(), Base64.DEFAULT)) + keyFactory.generatePrivate(keySpec) + } catch (exception: Exception) { + exception.printStackTrace() + null + } + + fun encryptText(plainText: String, publicKey: PublicKey): String? = + try { + cipher.init(Cipher.ENCRYPT_MODE, publicKey) + Base64.encodeToString(cipher.doFinal(plainText.toByteArray()), Base64.NO_WRAP) + } catch (exception: Exception) { + exception.printStackTrace() + null + } + + fun decryptText(encryptedText: String, privateKey: PrivateKey): String? = + try { + cipher.init(Cipher.DECRYPT_MODE, privateKey) + String(cipher.doFinal(Base64.decode(encryptedText, Base64.DEFAULT))) + } catch (exception: Exception) { + exception.printStackTrace() + null + } +} + +// code found on https://stackoverflow.com/a/63701411 + +/** + * Conforming with CryptoJS AES method + */ +// see https://gist.github.com/thackerronak/554c985c3001b16810af5fc0eb5c358f +@Suppress("unused", "FunctionName", "SameParameterValue") +object CryptoJS { + + private const val KEY_SIZE = 256 + private const val IV_SIZE = 128 + private const val HASH_CIPHER = "AES/CBC/PKCS7Padding" + private const val AES = "AES" + private const val KDF_DIGEST = "MD5" + + // Seriously crypto-js, what's wrong with you? + private const val APPEND = "Salted__" + + /** + * Encrypt + * @param password passphrase + * @param plainText plain string + */ + fun encrypt(password: String, plainText: String): String { + val saltBytes = generateSalt(8) + val key = ByteArray(KEY_SIZE / 8) + val iv = ByteArray(IV_SIZE / 8) + EvpKDF(password.toByteArray(), KEY_SIZE, IV_SIZE, saltBytes, key, iv) + val keyS = SecretKeySpec(key, AES) + val cipher = Cipher.getInstance(HASH_CIPHER) + val ivSpec = IvParameterSpec(iv) + cipher.init(Cipher.ENCRYPT_MODE, keyS, ivSpec) + val cipherText = cipher.doFinal(plainText.toByteArray()) + // Thanks kientux for this: https://gist.github.com/kientux/bb48259c6f2133e628ad + // Create CryptoJS-like encrypted! + val sBytes = APPEND.toByteArray() + val b = ByteArray(sBytes.size + saltBytes.size + cipherText.size) + System.arraycopy(sBytes, 0, b, 0, sBytes.size) + System.arraycopy(saltBytes, 0, b, sBytes.size, saltBytes.size) + System.arraycopy(cipherText, 0, b, sBytes.size + saltBytes.size, cipherText.size) + val bEncode = Base64.encode(b, Base64.NO_WRAP) + return String(bEncode) + } + + /** + * Decrypt + * Thanks Artjom B. for this: http://stackoverflow.com/a/29152379/4405051 + * @param password passphrase + * @param cipherText encrypted string + */ + fun decrypt(password: String, cipherText: String): String { + val ctBytes = Base64.decode(cipherText.toByteArray(), Base64.NO_WRAP) + val saltBytes = Arrays.copyOfRange(ctBytes, 8, 16) + val cipherTextBytes = Arrays.copyOfRange(ctBytes, 16, ctBytes.size) + val key = ByteArray(KEY_SIZE / 8) + val iv = ByteArray(IV_SIZE / 8) + EvpKDF(password.toByteArray(), KEY_SIZE, IV_SIZE, saltBytes, key, iv) + val cipher = Cipher.getInstance(HASH_CIPHER) + val keyS = SecretKeySpec(key, AES) + cipher.init(Cipher.DECRYPT_MODE, keyS, IvParameterSpec(iv)) + val plainText = cipher.doFinal(cipherTextBytes) + return String(plainText) + } + + private fun EvpKDF( + password: ByteArray, + keySize: Int, + ivSize: Int, + salt: ByteArray, + resultKey: ByteArray, + resultIv: ByteArray + ): ByteArray { + return EvpKDF(password, keySize, ivSize, salt, 1, KDF_DIGEST, resultKey, resultIv) + } + + @Suppress("NAME_SHADOWING") + private fun EvpKDF( + password: ByteArray, + keySize: Int, + ivSize: Int, + salt: ByteArray, + iterations: Int, + hashAlgorithm: String, + resultKey: ByteArray, + resultIv: ByteArray + ): ByteArray { + val keySize = keySize / 32 + val ivSize = ivSize / 32 + val targetKeySize = keySize + ivSize + val derivedBytes = ByteArray(targetKeySize * 4) + var numberOfDerivedWords = 0 + var block: ByteArray? = null + val hash = MessageDigest.getInstance(hashAlgorithm) + while (numberOfDerivedWords < targetKeySize) { + if (block != null) { + hash.update(block) + } + hash.update(password) + block = hash.digest(salt) + hash.reset() + // Iterations + for (i in 1 until iterations) { + block = hash.digest(block!!) + hash.reset() + } + System.arraycopy( + block!!, 0, derivedBytes, numberOfDerivedWords * 4, + min(block.size, (targetKeySize - numberOfDerivedWords) * 4) + ) + numberOfDerivedWords += block.size / 4 + } + System.arraycopy(derivedBytes, 0, resultKey, 0, keySize * 4) + System.arraycopy(derivedBytes, keySize * 4, resultIv, 0, ivSize * 4) + return derivedBytes // key + iv + } + + private fun generateSalt(length: Int): ByteArray { + return ByteArray(length).apply { + SecureRandom().nextBytes(this) + } + } +} + +object AESGCM { + fun ByteArray.decrypt(pass: String): String { + val (key, iv) = generateKeyAndIv(pass) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv)) + return String(cipher.doFinal(this), StandardCharsets.UTF_8) + } + + private fun generateKeyAndIv(pass: String): Pair { + val datePart = getCurrentUTCDateString().take(16) + val hexString = datePart + pass + val byteArray = hexString.toByteArray(StandardCharsets.UTF_8) + val digest = MessageDigest.getInstance("SHA-256").digest(byteArray) + return digest.copyOfRange(0, digest.size / 2) to digest.copyOfRange( + digest.size / 2, + digest.size + ) + } + + private fun getCurrentUTCDateString(): String { + val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.getDefault()) + dateFormat.timeZone = TimeZone.getTimeZone("GMT") + return dateFormat.format(Date()) + } +} diff --git a/Superstream/build.gradle.kts b/Superstream/build.gradle.kts new file mode 100644 index 0000000..057e388 --- /dev/null +++ b/Superstream/build.gradle.kts @@ -0,0 +1,42 @@ +import org.jetbrains.kotlin.konan.properties.Properties + +// use an integer for version numbers +version = 3 + +android { + defaultConfig { + val properties = Properties() + properties.load(project.rootProject.file("local.properties").inputStream()) + + buildConfigField("String", "SUPERSTREAM_FIRST_API", "\"${properties.getProperty("SUPERSTREAM_FIRST_API")}\"") + buildConfigField("String", "SUPERSTREAM_SECOND_API", "\"${properties.getProperty("SUPERSTREAM_SECOND_API")}\"") + buildConfigField("String", "SUPERSTREAM_THIRD_API", "\"${properties.getProperty("SUPERSTREAM_THIRD_API")}\"") + buildConfigField("String", "SUPERSTREAM_FOURTH_API", "\"${properties.getProperty("SUPERSTREAM_FOURTH_API")}\"") + } +} + +cloudstream { + language = "en" + // All of these properties are optional, you can safely remove them + + // description = "Lorem Ipsum" + authors = listOf("Blatzar") + + /** + * Status int as the following: + * 0: Down + * 1: Ok + * 2: Slow + * 3: Beta only + * */ + status = 1 // will be 3 if unspecified + tvTypes = listOf( + "AsianDrama", + "Anime", + "TvSeries", + "Movie", + ) + + + iconUrl = "https://cdn.discordapp.com/attachments/1109266606292488297/1196694385061003334/icon.png?ex=65efee7e&is=65dd797e&hm=18fa57323826d0cbf3cf5ce7d3f5705de640f2f8d08739d41f95882d2ae0a3e0&" +} \ No newline at end of file diff --git a/Superstream/src/main/AndroidManifest.xml b/Superstream/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c98063f --- /dev/null +++ b/Superstream/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Superstream/src/main/kotlin/com/hexated/Extractors.kt b/Superstream/src/main/kotlin/com/hexated/Extractors.kt new file mode 100644 index 0000000..1d0fd0e --- /dev/null +++ b/Superstream/src/main/kotlin/com/hexated/Extractors.kt @@ -0,0 +1,243 @@ +package com.hexated + +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.utils.* +import java.net.URL + +object Extractors : Superstream() { + + suspend fun invokeInternalSource( + id: Int? = null, + type: Int? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ) { + fun LinkList.toExtractorLink(): ExtractorLink? { + if (this.path.isNullOrBlank()) return null + return ExtractorLink( + "Internal", + "Internal [${this.size}]", + this.path.replace("\\/", ""), + "", + getQualityFromName(this.quality), + ) + } + + // No childmode when getting links + // New api does not return video links :( + val query = if (type == ResponseTypes.Movies.value) { + """{"childmode":"0","uid":"","app_version":"11.5","appid":"$appId","module":"Movie_downloadurl_v3","channel":"Website","mid":"$id","lang":"","expired_date":"${getExpiryDate()}","platform":"android","oss":"1","group":""}""" + } else { + """{"childmode":"0","app_version":"11.5","module":"TV_downloadurl_v3","channel":"Website","episode":"$episode","expired_date":"${getExpiryDate()}","platform":"android","tid":"$id","oss":"1","uid":"","appid":"$appId","season":"$season","lang":"en","group":""}""" + } + + val linkData = queryApiParsed(query, false) + linkData.data?.list?.forEach { + callback.invoke(it.toExtractorLink() ?: return@forEach) + } + + // Should really run this query for every link :( + val fid = linkData.data?.list?.firstOrNull { it.fid != null }?.fid + + val subtitleQuery = if (type == ResponseTypes.Movies.value) { + """{"childmode":"0","fid":"$fid","uid":"","app_version":"11.5","appid":"$appId","module":"Movie_srt_list_v2","channel":"Website","mid":"$id","lang":"en","expired_date":"${getExpiryDate()}","platform":"android"}""" + } else { + """{"childmode":"0","fid":"$fid","app_version":"11.5","module":"TV_srt_list_v2","channel":"Website","episode":"$episode","expired_date":"${getExpiryDate()}","platform":"android","tid":"$id","uid":"","appid":"$appId","season":"$season","lang":"en"}""" + } + + val subtitles = queryApiParsed(subtitleQuery).data + subtitles?.list?.forEach { subs -> + val sub = subs.subtitles.maxByOrNull { it.support_total ?: 0 } + subtitleCallback.invoke( + SubtitleFile( + sub?.language ?: sub?.lang ?: return@forEach, + sub?.filePath ?: return@forEach + ) + ) + } + } + + suspend fun invokeExternalSource( + mediaId: Int? = null, + type: Int? = null, + season: Int? = null, + episode: Int? = null, + callback: (ExtractorLink) -> Unit, + ) { + val (seasonSlug, episodeSlug) = getEpisodeSlug(season, episode) + val shareKey = app.get("$fourthAPI/index/share_link?id=${mediaId}&type=$type") + .parsedSafe()?.data?.link?.substringAfterLast("/") ?: return + + val headers = mapOf("Accept-Language" to "en") + val shareRes = app.get("$thirdAPI/file/file_share_list?share_key=$shareKey", headers = headers) + .parsedSafe()?.data ?: return + + val fids = if (season == null) { + shareRes.file_list + } else { + val parentId = shareRes.file_list?.find { it.file_name.equals("season $season", true) }?.fid + app.get("$thirdAPI/file/file_share_list?share_key=$shareKey&parent_id=$parentId&page=1", headers = headers) + .parsedSafe()?.data?.file_list?.filter { + it.file_name?.contains("s${seasonSlug}e${episodeSlug}", true) == true + } + } ?: return + + fids.apmapIndexed { index, fileList -> + val player = app.get("$thirdAPI/file/player?fid=${fileList.fid}&share_key=$shareKey").text + val sources = "sources\\s*=\\s*(.*);".toRegex().find(player)?.groupValues?.get(1) + val qualities = "quality_list\\s*=\\s*(.*);".toRegex().find(player)?.groupValues?.get(1) + listOf(sources, qualities).forEach { + AppUtils.tryParseJson>(it)?.forEach org@{ source -> + val format = if (source.type == "video/mp4") ExtractorLinkType.VIDEO else ExtractorLinkType.M3U8 + val label = if (format == ExtractorLinkType.M3U8) "Hls" else "Mp4" + if(!(source.label == "AUTO" || format == ExtractorLinkType.VIDEO)) return@org + callback.invoke( + ExtractorLink( + "External", + "External $label [Server ${index + 1}]", + (source.m3u8_url ?: source.file)?.replace("\\/", "/") ?: return@org, + "", + getIndexQuality(if (format == ExtractorLinkType.M3U8) fileList.file_name else source.label), + type = format, + ) + ) + } + } + } + } + + suspend fun invokeWatchsomuch( + imdbId: String? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + ) { + val id = imdbId?.removePrefix("tt") + val epsId = app.post( + "$watchSomuchAPI/Watch/ajMovieTorrents.aspx", + data = mapOf( + "index" to "0", + "mid" to "$id", + "wsk" to "30fb68aa-1c71-4b8c-b5d4-4ca9222cfb45", + "lid" to "", + "liu" to "" + ), headers = mapOf("X-Requested-With" to "XMLHttpRequest") + ).parsedSafe()?.movie?.torrents?.let { eps -> + if (season == null) { + eps.firstOrNull()?.id + } else { + eps.find { it.episode == episode && it.season == season }?.id + } + } ?: return + + val (seasonSlug, episodeSlug) = getEpisodeSlug( + season, + episode + ) + + val subUrl = if (season == null) { + "$watchSomuchAPI/Watch/ajMovieSubtitles.aspx?mid=$id&tid=$epsId&part=" + } else { + "$watchSomuchAPI/Watch/ajMovieSubtitles.aspx?mid=$id&tid=$epsId&part=S${seasonSlug}E${episodeSlug}" + } + + app.get(subUrl) + .parsedSafe()?.subtitles + ?.map { sub -> + subtitleCallback.invoke( + SubtitleFile( + sub.label ?: "", + fixUrl(sub.url ?: return@map null, watchSomuchAPI) + ) + ) + } + + + } + + suspend fun invokeOpenSubs( + imdbId: String? = null, + season: Int? = null, + episode: Int? = null, + subtitleCallback: (SubtitleFile) -> Unit, + ) { + val slug = if(season == null) { + "movie/$imdbId" + } else { + "series/$imdbId:$season:$episode" + } + app.get("${openSubAPI}/subtitles/$slug.json", timeout = 120L).parsedSafe()?.subtitles?.map { sub -> + subtitleCallback.invoke( + SubtitleFile( + SubtitleHelper.fromThreeLettersToLanguage(sub.lang ?: "") ?: sub.lang + ?: return@map, + sub.url ?: return@map + ) + ) + } + } + + suspend fun invokeVidsrcto( + imdbId: String?, + season: Int?, + episode: Int?, + subtitleCallback: (SubtitleFile) -> Unit, + ) { + val url = if (season == null) { + "$vidsrctoAPI/embed/movie/$imdbId" + } else { + "$vidsrctoAPI/embed/tv/$imdbId/$season/$episode" + } + + val mediaId = app.get(url).document.selectFirst("ul.episodes li a")?.attr("data-id") ?: return + val subtitles = app.get("$vidsrctoAPI/ajax/embed/episode/$mediaId/subtitles").text + AppUtils.tryParseJson>(subtitles)?.map { + subtitleCallback.invoke( + SubtitleFile( + it.label ?: "", + it.file ?: return@map + ) + ) + } + + } + + private fun fixUrl(url: String, domain: String): String { + if (url.startsWith("http")) { + return url + } + if (url.isEmpty()) { + return "" + } + + val startsWithNoHttp = url.startsWith("//") + if (startsWithNoHttp) { + return "https:$url" + } else { + if (url.startsWith('/')) { + return domain + url + } + return "$domain/$url" + } + } + + private fun getIndexQuality(str: String?): Int { + return Regex("(\\d{3,4})[pP]").find(str ?: "")?.groupValues?.getOrNull(1)?.toIntOrNull() + ?: Qualities.Unknown.value + } + + private fun getEpisodeSlug( + season: Int? = null, + episode: Int? = null, + ): Pair { + return if (season == null && episode == null) { + "" to "" + } else { + (if (season!! < 10) "0$season" else "$season") to (if (episode!! < 10) "0$episode" else "$episode") + } + } + +} \ No newline at end of file diff --git a/Superstream/src/main/kotlin/com/hexated/Superstream.kt b/Superstream/src/main/kotlin/com/hexated/Superstream.kt new file mode 100644 index 0000000..bd971a6 --- /dev/null +++ b/Superstream/src/main/kotlin/com/hexated/Superstream.kt @@ -0,0 +1,823 @@ +package com.hexated + +import android.util.Base64 +import com.fasterxml.jackson.annotation.JsonProperty +import com.hexated.Extractors.invokeExternalSource +import com.hexated.Extractors.invokeInternalSource +import com.hexated.Extractors.invokeOpenSubs +import com.hexated.Extractors.invokeVidsrcto +import com.hexated.Extractors.invokeWatchsomuch +import com.hexated.Superstream.CipherUtils.getVerify +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.APIHolder.capitalize +import com.lagradost.cloudstream3.APIHolder.unixTime +import com.lagradost.cloudstream3.LoadResponse.Companion.addImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.nicehttp.NiceResponse +import okhttp3.Interceptor +import okhttp3.Response +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import javax.crypto.Cipher +import javax.crypto.Cipher.DECRYPT_MODE +import javax.crypto.Cipher.ENCRYPT_MODE +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.math.roundToInt + +open class Superstream : MainAPI() { + private val timeout = 60L + override var name = "SuperStream" + override val hasMainPage = true + override val hasChromecastSupport = true + override val supportedTypes = setOf( + TvType.Movie, + TvType.TvSeries, + TvType.Anime, + TvType.AnimeMovie, + ) + + enum class ResponseTypes(val value: Int) { + Series(2), + Movies(1); + + fun toTvType(): TvType { + return if (this == Series) TvType.TvSeries else TvType.Movie + } + + companion object { + fun getResponseType(value: Int?): ResponseTypes { + return values().firstOrNull { it.value == value } ?: Movies + } + } + } + + override val instantLinkLoading = true + + private val interceptor = UserAgentInterceptor() + + private val headers = mapOf( + "Platform" to "android", + "Accept" to "charset=utf-8", + ) + + private class UserAgentInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + return chain.proceed( + chain.request() + .newBuilder() + .removeHeader("user-agent") + .build() + ) + } + } + + // Random 32 length string + private fun randomToken(): String { + return (0..31).joinToString("") { + (('0'..'9') + ('a'..'f')).random().toString() + } + } + + private val token = randomToken() + + private object CipherUtils { + private const val ALGORITHM = "DESede" + private const val TRANSFORMATION = "DESede/CBC/PKCS5Padding" + fun encrypt(str: String, key: String, iv: String): String? { + return try { + val cipher: Cipher = Cipher.getInstance(TRANSFORMATION) + val bArr = ByteArray(24) + val bytes: ByteArray = key.toByteArray() + var length = if (bytes.size <= 24) bytes.size else 24 + System.arraycopy(bytes, 0, bArr, 0, length) + while (length < 24) { + bArr[length] = 0 + length++ + } + cipher.init( + ENCRYPT_MODE, + SecretKeySpec(bArr, ALGORITHM), + IvParameterSpec(iv.toByteArray()) + ) + + String(Base64.encode(cipher.doFinal(str.toByteArray()), 2), StandardCharsets.UTF_8) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + // Useful for deobfuscation + fun decrypt(str: String, key: String, iv: String): String? { + return try { + val cipher: Cipher = Cipher.getInstance(TRANSFORMATION) + val bArr = ByteArray(24) + val bytes: ByteArray = key.toByteArray() + var length = if (bytes.size <= 24) bytes.size else 24 + System.arraycopy(bytes, 0, bArr, 0, length) + while (length < 24) { + bArr[length] = 0 + length++ + } + cipher.init( + DECRYPT_MODE, + SecretKeySpec(bArr, ALGORITHM), + IvParameterSpec(iv.toByteArray()) + ) + val inputStr = Base64.decode(str.toByteArray(), Base64.DEFAULT) + cipher.doFinal(inputStr).decodeToString() + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + fun md5(str: String): String? { + return MD5Util.md5(str)?.let { HexDump.toHexString(it).lowercase() } + } + + fun getVerify(str: String?, str2: String, str3: String): String? { + if (str != null) { + return md5(md5(str2) + str3 + str) + } + return null + } + } + + private object HexDump { + private val HEX_DIGITS = charArrayOf( + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F' + ) + + @JvmOverloads + fun toHexString(bArr: ByteArray, i: Int = 0, i2: Int = bArr.size): String { + val cArr = CharArray(i2 * 2) + var i3 = 0 + for (i4 in i until i + i2) { + val b = bArr[i4].toInt() + val i5 = i3 + 1 + val cArr2 = HEX_DIGITS + cArr[i3] = cArr2[b ushr 4 and 15] + i3 = i5 + 1 + cArr[i5] = cArr2[b and 15] + } + return String(cArr) + } + } + + private object MD5Util { + fun md5(str: String): ByteArray? { + return md5(str.toByteArray()) + } + + fun md5(bArr: ByteArray?): ByteArray? { + return try { + val digest = MessageDigest.getInstance("MD5") + digest.update(bArr ?: return null) + digest.digest() + } catch (e: NoSuchAlgorithmException) { + e.printStackTrace() + null + } + } + } + + suspend fun queryApi(query: String, useAlternativeApi: Boolean): NiceResponse { + val encryptedQuery = CipherUtils.encrypt(query, key, iv)!! + val appKeyHash = CipherUtils.md5(appKey)!! + val newBody = + """{"app_key":"$appKeyHash","verify":"${ + getVerify( + encryptedQuery, + appKey, + key + ) + }","encrypt_data":"$encryptedQuery"}""" + val base64Body = String(Base64.encode(newBody.toByteArray(), Base64.DEFAULT)) + + val data = mapOf( + "data" to base64Body, + "appid" to "27", + "platform" to "android", + "version" to appVersionCode, + // Probably best to randomize this + "medium" to "Website&token$token" + ) + + val url = if (useAlternativeApi) secondAPI else firstAPI + return app.post( + url, + headers = headers, + data = data, + timeout = timeout, + interceptor = interceptor + ) + } + + suspend inline fun queryApiParsed( + query: String, + useAlternativeApi: Boolean = true + ): T { + return queryApi(query, useAlternativeApi).parsed() + } + + fun getExpiryDate(): Long { + // Current time + 12 hours + return unixTime + 60 * 60 * 12 + } + + private data class PostJSON( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("poster") val poster: String? = null, + @JsonProperty("poster_2") val poster2: String? = null, + @JsonProperty("box_type") val boxType: Int? = null, + @JsonProperty("imdb_rating") val imdbRating: String? = null, + @JsonProperty("quality_tag") val quality_tag: String? = null, + ) + + private data class ListJSON( + @JsonProperty("code") val code: Int? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("box_type") val boxType: Int? = null, + @JsonProperty("list") val list: ArrayList = arrayListOf(), + ) + + private data class DataJSON( + @JsonProperty("data") val data: ArrayList = arrayListOf() + ) + + // We do not want content scanners to notice this scraping going on so we've hidden all constants + // The source has its origins in China so I added some extra security with banned words + // Mayhaps a tiny bit unethical, but this source is just too good :) + // If you are copying this code please use precautions so they do not change their api. + + // Free Tibet, The Tienanmen Square protests of 1989 + private val iv = base64Decode("d0VpcGhUbiE=") + private val key = base64Decode("MTIzZDZjZWRmNjI2ZHk1NDIzM2FhMXc2") + + private val firstAPI = BuildConfig.SUPERSTREAM_FIRST_API + + // Another url because the first one sucks at searching + // This one was revealed to me in a dream + private val secondAPI = BuildConfig.SUPERSTREAM_SECOND_API + + val thirdAPI = BuildConfig.SUPERSTREAM_THIRD_API + val fourthAPI = BuildConfig.SUPERSTREAM_FOURTH_API + + val watchSomuchAPI = "https://watchsomuch.tv" + val openSubAPI = "https://opensubtitles-v3.strem.io" + val vidsrctoAPI = "https://vidsrc.to" + + private val appKey = base64Decode("bW92aWVib3g=") + val appId = base64Decode("Y29tLnRkby5zaG93Ym94") + private val appIdSecond = base64Decode("Y29tLm1vdmllYm94cHJvLmFuZHJvaWQ=") + private val appVersion = "11.5" + private val appVersionCode = "129" + + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + val hideNsfw = if (settingsForProvider.enableAdult) 0 else 1 + val data = queryApiParsed( + """{"childmode":"$hideNsfw","app_version":"$appVersion","appid":"$appIdSecond","module":"Home_list_type_v5","channel":"Website","page":"$page","lang":"en","type":"all","pagelimit":"10","expired_date":"${getExpiryDate()}","platform":"android"} + """.trimIndent() + ) + + // Cut off the first row (featured) + val pages = data.data.let { it.subList(minOf(it.size, 1), it.size) } + .mapNotNull { + var name = it.name + if (name.isNullOrEmpty()) name = "Featured" + val postList = it.list.mapNotNull second@{ post -> + val type = if (post.boxType == 1) TvType.Movie else TvType.TvSeries + newMovieSearchResponse( + name = post.title ?: return@second null, + url = LoadData(post.id ?: return@mapNotNull null, post.boxType).toJson(), + type = type, + fix = false + ) { + posterUrl = post.poster ?: post.poster2 + quality = getQualityFromString(post.quality_tag ?: "") + } + } + if (postList.isEmpty()) return@mapNotNull null + HomePageList(name, postList) + } + return HomePageResponse(pages, hasNext = !pages.any { it.list.isEmpty() }) + } + + private data class Data( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("mid") val mid: Int? = null, + @JsonProperty("box_type") val boxType: Int? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("poster_org") val posterOrg: String? = null, + @JsonProperty("poster") val poster: String? = null, + @JsonProperty("cats") val cats: String? = null, + @JsonProperty("year") val year: Int? = null, + @JsonProperty("imdb_rating") val imdbRating: String? = null, + @JsonProperty("quality_tag") val qualityTag: String? = null, + ) { + fun toSearchResponse(api: MainAPI): MovieSearchResponse? { + return api.newMovieSearchResponse( + this.title ?: "", + LoadData( + this.id ?: this.mid ?: return null, + this.boxType ?: ResponseTypes.Movies.value + ).toJson(), + ResponseTypes.getResponseType(this.boxType).toTvType(), + false + ) { + posterUrl = this@Data.posterOrg ?: this@Data.poster + year = this@Data.year + quality = getQualityFromString(this@Data.qualityTag?.replace("-", "") ?: "") + } + } + } + + private data class MainDataList( + @JsonProperty("list") val list: ArrayList = arrayListOf() + ) + + private data class MainData( + @JsonProperty("data") val data: MainDataList + ) + + override suspend fun search(query: String): List { + val hideNsfw = if (settingsForProvider.enableAdult) 0 else 1 + val apiQuery = + // Originally 8 pagelimit + """{"childmode":"$hideNsfw","app_version":"$appVersion","appid":"$appIdSecond","module":"Search4","channel":"Website","page":"1","lang":"en","type":"all","keyword":"$query","pagelimit":"20","expired_date":"${getExpiryDate()}","platform":"android"}""" + val searchResponse = queryApiParsed(apiQuery, true).data.list.mapNotNull { + it.toSearchResponse(this) + } + return searchResponse + } + + private data class LoadData( + val id: Int, + val type: Int? + ) + + private data class MovieData( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("director") val director: String? = null, + @JsonProperty("writer") val writer: String? = null, + @JsonProperty("actors") val actors: String? = null, + @JsonProperty("runtime") val runtime: Int? = null, + @JsonProperty("poster") val poster: String? = null, + @JsonProperty("description") val description: String? = null, + @JsonProperty("cats") val cats: String? = null, + @JsonProperty("year") val year: Int? = null, + @JsonProperty("imdb_id") val imdbId: String? = null, + @JsonProperty("imdb_rating") val imdbRating: String? = null, + @JsonProperty("trailer") val trailer: String? = null, + @JsonProperty("released") val released: String? = null, + @JsonProperty("content_rating") val contentRating: String? = null, + @JsonProperty("tmdb_id") val tmdbId: Int? = null, + @JsonProperty("tomato_meter") val tomatoMeter: Int? = null, + @JsonProperty("poster_org") val posterOrg: String? = null, + @JsonProperty("trailer_url") val trailerUrl: String? = null, + @JsonProperty("imdb_link") val imdbLink: String? = null, + @JsonProperty("box_type") val boxType: Int? = null, + @JsonProperty("recommend") val recommend: List = listOf(), + ) + + private data class MovieDataProp( + @JsonProperty("data") val data: MovieData? = MovieData() + ) + + + private data class SeriesDataProp( + @JsonProperty("code") val code: Int? = null, + @JsonProperty("msg") val msg: String? = null, + @JsonProperty("data") val data: SeriesData? = SeriesData() + ) + + private data class SeriesSeasonProp( + @JsonProperty("code") val code: Int? = null, + @JsonProperty("msg") val msg: String? = null, + @JsonProperty("data") val data: ArrayList? = arrayListOf() + ) +// data class PlayProgress ( +// +// @JsonProperty("over" ) val over : Int? = null, +// @JsonProperty("seconds" ) val seconds : Int? = null, +// @JsonProperty("mp4_id" ) val mp4Id : Int? = null, +// @JsonProperty("last_time" ) val lastTime : Int? = null +// +//) + + private data class SeriesEpisode( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("tid") val tid: Int? = null, + @JsonProperty("mb_id") val mbId: Int? = null, + @JsonProperty("imdb_id") val imdbId: String? = null, + @JsonProperty("imdb_id_status") val imdbIdStatus: Int? = null, + @JsonProperty("srt_status") val srtStatus: Int? = null, + @JsonProperty("season") val season: Int? = null, + @JsonProperty("episode") val episode: Int? = null, + @JsonProperty("state") val state: Int? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("thumbs") val thumbs: String? = null, + @JsonProperty("thumbs_bak") val thumbsBak: String? = null, + @JsonProperty("thumbs_original") val thumbsOriginal: String? = null, + @JsonProperty("poster_imdb") val posterImdb: Int? = null, + @JsonProperty("synopsis") val synopsis: String? = null, + @JsonProperty("runtime") val runtime: Int? = null, + @JsonProperty("view") val view: Int? = null, + @JsonProperty("download") val download: Int? = null, + @JsonProperty("source_file") val sourceFile: Int? = null, + @JsonProperty("code_file") val codeFile: Int? = null, + @JsonProperty("add_time") val addTime: Int? = null, + @JsonProperty("update_time") val updateTime: Int? = null, + @JsonProperty("released") val released: String? = null, + @JsonProperty("released_timestamp") val releasedTimestamp: Long? = null, + @JsonProperty("audio_lang") val audioLang: String? = null, + @JsonProperty("quality_tag") val qualityTag: String? = null, + @JsonProperty("3d") val _3d: Int? = null, + @JsonProperty("remark") val remark: String? = null, + @JsonProperty("pending") val pending: String? = null, + @JsonProperty("imdb_rating") val imdbRating: String? = null, + @JsonProperty("display") val display: Int? = null, + @JsonProperty("sync") val sync: Int? = null, + @JsonProperty("tomato_meter") val tomatoMeter: Int? = null, + @JsonProperty("tomato_meter_count") val tomatoMeterCount: Int? = null, + @JsonProperty("tomato_audience") val tomatoAudience: Int? = null, + @JsonProperty("tomato_audience_count") val tomatoAudienceCount: Int? = null, + @JsonProperty("thumbs_min") val thumbsMin: String? = null, + @JsonProperty("thumbs_org") val thumbsOrg: String? = null, + @JsonProperty("imdb_link") val imdbLink: String? = null, +// @JsonProperty("quality_tags") val qualityTags: ArrayList = arrayListOf(), +// @JsonProperty("play_progress" ) val playProgress : PlayProgress? = PlayProgress() + + ) + + private data class SeriesLanguage( + @JsonProperty("title") val title: String? = null, + @JsonProperty("lang") val lang: String? = null + ) + + private data class SeriesData( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("mb_id") val mbId: Int? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("display") val display: Int? = null, + @JsonProperty("state") val state: Int? = null, + @JsonProperty("vip_only") val vipOnly: Int? = null, + @JsonProperty("code_file") val codeFile: Int? = null, + @JsonProperty("director") val director: String? = null, + @JsonProperty("writer") val writer: String? = null, + @JsonProperty("actors") val actors: String? = null, + @JsonProperty("add_time") val addTime: Int? = null, + @JsonProperty("poster") val poster: String? = null, + @JsonProperty("poster_imdb") val posterImdb: Int? = null, + @JsonProperty("banner_mini") val bannerMini: String? = null, + @JsonProperty("description") val description: String? = null, + @JsonProperty("imdb_id") val imdbId: String? = null, + @JsonProperty("cats") val cats: String? = null, + @JsonProperty("year") val year: Int? = null, + @JsonProperty("collect") val collect: Int? = null, + @JsonProperty("view") val view: Int? = null, + @JsonProperty("download") val download: Int? = null, + @JsonProperty("update_time") val updateTime: String? = null, + @JsonProperty("released") val released: String? = null, + @JsonProperty("released_timestamp") val releasedTimestamp: Int? = null, + @JsonProperty("episode_released") val episodeReleased: String? = null, + @JsonProperty("episode_released_timestamp") val episodeReleasedTimestamp: Int? = null, + @JsonProperty("max_season") val maxSeason: Int? = null, + @JsonProperty("max_episode") val maxEpisode: Int? = null, + @JsonProperty("remark") val remark: String? = null, + @JsonProperty("imdb_rating") val imdbRating: String? = null, + @JsonProperty("content_rating") val contentRating: String? = null, + @JsonProperty("tmdb_id") val tmdbId: Int? = null, + @JsonProperty("tomato_url") val tomatoUrl: String? = null, + @JsonProperty("tomato_meter") val tomatoMeter: Int? = null, + @JsonProperty("tomato_meter_count") val tomatoMeterCount: Int? = null, + @JsonProperty("tomato_meter_state") val tomatoMeterState: String? = null, + @JsonProperty("reelgood_url") val reelgoodUrl: String? = null, + @JsonProperty("audience_score") val audienceScore: Int? = null, + @JsonProperty("audience_score_count") val audienceScoreCount: Int? = null, + @JsonProperty("no_tomato_url") val noTomatoUrl: Int? = null, + @JsonProperty("order_year") val orderYear: Int? = null, + @JsonProperty("episodate_id") val episodateId: String? = null, + @JsonProperty("weights_day") val weightsDay: Double? = null, + @JsonProperty("poster_min") val posterMin: String? = null, + @JsonProperty("poster_org") val posterOrg: String? = null, + @JsonProperty("banner_mini_min") val bannerMiniMin: String? = null, + @JsonProperty("banner_mini_org") val bannerMiniOrg: String? = null, + @JsonProperty("trailer_url") val trailerUrl: String? = null, + @JsonProperty("years") val years: ArrayList = arrayListOf(), + @JsonProperty("season") val season: ArrayList = arrayListOf(), + @JsonProperty("history") val history: ArrayList = arrayListOf(), + @JsonProperty("imdb_link") val imdbLink: String? = null, + @JsonProperty("episode") val episode: ArrayList = arrayListOf(), +// @JsonProperty("is_collect") val isCollect: Int? = null, + @JsonProperty("language") val language: ArrayList = arrayListOf(), + @JsonProperty("box_type") val boxType: Int? = null, + @JsonProperty("year_year") val yearYear: String? = null, + @JsonProperty("season_episode") val seasonEpisode: String? = null + ) + + + override suspend fun load(url: String): LoadResponse { + val loadData = parseJson(url) + // val module = if(type === "TvType.Movie") "Movie_detail" else "*tv series module*" + + val isMovie = loadData.type == ResponseTypes.Movies.value + val hideNsfw = if (settingsForProvider.enableAdult) 0 else 1 + if (isMovie) { // 1 = Movie + val apiQuery = + """{"childmode":"$hideNsfw","uid":"","app_version":"$appVersion","appid":"$appIdSecond","module":"Movie_detail","channel":"Website","mid":"${loadData.id}","lang":"en","expired_date":"${getExpiryDate()}","platform":"android","oss":"","group":""}""" + val data = (queryApiParsed(apiQuery)).data + ?: throw RuntimeException("API error") + + return newMovieLoadResponse( + data.title ?: "", + url, + TvType.Movie, + LinkData( + data.id ?: throw RuntimeException("No movie ID"), + ResponseTypes.Movies.value, + null, + null, + data.id, + data.imdbId + ), + ) { + this.recommendations = + data.recommend.mapNotNull { it.toSearchResponse(this@Superstream) } + this.posterUrl = data.posterOrg ?: data.poster + this.year = data.year + this.plot = data.description + this.tags = data.cats?.split(",")?.map { it.capitalize() } + this.rating = data.imdbRating?.split("/")?.get(0)?.toIntOrNull() + addTrailer(data.trailerUrl) + this.addImdbId(data.imdbId) + } + } else { // 2 Series + val apiQuery = + """{"childmode":"$hideNsfw","uid":"","app_version":"$appVersion","appid":"$appIdSecond","module":"TV_detail_1","display_all":"1","channel":"Website","lang":"en","expired_date":"${getExpiryDate()}","platform":"android","tid":"${loadData.id}"}""" + val data = (queryApiParsed(apiQuery)).data + ?: throw RuntimeException("API error") + + val episodes = data.season.mapNotNull { + val seasonQuery = + """{"childmode":"$hideNsfw","app_version":"$appVersion","year":"0","appid":"$appIdSecond","module":"TV_episode","display_all":"1","channel":"Website","season":"$it","lang":"en","expired_date":"${getExpiryDate()}","platform":"android","tid":"${loadData.id}"}""" + (queryApiParsed(seasonQuery)).data + }.flatten() + + return newTvSeriesLoadResponse( + data.title ?: "", + url, + TvType.TvSeries, + episodes.mapNotNull { + Episode( + LinkData( + it.tid ?: it.id ?: return@mapNotNull null, + ResponseTypes.Series.value, + it.season, + it.episode, + data.id, + data.imdbId + ).toJson(), + it.title, + it.season, + it.episode, + it.thumbs ?: it.thumbsBak ?: it.thumbsMin ?: it.thumbsOriginal + ?: it.thumbsOrg, + it.imdbRating?.toDoubleOrNull()?.times(10)?.roundToInt(), + it.synopsis, + it.releasedTimestamp + ) + } + ) { + this.year = data.year + this.plot = data.description + this.posterUrl = data.posterOrg ?: data.poster + this.rating = data.imdbRating?.split("/")?.get(0)?.toIntOrNull() + this.tags = data.cats?.split(",")?.map { it.capitalize() } + this.addImdbId(data.imdbId) + } + } + } + + private data class LinkData( + val id: Int, + val type: Int, + val season: Int?, + val episode: Int?, + val mediaId: Int?, + val imdbId: String?, + ) + + data class LinkDataProp( + @JsonProperty("code") val code: Int? = null, + @JsonProperty("msg") val msg: String? = null, + @JsonProperty("data") val data: ParsedLinkData? = ParsedLinkData() + ) + + data class LinkList( + @JsonProperty("path") val path: String? = null, + @JsonProperty("quality") val quality: String? = null, + @JsonProperty("real_quality") val realQuality: String? = null, + @JsonProperty("format") val format: String? = null, + @JsonProperty("size") val size: String? = null, + @JsonProperty("size_bytes") val sizeBytes: Long? = null, + @JsonProperty("count") val count: Int? = null, + @JsonProperty("dateline") val dateline: Long? = null, + @JsonProperty("fid") val fid: Int? = null, + @JsonProperty("mmfid") val mmfid: Int? = null, + @JsonProperty("h265") val h265: Int? = null, + @JsonProperty("hdr") val hdr: Int? = null, + @JsonProperty("filename") val filename: String? = null, + @JsonProperty("original") val original: Int? = null, + @JsonProperty("colorbit") val colorbit: Int? = null, + @JsonProperty("success") val success: Int? = null, + @JsonProperty("timeout") val timeout: Int? = null, + @JsonProperty("vip_link") val vipLink: Int? = null, + @JsonProperty("fps") val fps: Int? = null, + @JsonProperty("bitstream") val bitstream: String? = null, + @JsonProperty("width") val width: Int? = null, + @JsonProperty("height") val height: Int? = null + ) + + data class ParsedLinkData( + @JsonProperty("seconds") val seconds: Int? = null, + @JsonProperty("quality") val quality: ArrayList = arrayListOf(), + @JsonProperty("list") val list: ArrayList = arrayListOf() + ) + + data class SubtitleDataProp( + @JsonProperty("code") val code: Int? = null, + @JsonProperty("msg") val msg: String? = null, + @JsonProperty("data") val data: PrivateSubtitleData? = PrivateSubtitleData() + ) + + data class Subtitles( + @JsonProperty("sid") val sid: Int? = null, + @JsonProperty("mid") val mid: String? = null, + @JsonProperty("file_path") val filePath: String? = null, + @JsonProperty("lang") val lang: String? = null, + @JsonProperty("language") val language: String? = null, + @JsonProperty("delay") val delay: Int? = null, + @JsonProperty("point") val point: String? = null, + @JsonProperty("order") val order: Int? = null, + @JsonProperty("support_total") val support_total: Int? = null, + @JsonProperty("admin_order") val adminOrder: Int? = null, + @JsonProperty("myselect") val myselect: Int? = null, + @JsonProperty("add_time") val addTime: Long? = null, + @JsonProperty("count") val count: Int? = null + ) + + data class SubtitleList( + @JsonProperty("language") val language: String? = null, + @JsonProperty("subtitles") val subtitles: ArrayList = arrayListOf() + ) + + data class PrivateSubtitleData( + @JsonProperty("select") val select: ArrayList = arrayListOf(), + @JsonProperty("list") val list: ArrayList = arrayListOf() + ) + + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + + val parsed = parseJson(data) + + argamap( + { + invokeVidsrcto( + parsed.imdbId, + parsed.season, + parsed.episode, + subtitleCallback + ) + }, + { + invokeExternalSource( + parsed.mediaId, + parsed.type, + parsed.season, + parsed.episode, + callback + ) + }, + { + invokeInternalSource( + parsed.id, + parsed.type, + parsed.season, + parsed.episode, + subtitleCallback, + callback + ) + }, + { + invokeOpenSubs( + parsed.imdbId, + parsed.season, + parsed.episode, + subtitleCallback + ) + }, + { + invokeWatchsomuch( + parsed.imdbId, + parsed.season, + parsed.episode, + subtitleCallback + ) + } + ) + + return true + } + + data class ExternalResponse( + @JsonProperty("data") val data: Data? = null, + ) { + data class Data( + @JsonProperty("link") val link: String? = null, + @JsonProperty("file_list") val file_list: ArrayList? = arrayListOf(), + ) { + data class FileList( + @JsonProperty("fid") val fid: Long? = null, + @JsonProperty("file_name") val file_name: String? = null, + @JsonProperty("oss_fid") val oss_fid: Long? = null, + ) + } + } + + data class ExternalSources( + @JsonProperty("m3u8_url") val m3u8_url: String? = null, + @JsonProperty("file") val file: String? = null, + @JsonProperty("label") val label: String? = null, + @JsonProperty("type") val type: String? = null, + ) + + data class WatchsomuchTorrents( + @JsonProperty("id") val id: Int? = null, + @JsonProperty("movieId") val movieId: Int? = null, + @JsonProperty("season") val season: Int? = null, + @JsonProperty("episode") val episode: Int? = null, + ) + + data class WatchsomuchMovies( + @JsonProperty("torrents") val torrents: ArrayList? = arrayListOf(), + ) + + data class WatchsomuchResponses( + @JsonProperty("movie") val movie: WatchsomuchMovies? = null, + ) + + data class WatchsomuchSubtitles( + @JsonProperty("url") val url: String? = null, + @JsonProperty("label") val label: String? = null, + ) + + data class WatchsomuchSubResponses( + @JsonProperty("subtitles") val subtitles: ArrayList? = arrayListOf(), + ) + + data class OsSubtitles( + @JsonProperty("url") val url: String? = null, + @JsonProperty("lang") val lang: String? = null, + ) + + data class OsResult( + @JsonProperty("subtitles") val subtitles: ArrayList? = arrayListOf(), + ) + + data class VidsrcSubtitles( + @JsonProperty("label") val label: String? = null, + @JsonProperty("file") val file: String? = null, + ) + +} + diff --git a/Superstream/src/main/kotlin/com/hexated/SuperstreamPlugin.kt b/Superstream/src/main/kotlin/com/hexated/SuperstreamPlugin.kt new file mode 100644 index 0000000..099210f --- /dev/null +++ b/Superstream/src/main/kotlin/com/hexated/SuperstreamPlugin.kt @@ -0,0 +1,14 @@ + +package com.hexated + +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin +import com.lagradost.cloudstream3.plugins.Plugin +import android.content.Context + +@CloudstreamPlugin +class SuperstreamPlugin: Plugin() { + override fun load(context: Context) { + // All providers should be added in this manner. Please don't edit the providers list directly. + registerMainAPI(Superstream()) + } +} \ No newline at end of file diff --git a/ZoroTV/TODO b/ZoroTV/TODO new file mode 100644 index 0000000..e69de29 diff --git a/ZoroTV/zoro.kt b/ZoroTV/zoro.kt new file mode 100644 index 0000000..8216c7d --- /dev/null +++ b/ZoroTV/zoro.kt @@ -0,0 +1,366 @@ +package com.lagradost.cloudstream3.animeproviders + +import android.util.Log +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.module.kotlin.readValue +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.LoadResponse.Companion.addAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.addMalId +import com.lagradost.cloudstream3.movieproviders.SflixProvider.Companion.extractRabbitStream +import com.lagradost.cloudstream3.movieproviders.SflixProvider.Companion.runSflixExtractorVerifierJob +import com.lagradost.cloudstream3.network.Requests.Companion.await +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.loadExtractor +import okhttp3.Interceptor +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import java.net.URI + +private const val OPTIONS = "OPTIONS" + +class ZoroProvider : MainAPI() { + override var mainUrl = "https://zoro.to" + override var name = "Zoro" + override val hasQuickSearch = false + override val hasMainPage = true + override val hasChromecastSupport = true + override val hasDownloadSupport = true + override val usesWebView = true + + override val supportedTypes = setOf( + TvType.Anime, + TvType.AnimeMovie, + TvType.OVA + ) + + companion object { + fun getType(t: String): TvType { + return if (t.contains("OVA") || t.contains("Special")) TvType.OVA + else if (t.contains("Movie")) TvType.AnimeMovie + else TvType.Anime + } + + fun getStatus(t: String): ShowStatus { + return when (t) { + "Finished Airing" -> ShowStatus.Completed + "Currently Airing" -> ShowStatus.Ongoing + else -> ShowStatus.Completed + } + } + } + + val epRegex = Regex("Ep (\\d+)/") + private fun Element.toSearchResult(): SearchResponse? { + val href = fixUrl(this.select("a").attr("href")) + val title = this.select("h3.film-name").text() + val dubSub = this.select(".film-poster > .tick.ltr").text() + //val episodes = this.selectFirst(".film-poster > .tick-eps")?.text()?.toIntOrNull() + + val dubExist = dubSub.contains("dub", ignoreCase = true) + val subExist = dubSub.contains("sub", ignoreCase = true) + val episodes = this.selectFirst(".film-poster > .tick.rtl > .tick-eps")?.text()?.let { eps -> + //println("REGEX:::: $eps") + // current episode / max episode + //Regex("Ep (\\d+)/(\\d+)") + epRegex.find(eps)?.groupValues?.get(1)?.toIntOrNull() + } + if (href.contains("/news/") || title.trim().equals("News", ignoreCase = true)) return null + val posterUrl = fixUrl(this.select("img").attr("data-src")) + val type = getType(this.select("div.fd-infor > span.fdi-item").text()) + + return newAnimeSearchResponse(title, href, type) { + this.posterUrl = posterUrl + addDubStatus(dubExist, subExist, episodes, episodes) + } + } + + override suspend fun getMainPage(): HomePageResponse { + val html = app.get("$mainUrl/home").text + val document = Jsoup.parse(html) + + val homePageList = ArrayList() + + document.select("div.anif-block").forEach { block -> + val header = block.select("div.anif-block-header").text().trim() + val animes = block.select("li").mapNotNull { + it.toSearchResult() + } + if (animes.isNotEmpty()) homePageList.add(HomePageList(header, animes)) + } + + document.select("section.block_area.block_area_home").forEach { block -> + val header = block.select("h2.cat-heading").text().trim() + val animes = block.select("div.flw-item").mapNotNull { + it.toSearchResult() + } + if (animes.isNotEmpty()) homePageList.add(HomePageList(header, animes)) + } + + return HomePageResponse(homePageList) + } + + private data class Response( + @JsonProperty("status") val status: Boolean, + @JsonProperty("html") val html: String + ) + +// override suspend fun quickSearch(query: String): List { +// val url = "$mainUrl/ajax/search/suggest?keyword=${query}" +// val html = mapper.readValue(khttp.get(url).text).html +// val document = Jsoup.parse(html) +// +// return document.select("a.nav-item").map { +// val title = it.selectFirst(".film-name")?.text().toString() +// val href = fixUrl(it.attr("href")) +// val year = it.selectFirst(".film-infor > span")?.text()?.split(",")?.get(1)?.trim()?.toIntOrNull() +// val image = it.select("img").attr("data-src") +// +// AnimeSearchResponse( +// title, +// href, +// this.name, +// TvType.TvSeries, +// image, +// year, +// null, +// EnumSet.of(DubStatus.Subbed), +// null, +// null +// ) +// +// } +// } + + override suspend fun search(query: String): List { + val link = "$mainUrl/search?keyword=$query" + val html = app.get(link).text + val document = Jsoup.parse(html) + + return document.select(".flw-item").map { + val title = it.selectFirst(".film-detail > .film-name > a")?.attr("title").toString() + val filmPoster = it.selectFirst(".film-poster") + val poster = filmPoster.selectFirst("img")?.attr("data-src") + + val episodes = filmPoster.selectFirst("div.rtl > div.tick-eps")?.text()?.let { eps -> + // current episode / max episode + val epRegex = Regex("Ep (\\d+)/")//Regex("Ep (\\d+)/(\\d+)") + epRegex.find(eps)?.groupValues?.get(1)?.toIntOrNull() + } + val dubsub = filmPoster.selectFirst("div.ltr")?.text() + val dubExist = dubsub?.contains("DUB") ?: false + val subExist = dubsub?.contains("SUB") ?: false || dubsub?.contains("RAW") ?: false + + val tvType = + getType(it.selectFirst(".film-detail > .fd-infor > .fdi-item")?.text().toString()) + val href = fixUrl(it.selectFirst(".film-name a").attr("href")) + + newAnimeSearchResponse(title, href, tvType) { + this.posterUrl = poster + addDubStatus(dubExist, subExist, episodes, episodes) + } + } + } + + private fun Element?.getActor(): Actor? { + val image = + fixUrlNull(this?.selectFirst(".pi-avatar > img")?.attr("data-src")) ?: return null + val name = this?.selectFirst(".pi-detail > .pi-name")?.text() ?: return null + return Actor(name = name, image = image) + } + + data class ZoroSyncData( + @JsonProperty("mal_id") val malId: String?, + @JsonProperty("anilist_id") val aniListId: String?, + ) + + override suspend fun load(url: String): LoadResponse { + val html = app.get(url).text + val document = Jsoup.parse(html) + + val syncData = tryParseJson(document.selectFirst("#syncData")?.data()) + + val title = document.selectFirst(".anisc-detail > .film-name")?.text().toString() + val poster = document.selectFirst(".anisc-poster img")?.attr("src") + val tags = document.select(".anisc-info a[href*=\"/genre/\"]").map { it.text() } + + var year: Int? = null + var japaneseTitle: String? = null + var status: ShowStatus? = null + + for (info in document.select(".anisc-info > .item.item-title")) { + val text = info?.text().toString() + when { + (year != null && japaneseTitle != null && status != null) -> break + text.contains("Premiered") && year == null -> + year = + info.selectFirst(".name")?.text().toString().split(" ").last().toIntOrNull() + + text.contains("Japanese") && japaneseTitle == null -> + japaneseTitle = info.selectFirst(".name")?.text().toString() + + text.contains("Status") && status == null -> + status = getStatus(info.selectFirst(".name")?.text().toString()) + } + } + + val description = document.selectFirst(".film-description.m-hide > .text")?.text() + val animeId = URI(url).path.split("-").last() + + val episodes = Jsoup.parse( + mapper.readValue( + app.get( + "$mainUrl/ajax/v2/episode/list/$animeId" + ).text + ).html + ).select(".ss-list > a[href].ssl-item.ep-item").map { + newEpisode(it.attr("href")) { + this.name = it?.attr("title") + this.episode = it.selectFirst(".ssli-order")?.text()?.toIntOrNull() + } + } + + val actors = document.select("div.block-actors-content > div.bac-list-wrap > div.bac-item") + ?.mapNotNull { head -> + val subItems = head.select(".per-info") ?: return@mapNotNull null + if (subItems.isEmpty()) return@mapNotNull null + var role: ActorRole? = null + val mainActor = subItems.first()?.let { + role = when (it.selectFirst(".pi-detail > .pi-cast")?.text()?.trim()) { + "Supporting" -> ActorRole.Supporting + "Main" -> ActorRole.Main + else -> null + } + it.getActor() + } ?: return@mapNotNull null + val voiceActor = if (subItems.size >= 2) subItems[1]?.getActor() else null + ActorData(actor = mainActor, role = role, voiceActor = voiceActor) + } + + val recommendations = + document.select("#main-content > section > .tab-content > div > .film_list-wrap > .flw-item") + .mapNotNull { head -> + val filmPoster = head?.selectFirst(".film-poster") + val epPoster = filmPoster?.selectFirst("img")?.attr("data-src") + val a = head?.selectFirst(".film-detail > .film-name > a") + val epHref = a?.attr("href") + val epTitle = a?.attr("title") + if (epHref == null || epTitle == null || epPoster == null) { + null + } else { + AnimeSearchResponse( + epTitle, + fixUrl(epHref), + this.name, + TvType.Anime, + epPoster, + dubStatus = null + ) + } + } + + return newAnimeLoadResponse(title, url, TvType.Anime) { + japName = japaneseTitle + engName = title + posterUrl = poster + this.year = year + addEpisodes(DubStatus.Subbed, episodes) + showStatus = status + plot = description + this.tags = tags + this.recommendations = recommendations + this.actors = actors + addMalId(syncData?.malId?.toIntOrNull()) + addAniListId(syncData?.aniListId?.toIntOrNull()) + } + } + + private data class RapidCloudResponse( + @JsonProperty("link") val link: String + ) + + override suspend fun extractorVerifierJob(extractorData: String?) { + Log.d(this.name, "Starting ${this.name} job!") + runSflixExtractorVerifierJob(this, extractorData, "https://rapid-cloud.ru/") + } + + /** Url hashcode to sid */ + var sid: HashMap = hashMapOf() + + /** + * Makes an identical Options request before .ts request + * Adds an SID header to the .ts request. + * */ + override fun getVideoInterceptor(extractorLink: ExtractorLink): Interceptor { + return Interceptor { chain -> + val request = chain.request() + if (request.url.toString().endsWith(".ts") + && request.method != OPTIONS + // No option requests on VidCloud + && !request.url.toString().contains("betterstream") + ) { + val newRequest = + chain.request() + .newBuilder().apply { + sid[extractorLink.url.hashCode()]?.let { sid -> + addHeader("SID", sid) + } + } + .build() + val options = request.newBuilder().method(OPTIONS, request.body).build() + ioSafe { app.baseClient.newCall(options).await() } + + return@Interceptor chain.proceed(newRequest) + } else { + return@Interceptor chain.proceed(chain.request()) + } + } + } + + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + + val servers: List> = Jsoup.parse( + app.get("$mainUrl/ajax/v2/episode/servers?episodeId=" + data.split("=")[1]) + .mapped().html + ).select(".server-item[data-type][data-id]").map { + Pair( + if (it.attr("data-type") == "sub") DubStatus.Subbed else DubStatus.Dubbed, + it.attr("data-id")!! + ) + } + + val extractorData = + "https://ws1.rapid-cloud.ru/socket.io/?EIO=4&transport=polling" + + // Prevent duplicates + servers.distinctBy { it.second }.apmap { + val link = + "$mainUrl/ajax/v2/episode/sources?id=${it.second}" + val extractorLink = app.get( + link, + ).mapped().link + val hasLoadedExtractorLink = + loadExtractor(extractorLink, "https://rapid-cloud.ru/", callback) + + if (!hasLoadedExtractorLink) { + extractRabbitStream( + extractorLink, + subtitleCallback, + // Blacklist VidCloud for now + { videoLink -> if (!videoLink.url.contains("betterstream")) callback(videoLink) }, + extractorData + ) { sourceName -> + sourceName + " - ${it.first}" + } + } + } + + return true + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index ef029d3..b6e4304 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -74,10 +74,11 @@ subprojects { // but you dont need to include any of them if you dont need them // https://github.com/recloudstream/cloudstream/blob/master/app/build.gradle implementation(kotlin("stdlib")) // adds standard kotlin features - implementation("com.github.Blatzar:NiceHttp:0.4.4") // http library - implementation("org.jsoup:jsoup:1.16.2") // html parser + implementation("com.github.Blatzar:NiceHttp:0.4.11") // http library + implementation("org.jsoup:jsoup:1.17.2") // html parser implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.16.0") implementation("com.fasterxml.jackson.core:jackson-databind:2.16.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") } } diff --git a/repo.json b/repo.json new file mode 100644 index 0000000..6b68e7f --- /dev/null +++ b/repo.json @@ -0,0 +1,8 @@ +{ + "name": "mnemosyne", + "description": "Custom extensions for CloudStream", + "manifestVersion": 1, + "pluginLists": [ + "https://raw.githubusercontent.com/swisskyrepo/TestPlugins/builds/plugins.json" + ] +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 43f715c..4cf461f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,9 +1,25 @@ rootProject.name = "CloudstreamPlugins" -// This file sets what projects are included. Every time you add a new project, you must add it -// to the includes below. +// This file sets what projects are included. All new projects should get automatically included unless specified in "disabled" variable. -// Plugins are included like this +val disabled = listOf() + +File(rootDir, ".").eachDir { dir -> + if (!disabled.contains(dir.name) && File(dir, "build.gradle.kts").exists()) { + include(dir.name) + } +} + +fun File.eachDir(block: (File) -> Unit) { + listFiles()?.filter { it.isDirectory }?.forEach { block(it) } +} + + +// To only include a single project, comment out the previous lines (except the first one), and include your plugin like so: +/* include( - "ExampleProvider" + "Sflix", + "HiAnime", + "Anywave" ) +*/ \ No newline at end of file