diff --git a/SflixProvider/build.gradle.kts b/SflixProvider/build.gradle.kts
new file mode 100644
index 0000000..869a21a
--- /dev/null
+++ b/SflixProvider/build.gradle.kts
@@ -0,0 +1,26 @@
+// use an integer for version numbers
+version = 2
+
+
+cloudstream {
+ language = "en"
+ // All of these properties are optional, you can safely remove them
+
+ description = "Also includes Dopebox, Solarmovie, Zoro and 2embed"
+ // authors = listOf("Cloudburst")
+
+ /**
+ * Status int as the following:
+ * 0: Down
+ * 1: Ok
+ * 2: Slow
+ * 3: Beta only
+ * */
+ status = 1 // will be 3 if unspecified
+ tvTypes = listOf(
+ "TvSeries",
+ "Movie",
+ )
+
+ iconUrl = "https://www.google.com/s2/favicons?domain=www.2embed.to&sz=%size%"
+}
\ No newline at end of file
diff --git a/SflixProvider/src/main/AndroidManifest.xml b/SflixProvider/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..29aec9d
--- /dev/null
+++ b/SflixProvider/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/SflixProvider/src/main/kotlin/com/lagradost/DopeboxProvider.kt b/SflixProvider/src/main/kotlin/com/lagradost/DopeboxProvider.kt
new file mode 100644
index 0000000..fbcb65e
--- /dev/null
+++ b/SflixProvider/src/main/kotlin/com/lagradost/DopeboxProvider.kt
@@ -0,0 +1,6 @@
+package com.lagradost
+
+class DopeboxProvider : SflixProvider() {
+ override var mainUrl = "https://dopebox.to"
+ override var name = "Dopebox"
+}
\ No newline at end of file
diff --git a/SflixProvider/src/main/kotlin/com/lagradost/HDTodayProvider.kt b/SflixProvider/src/main/kotlin/com/lagradost/HDTodayProvider.kt
new file mode 100644
index 0000000..62b788e
--- /dev/null
+++ b/SflixProvider/src/main/kotlin/com/lagradost/HDTodayProvider.kt
@@ -0,0 +1,6 @@
+package com.lagradost
+
+class HDTodayProvider : SflixProvider() {
+ override var mainUrl = "https://hdtoday.cc"
+ override var name = "HDToday"
+}
diff --git a/SflixProvider/src/main/kotlin/com/lagradost/SflixProvider.kt b/SflixProvider/src/main/kotlin/com/lagradost/SflixProvider.kt
new file mode 100644
index 0000000..965bf48
--- /dev/null
+++ b/SflixProvider/src/main/kotlin/com/lagradost/SflixProvider.kt
@@ -0,0 +1,768 @@
+package com.lagradost
+
+import android.util.Log
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.lagradost.cloudstream3.*
+import com.lagradost.cloudstream3.APIHolder.getCaptchaToken
+import com.lagradost.cloudstream3.APIHolder.unixTimeMS
+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.animeproviders.ZoroProvider
+import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
+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 kotlinx.coroutines.delay
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Element
+import java.net.URI
+import java.util.*
+import kotlin.system.measureTimeMillis
+
+open class SflixProvider : 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)
+ }
+
+ private data class TmdbProviderSearchFilter(
+ @JsonProperty("title") val title: String,
+ @JsonProperty("tmdbYear") val tmdbYear: Int?,
+ @JsonProperty("tmdbPlot") val tmdbPlot: String?,
+ @JsonProperty("duration") val duration: Int?,
+ @JsonProperty("type") val type: TvType?,
+ )
+
+
+ override suspend fun search(query: String): List {
+ val parsedFilter = tryParseJson(query)
+ val searchedTitle = parsedFilter?.title ?: throw ErrorLoadingException()
+ val url = "$mainUrl/search/${searchedTitle.replace(" ", "-")}"
+ val html = app.get(url).text
+ val document = Jsoup.parse(html)
+
+ val output = document.select("div.flw-item").mapNotNull {
+ val title = it.select("h2.film-name").text()
+ val href = fixUrl(it.select("a").attr("href"))
+ val year = it.selectFirst("span.fdi-item:not(:has(i)):not(:has(strong))")?.text()?.toIntOrNull()
+ if (year != parsedFilter.tmdbYear) {
+ return@mapNotNull null
+ }
+
+ 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) {
+ MovieSearchResponse(
+ title,
+ href,
+ this.name,
+ TvType.Movie,
+ image,
+ year,
+ quality = quality
+ )
+ } else {
+ if (!isMovie) {
+ TvSeriesSearchResponse(
+ title,
+ href,
+ this.name,
+ TvType.TvSeries,
+ image,
+ year,
+ null,
+ quality = quality
+ )
+ } else {
+ null
+ }
+ }
+ }
+ return output
+ }
+
+
+ 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")
+ MovieSearchResponse(
+ recTitle,
+ recUrl,
+ this.name,
+ if (recUrl.contains("/movie/")) TvType.Movie else TvType.TvSeries,
+ poster,
+ year = null
+ )
+ }
+
+ 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")
+ if (sourceId.isNullOrEmpty())
+ sourceId = element.attr("data-linkid")
+
+ if (element.select("span").text().trim().isValidServer()) {
+ if (sourceId.isNullOrEmpty()) {
+ fixUrlNull(element.attr("href"))
+ } else {
+ "$url.$sourceId".replace("/movie/", "/watch-movie/")
+ }
+ } 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?,
+ @JsonProperty("sources_1") val sources1: List?,
+ @JsonProperty("sources_2") val sources2: List?,
+ @JsonProperty("sourcesBackup") val sourcesBackup: List?,
+ @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
+ if (element.select("span").text().trim().isValidServer()) {
+ "$prefix.$id".replace("/tv/", "/watch-tv/")
+ } else {
+ null
+ }
+ }
+ } ?: tryParseJson>(data))?.distinct()
+
+ urls?.apmap { url ->
+ 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(".")
+ 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"
+
+ if (iframeLink.contains("streamlare", ignoreCase = true)) {
+ loadExtractor(iframeLink, null, subtitleCallback, callback)
+ } else {
+ extractRabbitStream(iframeLink, subtitleCallback, callback, false) { 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) {
+ MovieSearchResponse(
+ title,
+ href,
+ this@SflixProvider.name,
+ TvType.Movie,
+ posterUrl = posterUrl,
+ year = year,
+ quality = quality,
+ )
+ } else {
+ TvSeriesSearchResponse(
+ title,
+ href,
+ this@SflixProvider.name,
+ TvType.Movie,
+ posterUrl,
+ year = year,
+ episodes = null,
+ 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()
+ }
+
+
+ /**
+ * 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(
+ api: MainAPI,
+ 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}") }
+ Log.d(api.name, "Running ${api.name} job $url")
+
+ 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
+ )
+ }
+ } ?: 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
+ )
+ }
+ }
+
+ suspend fun MainAPI.extractRabbitStream(
+ url: String,
+ subtitleCallback: (SubtitleFile) -> Unit,
+ callback: (ExtractorLink) -> Unit,
+ useSidAuthentication: Boolean,
+ /** Used for extractorLink name, input: Source name */
+ extractorData: 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 mapped = app.get(
+ "${
+ mainIframeUrl.replace(
+ "/embed",
+ "/ajax/embed"
+ )
+ }/getSources?id=$mainIframeId&_token=$iframeToken&_number=$number${sid?.let { "$&sId=$it" } ?: ""}",
+ referer = mainUrl,
+ headers = mapOf(
+ "X-Requested-With" to "XMLHttpRequest",
+ "Accept" to "*/*",
+ "Accept-Language" to "en-US,en;q=0.5",
+// "Cache-Control" to "no-cache",
+ "Connection" to "keep-alive",
+// "Sec-Fetch-Dest" to "empty",
+// "Sec-Fetch-Mode" to "no-cors",
+// "Sec-Fetch-Site" to "cross-site",
+// "Pragma" to "no-cache",
+// "Cache-Control" to "no-cache",
+ "TE" to "trailers"
+ )
+ ).parsed()
+
+ mapped.tracks?.forEach { track ->
+ track?.toSubtitleFile()?.let { subtitleFile ->
+ subtitleCallback.invoke(subtitleFile)
+ }
+ }
+
+ val list = listOf(
+ mapped.sources to "source 1",
+ mapped.sources1 to "source 2",
+ mapped.sources2 to "source 3",
+ mapped.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/SflixProvider/src/main/kotlin/com/lagradost/SflixProviderPlugin.kt b/SflixProvider/src/main/kotlin/com/lagradost/SflixProviderPlugin.kt
new file mode 100644
index 0000000..17c721a
--- /dev/null
+++ b/SflixProvider/src/main/kotlin/com/lagradost/SflixProviderPlugin.kt
@@ -0,0 +1,18 @@
+package com.lagradost
+
+import com.lagradost.cloudstream3.plugins.CloudstreamPlugin
+import com.lagradost.cloudstream3.plugins.Plugin
+import android.content.Context
+
+@CloudstreamPlugin
+class SflixProviderPlugin : Plugin() {
+ override fun load(context: Context) {
+ // All providers should be added in this manner. Please don't edit the providers list directly.
+ registerMainAPI(SflixProvider())
+ registerMainAPI(SolarmovieProvider())
+ registerMainAPI(TwoEmbedProvider())
+ registerMainAPI(DopeboxProvider())
+ registerMainAPI(ZoroProvider())
+ registerMainAPI(HDTodayProvider())
+ }
+}
\ No newline at end of file
diff --git a/SflixProvider/src/main/kotlin/com/lagradost/SolarmovieProvider.kt b/SflixProvider/src/main/kotlin/com/lagradost/SolarmovieProvider.kt
new file mode 100644
index 0000000..dbd827a
--- /dev/null
+++ b/SflixProvider/src/main/kotlin/com/lagradost/SolarmovieProvider.kt
@@ -0,0 +1,6 @@
+package com.lagradost
+
+class SolarmovieProvider : SflixProvider() {
+ override var mainUrl = "https://solarmovie.pe"
+ override var name = "Solarmovie"
+}
\ No newline at end of file
diff --git a/SflixProvider/src/main/kotlin/com/lagradost/TwoEmbedProvider.kt b/SflixProvider/src/main/kotlin/com/lagradost/TwoEmbedProvider.kt
new file mode 100644
index 0000000..a97887d
--- /dev/null
+++ b/SflixProvider/src/main/kotlin/com/lagradost/TwoEmbedProvider.kt
@@ -0,0 +1,79 @@
+package com.lagradost
+
+import android.util.Log
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.lagradost.SflixProvider.Companion.extractRabbitStream
+import com.lagradost.SflixProvider.Companion.runSflixExtractorVerifierJob
+import com.lagradost.cloudstream3.APIHolder.getCaptchaToken
+import com.lagradost.cloudstream3.SubtitleFile
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.apmap
+import com.lagradost.cloudstream3.app
+import com.lagradost.cloudstream3.metaproviders.TmdbLink
+import com.lagradost.cloudstream3.metaproviders.TmdbProvider
+import com.lagradost.cloudstream3.utils.AppUtils.parseJson
+import com.lagradost.cloudstream3.utils.ExtractorLink
+import com.lagradost.cloudstream3.utils.loadExtractor
+
+class TwoEmbedProvider : TmdbProvider() {
+ override val apiName = "2Embed"
+ override var name = "2Embed"
+ override var mainUrl = "https://www.2embed.to"
+ override val useMetaLoadResponse = true
+ override val instantLinkLoading = false
+ override val supportedTypes = setOf(
+ TvType.Movie,
+ TvType.TvSeries,
+ )
+
+ data class EmbedJson (
+ @JsonProperty("type") val type: String?,
+ @JsonProperty("link") val link: String,
+ @JsonProperty("sources") val sources: List,
+ @JsonProperty("tracks") val tracks: List?
+ )
+
+ override suspend fun loadLinks(
+ data: String,
+ isCasting: Boolean,
+ subtitleCallback: (SubtitleFile) -> Unit,
+ callback: (ExtractorLink) -> Unit
+ ): Boolean {
+ val mappedData = parseJson(data)
+ val (id, site) = if (mappedData.imdbID != null) listOf(
+ mappedData.imdbID,
+ "imdb"
+ ) else listOf(mappedData.tmdbID.toString(), "tmdb")
+ val isMovie = mappedData.episode == null && mappedData.season == null
+ val embedUrl = if (isMovie) {
+ "$mainUrl/embed/$site/movie?id=$id"
+ } else {
+ val suffix = "$id&s=${mappedData.season ?: 1}&e=${mappedData.episode ?: 1}"
+ "$mainUrl/embed/$site/tv?id=$suffix"
+ }
+
+ val document = app.get(embedUrl).document
+ val captchaKey =
+ document.select("script[src*=https://www.google.com/recaptcha/api.js?render=]")
+ .attr("src").substringAfter("render=")
+
+ val servers = document.select(".dropdown-menu a[data-id]").map { it.attr("data-id") }
+ servers.apmap { serverID ->
+ val token = getCaptchaToken(embedUrl, captchaKey)
+ val ajax = app.get("$mainUrl/ajax/embed/play?id=$serverID&_token=$token", referer = embedUrl).text
+ val mappedservers = parseJson(ajax)
+ val iframeLink = mappedservers.link
+ if (iframeLink.contains("rabbitstream")) {
+ extractRabbitStream(iframeLink, subtitleCallback, callback, false) { it }
+ } else {
+ loadExtractor(iframeLink, embedUrl, subtitleCallback, callback)
+ }
+ }
+ return true
+ }
+
+ override suspend fun extractorVerifierJob(extractorData: String?) {
+ Log.d(this.name, "Starting ${this.name} job!")
+ runSflixExtractorVerifierJob(this, extractorData, "https://rabbitstream.net/")
+ }
+}
diff --git a/SflixProvider/src/main/kotlin/com/lagradost/ZoroProvider.kt b/SflixProvider/src/main/kotlin/com/lagradost/ZoroProvider.kt
new file mode 100644
index 0000000..7ff14a6
--- /dev/null
+++ b/SflixProvider/src/main/kotlin/com/lagradost/ZoroProvider.kt
@@ -0,0 +1,371 @@
+package com.lagradost
+
+import android.util.Log
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.lagradost.SflixProvider.Companion.extractRabbitStream
+import com.lagradost.SflixProvider.Companion.runSflixExtractorVerifierJob
+import com.lagradost.cloudstream3.*
+import com.lagradost.cloudstream3.LoadResponse.Companion.addAniListId
+import com.lagradost.cloudstream3.LoadResponse.Companion.addMalId
+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.loadExtractor
+import com.lagradost.nicehttp.Requests.Companion.await
+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(page: Int, request : MainPageRequest): 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(
+ parseJson(
+ 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 {
+ // 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())
+ }
+ }
+ }
+ }
+
+ 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])
+ .parsed().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,
+ ).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) },
+ true,
+ extractorData
+ ) { sourceName ->
+ sourceName + " - ${it.first}"
+ }
+ }
+ }
+
+ return true
+ }
+}