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 + } +}