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 0000000..639af03 Binary files /dev/null and b/SoraStream/Icon.png differ diff --git a/SoraStream/build.gradle.kts.todo b/SoraStream/build.gradle.kts.todo new file mode 100644 index 0000000..a1442e3 --- /dev/null +++ b/SoraStream/build.gradle.kts.todo @@ -0,0 +1,46 @@ +import org.jetbrains.kotlin.konan.properties.Properties + +// use an integer for version numbers +version = 228 + +android { + defaultConfig { + val properties = Properties() + properties.load(project.rootProject.file("local.properties").inputStream()) + + buildConfigField("String", "TMDB_API", "\"${properties.getProperty("TMDB_API")}\"") + buildConfigField("String", "GHOSTX_API", "\"${properties.getProperty("GHOSTX_API")}\"") + buildConfigField("String", "CINEMATV_API", "\"${properties.getProperty("CINEMATV_API")}\"") + buildConfigField("String", "SFMOVIES_API", "\"${properties.getProperty("SFMOVIES_API")}\"") + buildConfigField("String", "ZSHOW_API", "\"${properties.getProperty("ZSHOW_API")}\"") + buildConfigField("String", "DUMP_API", "\"${properties.getProperty("DUMP_API")}\"") + buildConfigField("String", "DUMP_KEY", "\"${properties.getProperty("DUMP_KEY")}\"") + buildConfigField("String", "CRUNCHYROLL_BASIC_TOKEN", "\"${properties.getProperty("CRUNCHYROLL_BASIC_TOKEN")}\"") + buildConfigField("String", "CRUNCHYROLL_REFRESH_TOKEN", "\"${properties.getProperty("CRUNCHYROLL_REFRESH_TOKEN")}\"") + } +} + +cloudstream { + language = "en" + // All of these properties are optional, you can safely remove them + + description = "#1 best extention based on MultiAPI" + authors = listOf("Hexated", "Sora") + + /** + * Status int as the following: + * 0: Down + * 1: Ok + * 2: Slow + * 3: Beta only + * */ + status = 1 // will be 3 if unspecified + tvTypes = listOf( + "AsianDrama", + "TvSeries", + "Anime", + "Movie", + ) + + iconUrl = "https://cdn.discordapp.com/attachments/1109266606292488297/1193122096159674448/2-modified.png?ex=65ec2a0a&is=65d9b50a&hm=f1e0b0165e71101e5440b47592d9e15727a6c00cdeb3512108067bfbdbef1af7&" +} diff --git a/SoraStream/ci.yml b/SoraStream/ci.yml new file mode 100644 index 0000000..00d5691 --- /dev/null +++ b/SoraStream/ci.yml @@ -0,0 +1,103 @@ +name: Build + +# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency +concurrency: + group: "build" + cancel-in-progress: true + +on: + push: + branches: + # choose your default branch + - master + - main + paths-ignore: + - '*.md' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + path: "src" + + - name: Checkout builds + uses: actions/checkout@v4 + with: + ref: "builds" + path: "builds" + + - name: Clean old builds + run: rm $GITHUB_WORKSPACE/builds/*.cs3 || true + + - name: Setup JDK 11 + uses: actions/setup-java@v4 + with: + distribution: "adopt" + java-version: 11 + + - 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 }} + ANICHI_API: ${{ secrets.ANICHI_API }} + ANICHI_SERVER: ${{ secrets.ANICHI_SERVER }} + ANICHI_ENDPOINT: ${{ secrets.ANICHI_ENDPOINT }} + ANICHI_APP: ${{ secrets.ANICHI_APP }} + ZSHOW_API: ${{ secrets.ZSHOW_API }} + 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 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