diff --git a/Crunchyroll/build.gradle.kts b/Crunchyroll/build.gradle.kts new file mode 100644 index 00000000..83f90d70 --- /dev/null +++ b/Crunchyroll/build.gradle.kts @@ -0,0 +1,22 @@ +// use an integer for version numbers +version = 5 + + +cloudstream { + language = "en" + // All of these properties are optional, you can safely remove them + + description = "The Crunchyroll provider allows you to watch all the shows that are on Crunchyroll." + authors = listOf("Sir Aguacata (KillerDogeEmpire)") + + /** + * Status int as the following: + * 0: Down + * 1: Ok + * 2: Slow + * 3: Beta only + * */ + status = 0 // will be 3 if unspecified + tvTypes = listOf("AnimeMovie", "Anime", "OVA") + iconUrl = "https://www.google.com/s2/favicons?domain=crunchyroll.com&sz=%size%" +} diff --git a/Crunchyroll/src/main/AndroidManifest.xml b/Crunchyroll/src/main/AndroidManifest.xml new file mode 100644 index 00000000..29aec9de --- /dev/null +++ b/Crunchyroll/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Crunchyroll/src/main/kotlin/com/lagradost/CookieInterceptor.kt b/Crunchyroll/src/main/kotlin/com/lagradost/CookieInterceptor.kt new file mode 100644 index 00000000..ff11972a --- /dev/null +++ b/Crunchyroll/src/main/kotlin/com/lagradost/CookieInterceptor.kt @@ -0,0 +1,46 @@ +package com.lagradost + +import com.lagradost.nicehttp.Requests +import okhttp3.* +import okhttp3.internal.parseCookie + +/** + * An HTTP session manager. + * + * This class simply keeps cookies across requests. No security about which site should use which cookies. + * + */ + +class CustomSession( + client: OkHttpClient +) : Requests() { + var cookies = mutableMapOf() + + init { + this.baseClient = client + .newBuilder() + .addInterceptor { + val time = System.currentTimeMillis() + val request = it.request() + request.headers.forEach { header -> + if (header.first.equals("cookie", ignoreCase = true)) { + val cookie = parseCookie(time, request.url, header.second) ?: return@forEach + cookies += cookie.name to cookie + } + } + it.proceed(request) + } + .cookieJar(CustomCookieJar()) + .build() + } + + inner class CustomCookieJar : CookieJar { + override fun loadForRequest(url: HttpUrl): List { + return this@CustomSession.cookies.values.toList() + } + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + this@CustomSession.cookies += cookies.map { it.name to it } + } + } +} \ No newline at end of file diff --git a/Crunchyroll/src/main/kotlin/com/lagradost/CrunchyrollProvider.kt b/Crunchyroll/src/main/kotlin/com/lagradost/CrunchyrollProvider.kt new file mode 100644 index 00000000..ab7a40da --- /dev/null +++ b/Crunchyroll/src/main/kotlin/com/lagradost/CrunchyrollProvider.kt @@ -0,0 +1,513 @@ +package com.lagradost + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.APIHolder.capitalize +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.getQualityFromName +import com.lagradost.nicehttp.NiceResponse +import kotlinx.coroutines.delay +import org.jsoup.Jsoup +import java.util.* + +private fun String.toAscii() = this.map { it.code }.joinToString() + +class KrunchyGeoBypasser { + companion object { + const val BYPASS_SERVER = "https://cr-unblocker.us.to/start_session" + val headers = mapOf( + "accept" to "*/*", +// "Accept-Encoding" to "gzip, deflate", + "connection" to "keep-alive", +// "Referer" to "https://google.com/", + "user-agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36".toAscii() + ) + var sessionId: String? = null + + // val interceptor = CookieInterceptor() + val session = CustomSession(app.baseClient) + } + + data class KrunchySession( + @JsonProperty("data") var data: DataInfo? = DataInfo(), + @JsonProperty("error") var error: Boolean? = null, + @JsonProperty("code") var code: String? = null + ) + + data class DataInfo( + @JsonProperty("session_id") var sessionId: String? = null, + @JsonProperty("country_code") var countryCode: String? = null, + ) + + private suspend fun getSessionId(): Boolean { + return try { + val response = app.get(BYPASS_SERVER, params = mapOf("version" to "1.1")).text + val json = parseJson(response) + sessionId = json.data?.sessionId + true + } catch (e: Exception) { + sessionId = null + false + } + } + + private suspend fun autoLoadSession(): Boolean { + if (sessionId != null) return true + getSessionId() + // Do not spam the api! + delay(3000) + return autoLoadSession() + } + + suspend fun geoBypassRequest(url: String): NiceResponse { + autoLoadSession() + return session.get(url, headers = headers, cookies = mapOf("session_id" to sessionId!!)) + } +} + +class KrunchyProvider : MainAPI() { + companion object { + val crUnblock = KrunchyGeoBypasser() + val episodeNumRegex = Regex("""Episode (\d+)""") + } + + // Do not make https! It will fail! + override var mainUrl = "http://www.crunchyroll.com" + override var name: String = "Crunchyroll" + override var lang = "en" + override val hasQuickSearch = false + override val hasMainPage = true + + override val supportedTypes = setOf( + TvType.AnimeMovie, + TvType.Anime, + TvType.OVA + ) + + override val mainPage = mainPageOf( + "$mainUrl/videos/anime/popular/ajax_page?pg=" to "Popular", + "$mainUrl/videos/anime/simulcasts/ajax_page" to "Simulcasts" + ) + + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + println("GETMAINPAGE ") + val categoryData = request.data + + val paginated = categoryData.endsWith("=") + val pagedLink = if (paginated) categoryData + page else categoryData + val items = mutableListOf() + + // Only fetch page at first-time load of homepage + if (page <= 1 && request.name == "Popular") { + val doc = Jsoup.parse(crUnblock.geoBypassRequest(mainUrl).text) + val featured = doc.select(".js-featured-show-list > li").mapNotNull { anime -> + val url = + fixUrlNull(anime?.selectFirst("a")?.attr("href")) ?: return@mapNotNull null + val imgEl = anime.selectFirst("img") + val name = imgEl?.attr("alt") ?: "" + val posterUrl = imgEl?.attr("src")?.replace("small", "full") + AnimeSearchResponse( + name = name, + url = url, + apiName = this.name, + type = TvType.Anime, + posterUrl = posterUrl, + dubStatus = EnumSet.of(DubStatus.Subbed) + ) + } + val recent = + doc.select("div.welcome-countdown-day:contains(Now Showing) li").mapNotNull { + val link = + fixUrlNull(it.selectFirst("a")?.attr("href")) ?: return@mapNotNull null + val name = it.selectFirst("span.welcome-countdown-name")?.text() ?: "" + val img = it.selectFirst("img")?.attr("src")?.replace("medium", "full") + val dubstat = if (name.contains("Dub)", true)) EnumSet.of(DubStatus.Dubbed) else + EnumSet.of(DubStatus.Subbed) + val details = it.selectFirst("span.welcome-countdown-details")?.text() + val epnum = + if (details.isNullOrBlank()) null else episodeNumRegex.find(details)?.value?.replace( + "Episode ", + "" + ) ?: "0" + val episodesMap = mutableMapOf() + episodesMap[DubStatus.Subbed] = epnum?.toIntOrNull() ?: 0 + episodesMap[DubStatus.Dubbed] = epnum?.toIntOrNull() ?: 0 + AnimeSearchResponse( + name = "★ $name ★", + url = link.replace(Regex("(\\/episode.*)"), ""), + apiName = this.name, + type = TvType.Anime, + posterUrl = fixUrlNull(img), + dubStatus = dubstat, + episodes = episodesMap + ) + } + if (recent.isNotEmpty()) { + items.add( + HomePageList( + name = "Now Showing", + list = recent, + ) + ) + } + if (featured.isNotEmpty()) { + items.add(HomePageList("Featured", featured)) + } + } + + if (paginated || !paginated && page <= 1) { + crUnblock.geoBypassRequest(pagedLink).let { respText -> + val soup = Jsoup.parse(respText.text) + + val episodes = soup.select("li").mapNotNull { + val innerA = it.selectFirst("a") ?: return@mapNotNull null + val urlEps = fixUrlNull(innerA.attr("href")) ?: return@mapNotNull null + AnimeSearchResponse( + name = innerA.attr("title"), + url = urlEps, + apiName = this.name, + type = TvType.Anime, + posterUrl = it.selectFirst("img")?.attr("src"), + dubStatus = EnumSet.of(DubStatus.Subbed) + ) + } + if (episodes.isNotEmpty()) { + items.add( + HomePageList( + name = request.name, + list = episodes, + ) + ) + } + } + } + + if (items.isNotEmpty()) { + return newHomePageResponse(items) + } + throw ErrorLoadingException() + } + + // Maybe fuzzy match in the future + private fun getCloseMatches(sequence: String, items: Collection): List { + val a = sequence.trim().lowercase() + + return items.mapNotNull { item -> + val b = item.trim().lowercase() + if (b.contains(a)) + item + else if (a.contains(b)) + item + else null + } + } + + private data class CrunchyAnimeData( + @JsonProperty("name") val name: String, + @JsonProperty("img") var img: String, + @JsonProperty("link") var link: String + ) + + private data class CrunchyJson( + @JsonProperty("data") val data: List, + ) + + + override suspend fun search(query: String): ArrayList { + val json = + crUnblock.geoBypassRequest("http://www.crunchyroll.com/ajax/?req=RpcApiSearch_GetSearchCandidates").text.split( + "*/" + )[0].replace("\\/", "/") + val data = parseJson( + json.split("\n").mapNotNull { if (!it.startsWith("/")) it else null }.joinToString("\n") + ).data + + val results = getCloseMatches(query, data.map { it.name }) + if (results.isEmpty()) return ArrayList() + val searchResutls = ArrayList() + + var count = 0 + for (anime in data) { + if (count == results.size) { + break + } + if (anime.name == results[count]) { + val dubstat = + if (anime.name.contains("Dub)", true)) EnumSet.of(DubStatus.Dubbed) else + EnumSet.of(DubStatus.Subbed) + anime.link = fixUrl(anime.link) + anime.img = anime.img.replace("small", "full") + searchResutls.add( + AnimeSearchResponse( + name = anime.name, + url = anime.link, + apiName = this.name, + type = TvType.Anime, + posterUrl = anime.img, + dubStatus = dubstat, + ) + ) + ++count + } + } + + return searchResutls + } + + override suspend fun load(url: String): LoadResponse { + val soup = Jsoup.parse(crUnblock.geoBypassRequest(url).text) + val title = soup.selectFirst("#showview-content-header .ellipsis")?.text()?.trim() + val posterU = soup.selectFirst(".poster")?.attr("src") + + val p = soup.selectFirst(".description") + var description = p?.selectFirst(".more")?.text()?.trim() + if (description.isNullOrBlank()) { + description = p?.selectFirst("span")?.text()?.trim() + } + + val genres = soup.select(".large-margin-bottom > ul:nth-child(2) li:nth-child(2) a") + .map { it.text().capitalize() } + val year = genres.filter { it.toIntOrNull() != null }.map { it.toInt() }.sortedBy { it } + .getOrNull(0) + + val subEpisodes = mutableListOf() + val dubEpisodes = mutableListOf() + val premiumSubEpisodes = mutableListOf() + val premiumDubEpisodes = mutableListOf() + soup.select(".season").forEach { + val seasonName = it.selectFirst("a.season-dropdown")?.text()?.trim() + it.select(".episode").forEach { ep -> + val epTitle = ep.selectFirst(".short-desc")?.text() + + val epNum = episodeNumRegex.find( + ep.selectFirst("span.ellipsis")?.text().toString() + )?.destructured?.component1() + var poster = ep.selectFirst("img.landscape")?.attr("data-thumbnailurl") + val poster2 = ep.selectFirst("img")?.attr("src") + if (poster.isNullOrBlank()) { + poster = poster2 + } + + var epDesc = + (if (epNum == null) "" else "Episode $epNum") + (if (!seasonName.isNullOrEmpty()) " - $seasonName" else "") + val isPremium = poster?.contains("widestar", ignoreCase = true) ?: false + if (isPremium) { + epDesc = "★ $epDesc ★" + } + + val isPremiumDubbed = + isPremium && seasonName != null && (seasonName.contains("Dub") || seasonName.contains( + "Russian" + ) || seasonName.contains("Spanish")) + + val epi = Episode( + fixUrl(ep.attr("href")), + "$epTitle", + posterUrl = poster?.replace("widestar", "full")?.replace("wide", "full"), + description = epDesc, + season = if (isPremium) -1 else 1 + ) + if (isPremiumDubbed) { + premiumDubEpisodes.add(epi) + } else if (isPremium) { + premiumSubEpisodes.add(epi) + } else if (seasonName != null && (seasonName.contains("Dub"))) { + dubEpisodes.add(epi) + } else { + subEpisodes.add(epi) + } + } + } + val recommendations = + soup.select(".other-series > ul li")?.mapNotNull { element -> + val recTitle = + element.select("span.ellipsis[dir=auto]").text() ?: return@mapNotNull null + val image = element.select("img")?.attr("src") + val recUrl = fixUrl(element.select("a").attr("href")) + AnimeSearchResponse( + recTitle, + fixUrl(recUrl), + this.name, + TvType.Anime, + fixUrl(image!!), + dubStatus = + if (recTitle.contains("(DUB)") || recTitle.contains("Dub")) EnumSet.of( + DubStatus.Dubbed + ) else EnumSet.of(DubStatus.Subbed), + ) + } + return newAnimeLoadResponse(title.toString(), url, TvType.Anime) { + this.posterUrl = posterU + this.engName = title + if (subEpisodes.isNotEmpty()) addEpisodes(DubStatus.Subbed, subEpisodes.reversed()) + if (dubEpisodes.isNotEmpty()) addEpisodes(DubStatus.Dubbed, dubEpisodes.reversed()) + + if (premiumDubEpisodes.isNotEmpty()) addEpisodes( + DubStatus.Dubbed, + premiumDubEpisodes.reversed() + ) + if (premiumSubEpisodes.isNotEmpty()) addEpisodes( + DubStatus.Subbed, + premiumSubEpisodes.reversed() + ) + + this.plot = description + this.tags = genres + this.year = year + + this.recommendations = recommendations + this.seasonNames = listOf( + SeasonData( + 1, + "Free", + null + ), + SeasonData( + -1, + "Premium", + null + ), + ) + } + } + + data class Subtitles( + @JsonProperty("language") val language: String, + @JsonProperty("url") val url: String, + @JsonProperty("title") val title: String?, + @JsonProperty("format") val format: String? + ) + + data class Streams( + @JsonProperty("format") val format: String?, + @JsonProperty("audio_lang") val audioLang: String?, + @JsonProperty("hardsub_lang") val hardsubLang: String?, + @JsonProperty("url") val url: String, + @JsonProperty("resolution") val resolution: String?, + @JsonProperty("title") var title: String? + ) { + fun title(): String { + return when { + this.hardsubLang == "enUS" && this.audioLang == "jaJP" -> "Hardsub (English)" + this.hardsubLang == "esLA" && this.audioLang == "jaJP" -> "Hardsub (Latino)" + this.hardsubLang == "esES" && this.audioLang == "jaJP" -> "Hardsub (Español España)" + this.audioLang == "esLA" -> "Latino" + this.audioLang == "esES" -> "Español España" + this.audioLang == "enUS" -> "English (US)" + else -> "RAW" + } + } + } + + data class KrunchyVideo( + @JsonProperty("streams") val streams: List, + @JsonProperty("subtitles") val subtitles: List, + ) + + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + val contentRegex = Regex("""vilos\.config\.media = (\{.+\})""") + val response = crUnblock.geoBypassRequest(data) + + val hlsHelper = M3u8Helper() + + val dat = contentRegex.find(response.text)?.destructured?.component1() + + if (!dat.isNullOrEmpty()) { + val json = parseJson(dat) + val streams = ArrayList() + + for (stream in json.streams) { + if ( + listOf( + "adaptive_hls", "adaptive_dash", + "multitrack_adaptive_hls_v2", + "vo_adaptive_dash", "vo_adaptive_hls", + "trailer_hls", + ).contains(stream.format) + ) { + if (stream.format!!.contains("adaptive") && listOf( + "jaJP", + "esLA", + "esES", + "enUS" + ) + .contains(stream.audioLang) && (listOf( + "esLA", + "esES", + "enUS", + null + ).contains(stream.hardsubLang)) +// && URI(stream.url).path.endsWith(".m3u") + ) { + stream.title = stream.title() + streams.add(stream) + } + // Premium eps + else if (stream.format == "trailer_hls" && listOf( + "jaJP", + "esLA", + "esES", + "enUS" + ).contains(stream.audioLang) && + (listOf("esLA", "esES", "enUS", null).contains(stream.hardsubLang)) + ) { + stream.title = stream.title() + streams.add(stream) + } + } + } + + streams.apmap { stream -> + if (stream.url.contains("m3u8") && stream.format!!.contains("adaptive")) { +// hlsHelper.m3u8Generation(M3u8Helper.M3u8Stream(stream.url, null), false) +// .forEach { + callback( + ExtractorLink( + "Crunchyroll", + "Crunchy - ${stream.title}", + stream.url, + "", + getQualityFromName(stream.resolution), + true + ) + ) +// } + } else if (stream.format == "trailer_hls") { + val premiumStream = stream.url + .replace("\\/", "/") + .replace(Regex("\\/clipFrom.*?index.m3u8"), "").replace("'_,'", "'_'") + .replace(stream.url.split("/")[2], "fy.v.vrv.co") + callback( + ExtractorLink( + this.name, + "Crunchy - ${stream.title} ★", + premiumStream, + "", + Qualities.Unknown.value, + false + ) + ) + } else null + } + json.subtitles.forEach { + val langclean = it.language.replace("esLA", "Spanish") + .replace("enUS", "English") + .replace("esES", "Spanish (Spain)") + subtitleCallback( + SubtitleFile(langclean, it.url) + ) + } + + return true + } + return false + } +} \ No newline at end of file diff --git a/Crunchyroll/src/main/kotlin/com/lagradost/CrunchyrollProviderPlugin.kt b/Crunchyroll/src/main/kotlin/com/lagradost/CrunchyrollProviderPlugin.kt new file mode 100644 index 00000000..26997ee2 --- /dev/null +++ b/Crunchyroll/src/main/kotlin/com/lagradost/CrunchyrollProviderPlugin.kt @@ -0,0 +1,14 @@ + +package com.lagradost + +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin +import com.lagradost.cloudstream3.plugins.Plugin +import android.content.Context + +@CloudstreamPlugin +class CrunchyrollProviderPlugin: Plugin() { + override fun load(context: Context) { + // All providers should be added in this manner. Please don't edit the providers list directly. + registerMainAPI(KrunchyProvider()) + } +} \ No newline at end of file diff --git a/GogoanimeProvider/build.gradle.kts b/GogoanimeProvider/build.gradle.kts new file mode 100644 index 00000000..a21f6ba3 --- /dev/null +++ b/GogoanimeProvider/build.gradle.kts @@ -0,0 +1,27 @@ +// use an integer for version numbers +version = 1 + + +cloudstream { + language = "en" + // All of these properties are optional, you can safely remove them + + // description = "Lorem Ipsum" + // 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( + "AnimeMovie", + "Anime", + "OVA", + ) + + iconUrl = "https://www.google.com/s2/favicons?domain=gogoanime.lu&sz=%size%" +} \ No newline at end of file diff --git a/GogoanimeProvider/src/main/AndroidManifest.xml b/GogoanimeProvider/src/main/AndroidManifest.xml new file mode 100644 index 00000000..29aec9de --- /dev/null +++ b/GogoanimeProvider/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/GogoanimeProvider/src/main/kotlin/com/lagradost/GogoanimeProvider.kt b/GogoanimeProvider/src/main/kotlin/com/lagradost/GogoanimeProvider.kt new file mode 100644 index 00000000..f8037ffc --- /dev/null +++ b/GogoanimeProvider/src/main/kotlin/com/lagradost/GogoanimeProvider.kt @@ -0,0 +1,412 @@ +package com.lagradost + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.utils.AppUtils +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.getQualityFromName +import com.lagradost.cloudstream3.utils.loadExtractor +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import java.net.URI +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +class GogoanimeProvider : MainAPI() { + 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) { + "Completed" -> ShowStatus.Completed + "Ongoing" -> ShowStatus.Ongoing + else -> ShowStatus.Completed + } + } + + /** + * @param id base64Decode(show_id) + IV + * @return the encryption key + * */ + private fun getKey(id: String): String? { + return normalSafeApiCall { + id.map { + it.code.toString(16) + }.joinToString("").substring(0, 32) + } + } + + val qualityRegex = Regex("(\\d+)P") + + // https://github.com/saikou-app/saikou/blob/3e756bd8e876ad7a9318b17110526880525a5cd3/app/src/main/java/ani/saikou/anime/source/extractors/GogoCDN.kt#L60 + // No Licence on the function + private fun cryptoHandler( + string: String, + iv: String, + secretKeyString: String, + encrypt: Boolean = true + ): String { + //println("IV: $iv, Key: $secretKeyString, encrypt: $encrypt, Message: $string") + val ivParameterSpec = IvParameterSpec(iv.toByteArray()) + val secretKey = SecretKeySpec(secretKeyString.toByteArray(), "AES") + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + return if (!encrypt) { + cipher.init(Cipher.DECRYPT_MODE, secretKey, ivParameterSpec) + String(cipher.doFinal(base64DecodeArray(string))) + } else { + cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivParameterSpec) + base64Encode(cipher.doFinal(string.toByteArray())) + } + } + + private fun String.decodeHex(): ByteArray { + check(length % 2 == 0) { "Must have an even length" } + return chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + } + + /** + * @param iframeUrl something like https://gogoplay4.com/streaming.php?id=XXXXXX + * @param mainApiName used for ExtractorLink names and source + * @param iv secret iv from site, required non-null if isUsingAdaptiveKeys is off + * @param secretKey secret key for decryption from site, required non-null if isUsingAdaptiveKeys is off + * @param secretDecryptKey secret key to decrypt the response json, required non-null if isUsingAdaptiveKeys is off + * @param isUsingAdaptiveKeys generates keys from IV and ID, see getKey() + * @param isUsingAdaptiveData generate encrypt-ajax data based on $("script[data-name='episode']")[0].dataset.value + * */ + suspend fun extractVidstream( + iframeUrl: String, + mainApiName: String, + callback: (ExtractorLink) -> Unit, + iv: String?, + secretKey: String?, + secretDecryptKey: String?, + // This could be removed, but i prefer it verbose + isUsingAdaptiveKeys: Boolean, + isUsingAdaptiveData: Boolean, + // If you don't want to re-fetch the document + iframeDocument: Document? = null + ) = safeApiCall { + // https://github.com/saikou-app/saikou/blob/3e756bd8e876ad7a9318b17110526880525a5cd3/app/src/main/java/ani/saikou/anime/source/extractors/GogoCDN.kt + // No Licence on the following code + // Also modified of https://github.com/jmir1/aniyomi-extensions/blob/master/src/en/gogoanime/src/eu/kanade/tachiyomi/animeextension/en/gogoanime/extractors/GogoCdnExtractor.kt + // License on the code above https://github.com/jmir1/aniyomi-extensions/blob/master/LICENSE + + if ((iv == null || secretKey == null || secretDecryptKey == null) && !isUsingAdaptiveKeys) + return@safeApiCall + + val id = Regex("id=([^&]+)").find(iframeUrl)!!.value.removePrefix("id=") + + var document: Document? = iframeDocument + val foundIv = + iv ?: (document ?: app.get(iframeUrl).document.also { document = it }) + .select("""div.wrapper[class*=container]""") + .attr("class").split("-").lastOrNull() ?: return@safeApiCall + val foundKey = secretKey ?: getKey(base64Decode(id) + foundIv) ?: return@safeApiCall + val foundDecryptKey = secretDecryptKey ?: foundKey + + val uri = URI(iframeUrl) + val mainUrl = "https://" + uri.host + + val encryptedId = cryptoHandler(id, foundIv, foundKey) + val encryptRequestData = if (isUsingAdaptiveData) { + // Only fetch the document if necessary + val realDocument = document ?: app.get(iframeUrl).document + val dataEncrypted = + realDocument.select("script[data-name='episode']").attr("data-value") + val headers = cryptoHandler(dataEncrypted, foundIv, foundKey, false) + "id=$encryptedId&alias=$id&" + headers.substringAfter("&") + } else { + "id=$encryptedId&alias=$id" + } + + val jsonResponse = + app.get( + "$mainUrl/encrypt-ajax.php?$encryptRequestData", + headers = mapOf("X-Requested-With" to "XMLHttpRequest") + ) + val dataencrypted = + jsonResponse.text.substringAfter("{\"data\":\"").substringBefore("\"}") + val datadecrypted = cryptoHandler(dataencrypted, foundIv, foundDecryptKey, false) + val sources = AppUtils.parseJson(datadecrypted) + + fun invokeGogoSource( + source: GogoSource, + sourceCallback: (ExtractorLink) -> Unit + ) { + sourceCallback.invoke( + ExtractorLink( + mainApiName, + mainApiName, + source.file, + mainUrl, + getQualityFromName(source.label), + isM3u8 = source.type == "hls" || source.label?.contains( + "auto", + ignoreCase = true + ) == true + ) + ) + } + + sources.source?.forEach { + invokeGogoSource(it, callback) + } + sources.sourceBk?.forEach { + invokeGogoSource(it, callback) + } + } + } + + override var mainUrl = "https://gogoanime.lu" + override var name = "GogoAnime" + override val hasQuickSearch = false + override val hasMainPage = true + + override val supportedTypes = setOf( + TvType.AnimeMovie, + TvType.Anime, + TvType.OVA + ) + + val headers = mapOf( + "authority" to "ajax.gogo-load.com", + "sec-ch-ua" to "\"Google Chrome\";v=\"89\", \"Chromium\";v=\"89\", \";Not A Brand\";v=\"99\"", + "accept" to "text/html, */*; q=0.01", + "dnt" to "1", + "sec-ch-ua-mobile" to "?0", + "user-agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36", + "origin" to mainUrl, + "sec-fetch-site" to "cross-site", + "sec-fetch-mode" to "cors", + "sec-fetch-dest" to "empty", + "referer" to "$mainUrl/" + ) + val parseRegex = + Regex("""
  • \s*\n.*\n.*\n.*?img src="(.*?)"""") + + override val mainPage = mainPageOf( + Pair("1", "Recent Release - Sub"), + Pair("2", "Recent Release - Dub"), + Pair("3", "Recent Release - Chinese"), + ) + + override suspend fun getMainPage( + page: Int, + request : MainPageRequest + ): HomePageResponse { + val params = mapOf("page" to page.toString(), "type" to request.data) + val html = app.get( + "https://ajax.gogo-load.com/ajax/page-recent-release.html", + headers = headers, + params = params + ) + val isSub = listOf(1, 3).contains(request.data.toInt()) + + val home = parseRegex.findAll(html.text).map { + val (link, epNum, title, poster) = it.destructured + newAnimeSearchResponse(title, link) { + this.posterUrl = poster + addDubStatus(!isSub, epNum.toIntOrNull()) + } + }.toList() + + return newHomePageResponse(request.name, home) + } + + override suspend fun search(query: String): ArrayList { + val link = "$mainUrl/search.html?keyword=$query" + val html = app.get(link).text + val doc = Jsoup.parse(html) + + val episodes = doc.select(""".last_episodes li""").mapNotNull { + AnimeSearchResponse( + it.selectFirst(".name")?.text()?.replace(" (Dub)", "") ?: return@mapNotNull null, + fixUrl(it.selectFirst(".name > a")?.attr("href") ?: return@mapNotNull null), + this.name, + TvType.Anime, + it.selectFirst("img")?.attr("src"), + it.selectFirst(".released")?.text()?.split(":")?.getOrNull(1)?.trim() + ?.toIntOrNull(), + if (it.selectFirst(".name")?.text() + ?.contains("Dub") == true + ) EnumSet.of(DubStatus.Dubbed) else EnumSet.of( + DubStatus.Subbed + ), + ) + } + + return ArrayList(episodes) + } + + private fun getProperAnimeLink(uri: String): String { + if (uri.contains("-episode")) { + val split = uri.split("/") + val slug = split[split.size - 1].split("-episode")[0] + return "$mainUrl/category/$slug" + } + return uri + } + + override suspend fun load(url: String): LoadResponse { + val link = getProperAnimeLink(url) + val episodeloadApi = "https://ajax.gogo-load.com/ajax/load-list-episode" + val doc = app.get(link).document + + val animeBody = doc.selectFirst(".anime_info_body_bg") + val title = animeBody?.selectFirst("h1")!!.text() + val poster = animeBody.selectFirst("img")?.attr("src") + var description: String? = null + val genre = ArrayList() + var year: Int? = null + var status: String? = null + var nativeName: String? = null + var type: String? = null + + animeBody.select("p.type").forEach { pType -> + when (pType.selectFirst("span")?.text()?.trim()) { + "Plot Summary:" -> { + description = pType.text().replace("Plot Summary:", "").trim() + } + "Genre:" -> { + genre.addAll(pType.select("a").map { + it.attr("title") + }) + } + "Released:" -> { + year = pType.text().replace("Released:", "").trim().toIntOrNull() + } + "Status:" -> { + status = pType.text().replace("Status:", "").trim() + } + "Other name:" -> { + nativeName = pType.text().replace("Other name:", "").trim() + } + "Type:" -> { + type = pType.text().replace("type:", "").trim() + } + } + } + + val animeId = doc.selectFirst("#movie_id")!!.attr("value") + val params = mapOf("ep_start" to "0", "ep_end" to "2000", "id" to animeId) + + val episodes = app.get(episodeloadApi, params = params).document.select("a").map { + Episode( + fixUrl(it.attr("href").trim()), + "Episode " + it.selectFirst(".name")?.text()?.replace("EP", "")?.trim() + ) + }.reversed() + + return newAnimeLoadResponse(title, link, getType(type.toString())) { + japName = nativeName + engName = title + posterUrl = poster + this.year = year + addEpisodes(DubStatus.Subbed, episodes) // TODO CHECK + plot = description + tags = genre + + showStatus = getStatus(status.toString()) + } + } + + data class GogoSources( + @JsonProperty("source") val source: List?, + @JsonProperty("sourceBk") val sourceBk: List?, + //val track: List, + //val advertising: List, + //val linkiframe: String + ) + + data class GogoSource( + @JsonProperty("file") val file: String, + @JsonProperty("label") val label: String?, + @JsonProperty("type") val type: String?, + @JsonProperty("default") val default: String? = null + ) + + private suspend fun extractVideos( + uri: String, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val doc = app.get(uri).document + + val iframe = fixUrlNull(doc.selectFirst("div.play-video > iframe")?.attr("src")) ?: return + + argamap( + { + val link = iframe.replace("streaming.php", "download") + val page = app.get(link, headers = mapOf("Referer" to iframe)) + + page.document.select(".dowload > a").apmap { + if (it.hasAttr("download")) { + val qual = if (it.text() + .contains("HDP") + ) "1080" else qualityRegex.find(it.text())?.destructured?.component1() + .toString() + callback( + ExtractorLink( + "Gogoanime", + "Gogoanime", + it.attr("href"), + page.url, + getQualityFromName(qual), + it.attr("href").contains(".m3u8") + ) + ) + } else { + val url = it.attr("href") + loadExtractor(url, null, subtitleCallback, callback) + } + } + }, { + val streamingResponse = app.get(iframe, headers = mapOf("Referer" to iframe)) + val streamingDocument = streamingResponse.document + argamap({ + streamingDocument.select(".list-server-items > .linkserver") + .forEach { element -> + val status = element.attr("data-status") ?: return@forEach + if (status != "1") return@forEach + val data = element.attr("data-video") ?: return@forEach + loadExtractor(data, streamingResponse.url, subtitleCallback, callback) + } + }, { + val iv = "3134003223491201" + val secretKey = "37911490979715163134003223491201" + val secretDecryptKey = "54674138327930866480207815084989" + extractVidstream( + iframe, + this.name, + callback, + iv, + secretKey, + secretDecryptKey, + isUsingAdaptiveKeys = false, + isUsingAdaptiveData = true + ) + }) + } + ) + } + + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + extractVideos(data, subtitleCallback, callback) + return true + } +} diff --git a/GogoanimeProvider/src/main/kotlin/com/lagradost/GogoanimeProviderPlugin.kt b/GogoanimeProvider/src/main/kotlin/com/lagradost/GogoanimeProviderPlugin.kt new file mode 100644 index 00000000..34e0fb17 --- /dev/null +++ b/GogoanimeProvider/src/main/kotlin/com/lagradost/GogoanimeProviderPlugin.kt @@ -0,0 +1,14 @@ + +package com.lagradost + +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin +import com.lagradost.cloudstream3.plugins.Plugin +import android.content.Context + +@CloudstreamPlugin +class GogoanimeProviderPlugin: Plugin() { + override fun load(context: Context) { + // All providers should be added in this manner. Please don't edit the providers list directly. + registerMainAPI(GogoanimeProvider()) + } +} \ No newline at end of file