diff --git a/Kickassanime/build.gradle.kts b/Kickassanime/build.gradle.kts new file mode 100644 index 00000000..e4f3d602 --- /dev/null +++ b/Kickassanime/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("Hexated") + + /** + * 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=www2.kickassanime.ro&sz=%size%" +} \ No newline at end of file diff --git a/Kickassanime/src/main/AndroidManifest.xml b/Kickassanime/src/main/AndroidManifest.xml new file mode 100644 index 00000000..874740e3 --- /dev/null +++ b/Kickassanime/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Kickassanime/src/main/kotlin/com/hexated/GogoExtractor.kt b/Kickassanime/src/main/kotlin/com/hexated/GogoExtractor.kt new file mode 100644 index 00000000..ed19fd25 --- /dev/null +++ b/Kickassanime/src/main/kotlin/com/hexated/GogoExtractor.kt @@ -0,0 +1,166 @@ +package com.hexated + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.base64Decode +import com.lagradost.cloudstream3.base64DecodeArray +import com.lagradost.cloudstream3.base64Encode +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.M3u8Helper +import com.lagradost.cloudstream3.utils.getQualityFromName +import org.jsoup.nodes.Document +import java.net.URI +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +object GogoExtractor { + + /** + * @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) + } + } + + // 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())) + } + } + + /** + * @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) + + suspend fun invokeGogoSource( + source: GogoSource, + sourceCallback: (ExtractorLink) -> Unit + ) { + if (source.file.contains(".m3u8")) { + M3u8Helper.generateM3u8( + mainApiName, + source.file, + mainUrl, + headers = mapOf("Origin" to "https://plyr.link") + ).forEach(sourceCallback) + } else { + sourceCallback.invoke( + ExtractorLink( + mainApiName, + mainApiName, + source.file, + mainUrl, + getQualityFromName(source.label), + ) + ) + } + } + + sources.source?.forEach { + invokeGogoSource(it, callback) + } + sources.sourceBk?.forEach { + invokeGogoSource(it, callback) + } + } + + 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 + ) +} diff --git a/Kickassanime/src/main/kotlin/com/hexated/Kickassanime.kt b/Kickassanime/src/main/kotlin/com/hexated/Kickassanime.kt new file mode 100644 index 00000000..07f8e10f --- /dev/null +++ b/Kickassanime/src/main/kotlin/com/hexated/Kickassanime.kt @@ -0,0 +1,462 @@ +package com.hexated + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.LoadResponse.Companion.addAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.addMalId +import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import java.net.URI +import java.net.URLDecoder + +class Kickassanime : MainAPI() { + override var mainUrl = "https://www2.kickassanime.ro" + override var name = "Kickassanime" + override val hasMainPage = true + override var lang = "en" + override val hasDownloadSupport = true + + override val supportedTypes = setOf( + TvType.Anime, + TvType.AnimeMovie, + TvType.OVA + ) + + companion object { + private const val kaast = "https://kaast1.com" + fun getType(t: String): TvType { + return when { + t.contains("Ova", true) -> TvType.OVA + t.contains("Movie", true) -> TvType.AnimeMovie + else -> TvType.Anime + } + } + + fun getStatus(t: String): ShowStatus { + return when (t) { + "Finished Airing" -> ShowStatus.Completed + "Currently Airing" -> ShowStatus.Ongoing + else -> ShowStatus.Completed + } + } + } + + override val mainPage = mainPageOf( + "$mainUrl/api/get_anime_list/sub/" to "Sub", + "$mainUrl/api/get_anime_list/dub/" to "Dub", + ) + + override suspend fun getMainPage( + page: Int, + request: MainPageRequest + ): HomePageResponse { + val home = app.get(request.data + page).parsedSafe()?.data?.mapNotNull { media -> + media.toSearchResponse() + } ?: throw ErrorLoadingException() + return newHomePageResponse(request.name, home) + } + + private fun getProperAnimeLink(uri: String): String { + return when { + uri.contains("/episode") -> fixUrl(uri.substringBeforeLast("/")) + else -> fixUrl(uri) + } + } + + private fun Animes.toSearchResponse(): AnimeSearchResponse? { + val href = getProperAnimeLink(this.slug ?: return null) + val title = this.name ?: return null + val posterUrl = getImageUrl(this.poster) + val episode = this.episode?.toIntOrNull() + val isDub = this.name.contains("(Dub)") + + return newAnimeSearchResponse(title, href, TvType.Anime) { + this.posterUrl = posterUrl + addDubStatus(isDub, episode) + } + } + + override suspend fun search(query: String): List { + val document = app.get("$mainUrl/search?q=$query").document + val data = document.selectFirst("script:containsData(appData)")?.data() + ?.substringAfter("\"animes\":[")?.substringBefore("],") + + return tryParseJson>("[$data]")?.mapNotNull { media -> media.toSearchResponse() } + ?: throw ErrorLoadingException() + + } + + override suspend fun load(url: String): LoadResponse? { + val document = app.get(url).document + + val res = document.selectFirst("script:containsData(appData)")?.data() + ?.substringAfter("\"anime\":{")?.substringBefore("},\"wkl\"")?.let { + tryParseJson("{$it}") + } ?: throw ErrorLoadingException() + + val title = res.name ?: return null + val trackerTitle = res.en_title.orEmpty().ifEmpty { res.name }.fixTitle() + val poster = getImageUrl(res.image) + val tags = res.genres?.map { it.name ?: return null } + val year = res.startdate?.substringBefore("-")?.toIntOrNull() + val status = getStatus(res.status ?: return null) + val description = res.description + + val episodes = res.episodes?.mapNotNull { eps -> + Episode(fixUrl(eps.slug ?: return@mapNotNull null), episode = eps.num?.toIntOrNull()) + }?.reversed() ?: emptyList() + + val type = res.type?.substringBefore(",")?.trim()?.let { + when (it) { + "TV Series" -> "tv" + "Ova" -> "ova" + "Movie" -> "movie" + else -> "tv" + } + } ?: if (episodes.size == 1) "movie" else "tv" + + val (malId, anilistId, image, cover) = getTracker(trackerTitle, type, year) + + return newAnimeLoadResponse(title, url, getType(type)) { + engName = title + posterUrl = image ?: poster + backgroundPosterUrl = cover ?: image ?: poster + this.year = year + addEpisodes(DubStatus.Subbed, episodes) + showStatus = status + plot = description + this.tags = tags + addMalId(malId) + addAniListId(anilistId?.toIntOrNull()) + } + + } + + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ): Boolean { + + val document = app.get(data).document + val sources = document.selectFirst("script:containsData(appData)")?.data()?.let { + tryParseJson("{${Regex("(\"episode\":.*),\"wkl").find(it)?.groupValues?.get(1)}}") + }?.let { server -> + listOf( + server.episode?.link1, + server.ext_servers?.find { it.name == "Vidstreaming" }?.link + ) + }?.filterNotNull() + + sources?.flatMap { + httpsify(it).fixIframe() + }?.apmap { (name, iframe) -> + val sourceName = fixTitle(name ?: this.name) + val link = httpsify(iframe ?: return@apmap null) + when { + name?.contains(Regex("(?i)(KICKASSANIMEV2|ORIGINAL-QUALITY-V2|BETA-SERVER)")) == true -> { + invokeAlpha(sourceName, link, callback) + } + name?.contains(Regex("(?i)(BETAPLAYER)")) == true -> { + invokeBeta(sourceName, link, callback) + } + name?.contains(Regex("(?i)(MAVERICKKI)")) == true -> { + invokeMave(sourceName, link, subtitleCallback, callback) + } + name?.contains(Regex("(?i)(gogo)")) == true -> { + invokeGogo(link, subtitleCallback, callback) + } + else -> {} + } + } + + return true + } + + private suspend fun invokeAlpha( + name: String, + url: String? = null, + callback: (ExtractorLink) -> Unit + ) { + val fixUrl = url?.replace(Regex("(player|embed)\\.php"), "pref.php") + app.get( + fixUrl ?: return, + referer = kaast + ).document.selectFirst("script:containsData(Base64.decode)")?.data() + ?.substringAfter("Base64.decode(\"")?.substringBefore("\")")?.let { base64Decode(it) } + ?.substringAfter("sources: [")?.substringBefore("],") + ?.let { tryParseJson>("[$it]") }?.map { + callback.invoke( + ExtractorLink( + name, + name, + it.file ?: return@map null, + url, + getQualityFromName(it.label) + ) + ) + } + } + + private suspend fun invokeBeta( + name: String, + url: String? = null, + callback: (ExtractorLink) -> Unit + ) { + app.get( + url ?: return, + referer = kaast + ).document.selectFirst("script:containsData(JSON.parse)")?.data() + ?.substringAfter("JSON.parse('")?.substringBeforeLast("')") + ?.let { tryParseJson>(it) }?.map { + callback.invoke( + ExtractorLink( + name, + name, + it.file ?: return@map null, + getBaseUrl(url), + getQualityFromName(it.label) + ) + ) + } + } + + private suspend fun invokeMave( + name: String, + url: String? = null, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ) { + val fixUrl = url?.replace("/embed/", "/api/source/") ?: return + val base = getBaseUrl(url) + val data = app.get(fixUrl, referer = url).parsedSafe() + + M3u8Helper.generateM3u8( + name, + fixUrl(data?.hls ?: return, base), + url + ).forEach(callback) + + data.subtitles?.map { sub -> + subtitleCallback.invoke( + SubtitleFile( + sub.name ?: "", + fixUrl(sub.src ?: return@map null, base) + ) + ) + } + + } + + private suspend fun invokeGogo( + link: String, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val iframe = app.get(link) + val iframeDoc = iframe.document + argamap({ + iframeDoc.select(".list-server-items > .linkserver") + .forEach { element -> + val status = element.attr("data-status") ?: return@forEach + if (status != "1") return@forEach + val extractorData = element.attr("data-video") ?: return@forEach + loadExtractor(extractorData, iframe.url, subtitleCallback, callback) + } + }, { + val iv = "3134003223491201" + val secretKey = "37911490979715163134003223491201" + val secretDecryptKey = "54674138327930866480207815084989" + GogoExtractor.extractVidstream( + iframe.url, + "Gogoanime", + callback, + iv, + secretKey, + secretDecryptKey, + isUsingAdaptiveKeys = false, + isUsingAdaptiveData = true, + iframeDocument = iframeDoc + ) + }) + } + + private suspend fun String.fixIframe(): List> { + return when { + this.startsWith("$kaast/dust/") -> { + val document = app.get(this).document + document.selectFirst("script:containsData(sources =)")?.data() + ?.substringAfter("sources = [")?.substringBefore("];")?.let { + tryParseJson>("[$it]")?.map { source -> + source.name to source.src + } + } ?: emptyList() + } + this.startsWith("$kaast/axplayer/") -> { + val source = decode( + this.substringAfter("&data=").substringBefore("&vref=") + ) + listOf(URI(source).host.substringBefore(".") to source) + } + else -> { + emptyList() + } + } + } + + private fun decode(input: String): String = + URLDecoder.decode(input, "utf-8").replace(" ", "%20") + + private fun String.fixTitle(): String { + return this.replace("(Dub)", "").replace("(Uncensored)", "").trim() + } + + private fun getImageUrl(link: String?): String? { + if (link == null) return null + return if (link.startsWith(mainUrl)) link else "$mainUrl/uploads/$link" + } + + private fun getBaseUrl(url: String): String { + return URI(url).let { + "${it.scheme}://${it.host}" + } + } + + private fun fixUrl(url: String, domain: String): String { + if (url.startsWith("http")) { + return url + } + if (url.isEmpty()) { + return "" + } + + val startsWithNoHttp = url.startsWith("//") + if (startsWithNoHttp) { + return "https:$url" + } else { + if (url.startsWith('/')) { + return domain + url + } + return "$domain/$url" + } + } + + private suspend fun getTracker(title: String?, type: String?, year: Int?): Tracker { + val res = app.get("https://api.consumet.org/meta/anilist/$title") + .parsedSafe()?.results?.find { media -> + (media.title?.english.equals(title, true) || media.title?.romaji.equals( + title, + true + )) || (media.type.equals(type, true) && media.releaseDate == year) + } + return Tracker(res?.malId, res?.aniId, res?.image, res?.cover) + } + + data class Tracker( + val malId: Int? = null, + val aniId: String? = null, + val image: String? = null, + val cover: String? = null, + ) + + data class Title( + @JsonProperty("romaji") val romaji: String? = null, + @JsonProperty("english") val english: String? = null, + ) + + data class Results( + @JsonProperty("id") val aniId: String? = null, + @JsonProperty("malId") val malId: Int? = null, + @JsonProperty("title") val title: Title? = null, + @JsonProperty("releaseDate") val releaseDate: Int? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("image") val image: String? = null, + @JsonProperty("cover") val cover: String? = null, + ) + + data class AniSearch( + @JsonProperty("results") val results: ArrayList? = arrayListOf(), + ) + + data class Genres( + @JsonProperty("name") val name: String? = null, + @JsonProperty("slug") val slug: String? = null, + ) + + data class Episodes( + @JsonProperty("epnum") val epnum: String? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("slug") val slug: String? = null, + @JsonProperty("createddate") val createddate: String? = null, + @JsonProperty("num") val num: String? = null, + ) + + data class DetailAnime( + @JsonProperty("name") val name: String? = null, + @JsonProperty("en_title") val en_title: String? = null, + @JsonProperty("slug") val slug: String? = null, + @JsonProperty("description") val description: String? = null, + @JsonProperty("status") val status: String? = null, + @JsonProperty("image") val image: String? = null, + @JsonProperty("startdate") val startdate: String? = null, + @JsonProperty("broadcast_day") val broadcast_day: String? = null, + @JsonProperty("broadcast_time") val broadcast_time: String? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("episodes") val episodes: ArrayList? = null, + @JsonProperty("genres") val genres: ArrayList? = null, + ) + + data class Animes( + @JsonProperty("episode") val episode: String? = null, + @JsonProperty("slug") val slug: String? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("episode_date") val episode_date: String? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("poster") val poster: String? = null, + ) + + data class Responses( + @JsonProperty("data") val data: ArrayList? = arrayListOf(), + ) + + data class Iframe( + @JsonProperty("name") val name: String? = null, + @JsonProperty("src") val src: String? = null, + ) + + data class ExtServers( + @JsonProperty("name") val name: String? = null, + @JsonProperty("link") val link: String? = null, + ) + + data class Eps( + @JsonProperty("link1") val link1: String? = null, + ) + + data class Resources( + @JsonProperty("episode") val episode: Eps? = null, + @JsonProperty("ext_servers") val ext_servers: ArrayList? = arrayListOf(), + ) + + data class BetaSources( + @JsonProperty("file") val file: String? = null, + @JsonProperty("label") val label: String? = null, + ) + + data class AlphaSources( + @JsonProperty("file") val file: String? = null, + @JsonProperty("label") val label: String? = null, + ) + + data class MaveSubtitles( + @JsonProperty("name") val name: String? = null, + @JsonProperty("src") val src: String? = null, + ) + + data class MaveSources( + @JsonProperty("hls") val hls: String? = null, + @JsonProperty("subtitles") val subtitles: ArrayList? = null, + ) + +} \ No newline at end of file diff --git a/Kickassanime/src/main/kotlin/com/hexated/KickassanimePlugin.kt b/Kickassanime/src/main/kotlin/com/hexated/KickassanimePlugin.kt new file mode 100644 index 00000000..5565d18d --- /dev/null +++ b/Kickassanime/src/main/kotlin/com/hexated/KickassanimePlugin.kt @@ -0,0 +1,14 @@ + +package com.hexated + +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin +import com.lagradost.cloudstream3.plugins.Plugin +import android.content.Context + +@CloudstreamPlugin +class KickassanimePlugin: Plugin() { + override fun load(context: Context) { + // All providers should be added in this manner. Please don't edit the providers list directly. + registerMainAPI(Kickassanime()) + } +} \ No newline at end of file diff --git a/SoraStream/src/main/kotlin/com/hexated/SoraExtractor.kt b/SoraStream/src/main/kotlin/com/hexated/SoraExtractor.kt index 78515e68..6884be58 100644 --- a/SoraStream/src/main/kotlin/com/hexated/SoraExtractor.kt +++ b/SoraStream/src/main/kotlin/com/hexated/SoraExtractor.kt @@ -1858,8 +1858,9 @@ object SoraExtractor : SoraStream() { } else { "$rStreamAPI/Shows/$id/$season/$episode.mp4" } + val referer = "https://remotestre.am/" - if (!app.get(url).isSuccessful) return + if (!app.get(url, referer = referer).isSuccessful) return delay(4000) callback.invoke( @@ -1867,7 +1868,7 @@ object SoraExtractor : SoraStream() { "RStream", "RStream", url, - "https://remotestre.am/", + referer, Qualities.P720.value ) )