fix(en/aniwatch): Fix video extractor + general refactor (#2028)

Hates cloudflare
Hates the antichrist
Hates obfuscated javascript
Hates kotlin
Hates aniwatch
Hates the femboi role
This commit is contained in:
Claudemirovsky
2023-08-08 19:30:40 -03:00
committed by GitHub
parent 9ac4c73ef7
commit dc9bfd773f
7 changed files with 247 additions and 478 deletions

View File

@ -1,17 +1,21 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
}
ext {
extName = 'AniWatch.to'
pkgNameSuffix = 'en.zoro'
extClass = '.AniWatch'
extVersionCode = 32
extVersionCode = 33
libVersion = '13'
}
dependencies {
implementation(project(':lib-streamtape-extractor'))
implementation(project(':lib-streamsb-extractor'))
implementation(project(':lib-cryptoaes'))
implementation(project(':lib-playlist-utils'))
}
apply from: "$rootDir/common.gradle"

View File

@ -5,8 +5,8 @@ import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.animeextension.en.zoro.dto.VideoDto
import eu.kanade.tachiyomi.animeextension.en.zoro.extractors.AniWatchExtractor
import eu.kanade.tachiyomi.animeextension.en.zoro.utils.JSONUtil
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
@ -15,7 +15,7 @@ import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.ParsedAnimeHttpSource
import eu.kanade.tachiyomi.lib.streamsbextractor.StreamSBExtractor
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.lib.streamtapeextractor.StreamTapeExtractor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
@ -23,14 +23,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@ -59,6 +57,8 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
private val json: Json by injectLazy()
private val ajaxRoute by lazy { if (baseUrl == "https://kaido.to") "" else "/v2" }
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
@ -77,120 +77,169 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override fun popularAnimeNextPageSelector(): String = "li.page-item a[title=Next]"
// =============================== Latest ===============================
override fun latestUpdatesNextPageSelector() = popularAnimeNextPageSelector()
override fun latestUpdatesFromElement(element: Element) = popularAnimeFromElement(element)
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/top-airing")
override fun latestUpdatesSelector() = popularAnimeSelector()
// =============================== Search ===============================
override fun searchAnimeFromElement(element: Element) = popularAnimeFromElement(element)
override fun searchAnimeNextPageSelector() = popularAnimeNextPageSelector()
override fun searchAnimeSelector() = popularAnimeSelector()
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
return if (query.startsWith(PREFIX_SEARCH)) {
val slug = query.removePrefix(PREFIX_SEARCH)
client.newCall(GET("$baseUrl/$slug"))
.asObservableSuccess()
.map(::searchAnimeBySlugParse)
} else {
super.fetchSearchAnime(page, query, filters)
}
}
private fun searchAnimeBySlugParse(response: Response): AnimesPage {
val details = animeDetailsParse(response)
return AnimesPage(listOf(details), false)
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val params = AniWatchFilters.getSearchParameters(filters)
val endpoint = if (query.isEmpty()) "filter" else "search"
val url = "$baseUrl/$endpoint".toHttpUrl().newBuilder()
.addQueryParameter("page", page.toString())
.addIfNotBlank("keyword", query)
.addIfNotBlank("type", params.type)
.addIfNotBlank("status", params.status)
.addIfNotBlank("rated", params.rated)
.addIfNotBlank("score", params.score)
.addIfNotBlank("season", params.season)
.addIfNotBlank("language", params.language)
.addIfNotBlank("sort", params.sort)
.addIfNotBlank("sy", params.start_year)
.addIfNotBlank("sm", params.start_month)
.addIfNotBlank("sd", params.start_day)
.addIfNotBlank("ey", params.end_year)
.addIfNotBlank("em", params.end_month)
.addIfNotBlank("ed", params.end_day)
.addIfNotBlank("genres", params.genres)
.build()
return GET(url.toString())
}
override fun getFilterList() = AniWatchFilters.FILTER_LIST
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document) = SAnime.create().apply {
val info = document.selectFirst("div.anisc-info")!!
val detail = document.selectFirst("div.anisc-detail")!!
thumbnail_url = document.selectFirst("div.anisc-poster img")!!.attr("src")
title = detail.selectFirst("h2")!!.attr("data-jname")
author = info.getInfo("Studios:")
status = parseStatus(info.getInfo("Status:"))
genre = info.getInfo("Genres:", isList = true)
description = buildString {
info.getInfo("Overview:")?.also { append(it + "\n") }
detail.select("div.film-stats div.tick-dub").eachText().also {
append("\nLanguage: " + it.joinToString())
}
info.getInfo("Aired:", full = true)?.also(::append)
info.getInfo("Premiered:", full = true)?.also(::append)
info.getInfo("Synonyms:", full = true)?.also(::append)
info.getInfo("Japanese:", full = true)?.also(::append)
}
}
// ============================== Episodes ==============================
override fun episodeListSelector() = "ul#episode_page li a"
override fun episodeListRequest(anime: SAnime): Request {
val id = anime.url.substringAfterLast("-")
val referer = Headers.headersOf("Referer", baseUrl + anime.url)
val ajaxRoute = if (baseUrl == "https://kaido.to") "" else "/v2"
return GET("$baseUrl/ajax$ajaxRoute/episode/list/$id", referer)
}
override fun episodeListParse(response: Response): List<SEpisode> {
val data = response.body.string()
.substringAfter("\"html\":\"")
.substringBefore("<script>")
val unescapedData = JSONUtil.unescape(data)
val document = Jsoup.parse(unescapedData)
val episodeList = document.select("a.ep-item").map {
SEpisode.create().apply {
episode_number = it.attr("data-number").toFloat()
name = "Episode ${it.attr("data-number")}: ${it.attr("title")}"
url = it.attr("href")
if (it.hasClass("ssl-item-filler") && preferences.getBoolean(MARK_FILLERS_KEY, MARK_FILLERS_DEFAULT)) {
scanlator = "Filler Episode"
}
}
}
return episodeList.reversed()
val document = Jsoup.parse(response.parseAs<HtmlResponse>().html)
return document.select("a.ep-item")
.map(::episodeFromElement)
.reversed()
}
override fun episodeFromElement(element: Element) = throw Exception("not used")
override fun episodeFromElement(element: Element) = SEpisode.create().apply {
episode_number = element.attr("data-number").toFloatOrNull() ?: 1F
name = "Episode ${element.attr("data-number")}: ${element.attr("title")}"
setUrlWithoutDomain(element.attr("href"))
if (element.hasClass("ssl-item-filler") && preferences.getBoolean(MARK_FILLERS_KEY, MARK_FILLERS_DEFAULT)) {
scanlator = "Filler Episode"
}
}
// ============================ Video Links =============================
override fun videoListRequest(episode: SEpisode): Request {
val id = episode.url.substringAfterLast("?ep=")
val referer = Headers.headersOf("Referer", baseUrl + episode.url)
val ajaxRoute = if (baseUrl == "https://kaido.to") "" else "/v2"
return GET("$baseUrl/ajax$ajaxRoute/episode/servers?episodeId=$id", referer)
}
private val aniwatchExtractor by lazy { AniWatchExtractor(client) }
override fun videoListParse(response: Response): List<Video> {
val body = response.body.string()
val episodeReferer = Headers.headersOf("Referer", response.request.header("referer")!!)
val data = body.substringAfter("\"html\":\"").substringBefore("<script>")
val unescapedData = JSONUtil.unescape(data)
val serversHtml = Jsoup.parse(unescapedData)
val extractor = AniWatchExtractor(client)
val videoList = serversHtml.select("div.server-item")
val serversDoc = Jsoup.parse(response.parseAs<HtmlResponse>().html)
return serversDoc.select("div.server-item")
.parallelMap { server ->
val name = server.text()
val id = server.attr("data-id")
val subDub = server.attr("data-type")
val ajaxRoute = if (baseUrl == "https://kaido.to") "" else "/v2"
val url = "$baseUrl/ajax$ajaxRoute/episode/sources?id=$id"
val reqBody = client.newCall(GET(url, episodeReferer)).execute()
.body.string()
.use { it.body.string() }
val sourceUrl = reqBody.substringAfter("\"link\":\"")
.substringBefore("\"")
runCatching {
when {
"Vidstreaming" in name || "Vidcloud" in name -> {
val source = extractor.getSourcesJson(sourceUrl)
source?.let { getVideosFromServer(it, subDub, name) }
}
"StreamSB" in name -> {
StreamSBExtractor(client)
.videosFromUrl(sourceUrl, headers, suffix = "- $subDub")
aniwatchExtractor.getVideoDto(sourceUrl).let {
getVideosFromServer(it, subDub, name)
}
}
"Streamtape" in name ->
StreamTapeExtractor(client)
.videoFromUrl(sourceUrl, "StreamTape - $subDub")
?.let { listOf(it) }
?.let(::listOf)
else -> null
}
}.getOrNull()
}
.filterNotNull()
.flatten()
return videoList
}.onFailure { it.printStackTrace() }.getOrNull() ?: emptyList()
}.flatten()
}
private fun getVideosFromServer(source: String, subDub: String, name: String): List<Video>? {
if (!source.contains("{\"sources\":[{\"file\":\"")) return null
val json = json.decodeFromString<JsonObject>(source)
val masterUrl = json["sources"]!!.jsonArray[0].jsonObject["file"]!!.jsonPrimitive.content
val subs2 = mutableListOf<Track>()
json["tracks"]?.jsonArray
?.filter { it.jsonObject["kind"]!!.jsonPrimitive.content == "captions" }
?.map { track ->
val trackUrl = track.jsonObject["file"]!!.jsonPrimitive.content
val lang = track.jsonObject["label"]!!.jsonPrimitive.content
try {
subs2.add(Track(trackUrl, lang))
} catch (e: Error) {}
} ?: emptyList()
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
private fun getVideosFromServer(video: VideoDto, subDub: String, name: String): List<Video> {
val masterUrl = video.sources.first().file
val subs2 = video.tracks
?.filter { it.kind == "captions" }
?.mapNotNull { Track(it.file, it.label) }
?: emptyList<Track>()
val subs = subLangOrder(subs2)
val prefix = "#EXT-X-STREAM-INF:"
val playlist = client.newCall(GET(masterUrl)).execute()
.body.string()
val videoList = playlist.substringAfter(prefix).split(prefix).map {
val quality = name + " - " + it.substringAfter("RESOLUTION=")
.substringAfter("x")
.substringBefore(",") + "p - $subDub"
val videoUrl = masterUrl.substringBeforeLast("/") + "/" +
it.substringAfter("\n").substringBefore("\n")
try {
Video(videoUrl, quality, videoUrl, subtitleTracks = subs)
} catch (e: Error) {
Video(videoUrl, quality, videoUrl)
}
}
return videoList
return playlistUtils.extractFromHls(
masterUrl,
videoNameGen = { "$name - $it - $subDub" },
subtitleList = subs,
)
}
override fun videoListSelector() = throw Exception("not used")
@ -199,144 +248,27 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override fun videoUrlParse(document: Document) = throw Exception("not used")
private fun List<Video>.sortIfContains(item: String): List<Video> {
val newList = mutableListOf<Video>()
var preferred = 0
for (video in this) {
if (item in video.quality) {
newList.add(preferred, video)
preferred++
} else {
newList.add(video)
}
}
return newList
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, "720p")!!
val type = preferences.getString(PREF_TYPE_KEY, "dub")!!
val newList = this.sortIfContains(type).sortIfContains(quality)
return newList
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
val type = preferences.getString(PREF_TYPE_KEY, PREF_TYPE_DEFAULT)!!
return sortedWith(
compareBy(
{ it.quality.contains(quality) },
{ it.quality.contains(type) },
),
).reversed()
}
private fun subLangOrder(tracks: List<Track>): List<Track> {
val language = preferences.getString(PREF_SUB_KEY, null)
if (language != null) {
val newList = mutableListOf<Track>()
var preferred = 0
for (track in tracks) {
if (track.lang == language) {
newList.add(preferred, track)
preferred++
} else {
newList.add(track)
}
}
return newList
}
return tracks
val language = preferences.getString(PREF_SUB_KEY, PREF_SUB_DEFAULT)!!
return tracks.sortedWith(
compareBy { it.lang.contains(language) },
).reversed()
}
// =============================== Search ===============================
override fun searchAnimeFromElement(element: Element) = popularAnimeFromElement(element)
override fun searchAnimeNextPageSelector(): String = popularAnimeNextPageSelector()
override fun searchAnimeSelector(): String = popularAnimeSelector()
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
return if (query.startsWith(PREFIX_SEARCH)) {
val slug = query.removePrefix(PREFIX_SEARCH)
client.newCall(GET("$baseUrl/$slug"))
.asObservableSuccess()
.map { response ->
searchAnimeBySlugParse(response, slug)
}
} else {
val params = AniWatchFilters.getSearchParameters(filters)
client.newCall(searchAnimeRequest(page, query, params))
.asObservableSuccess()
.map { response ->
searchAnimeParse(response)
}
}
}
private fun searchAnimeBySlugParse(response: Response, slug: String): AnimesPage {
val details = animeDetailsParse(response)
details.url = "/$slug"
return AnimesPage(listOf(details), false)
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request = throw Exception("not used")
private fun searchAnimeRequest(
page: Int,
query: String,
filters: AniWatchFilters.FilterSearchParams,
): Request {
val url = if (query.isEmpty()) {
"$baseUrl/filter".toHttpUrlOrNull()!!.newBuilder()
.addQueryParameter("page", page.toString())
} else {
"$baseUrl/search".toHttpUrlOrNull()!!.newBuilder()
.addQueryParameter("page", page.toString())
.addQueryParameter("keyword", query)
}.addIfNotBlank("type", filters.type)
.addIfNotBlank("status", filters.status)
.addIfNotBlank("rated", filters.rated)
.addIfNotBlank("score", filters.score)
.addIfNotBlank("season", filters.season)
.addIfNotBlank("language", filters.language)
.addIfNotBlank("sort", filters.sort)
.addIfNotBlank("sy", filters.start_year)
.addIfNotBlank("sm", filters.start_month)
.addIfNotBlank("sd", filters.start_day)
.addIfNotBlank("ey", filters.end_year)
.addIfNotBlank("em", filters.end_month)
.addIfNotBlank("ed", filters.end_day)
.addIfNotBlank("genres", filters.genres)
return GET(url.build().toString())
}
override fun getFilterList(): AnimeFilterList = AniWatchFilters.FILTER_LIST
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime {
val anime = SAnime.create()
val info = document.selectFirst("div.anisc-info")!!
val detail = document.selectFirst("div.anisc-detail")!!
anime.thumbnail_url = document.selectFirst("div.anisc-poster img")!!.attr("src")
anime.title = detail.selectFirst("h2")!!.attr("data-jname")
anime.author = info.getInfo("Studios:")
anime.status = parseStatus(info.getInfo("Status:"))
anime.genre = info.getInfo("Genres:", isList = true)
var description = info.getInfo("Overview:")?.let { it + "\n" } ?: ""
detail.select("div.film-stats div.tick-dub")?.let {
description += "\nLanguage: " + it.joinToString(", ") { lang -> lang.text() }
}
info.getInfo("Aired:", full = true)?.let { description += it }
info.getInfo("Premiered:", full = true)?.let { description += it }
info.getInfo("Synonyms:", full = true)?.let { description += it }
info.getInfo("Japanese:", full = true)?.let { description += it }
anime.description = description
return anime
}
// =============================== Latest ===============================
override fun latestUpdatesNextPageSelector(): String = popularAnimeNextPageSelector()
override fun latestUpdatesFromElement(element: Element) = popularAnimeFromElement(element)
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/top-airing")
override fun latestUpdatesSelector(): String = popularAnimeSelector()
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val domainPref = ListPreference(screen.context).apply {
ListPreference(screen.context).apply {
key = PREF_DOMAIN_KEY
title = PREF_DOMAIN_TITLE
entries = PREF_DOMAIN_ENTRIES
@ -350,14 +282,14 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
}.also(screen::addPreference)
val videoQualityPref = ListPreference(screen.context).apply {
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = PREF_QUALITY_TITLE
entries = PREF_QUALITY_ENTRIES
entryValues = PREF_QUALITY_ENTRIES
setDefaultValue("720p")
setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
@ -366,14 +298,14 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
}.also(screen::addPreference)
val epTypePref = ListPreference(screen.context).apply {
ListPreference(screen.context).apply {
key = PREF_TYPE_KEY
title = PREF_TYPE_TITLE
entries = PREF_TYPE_ENTRIES
entryValues = PREF_TYPE_ENTRIES
setDefaultValue("dub")
setDefaultValue(PREF_TYPE_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
@ -382,14 +314,14 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
}.also(screen::addPreference)
val subLangPref = ListPreference(screen.context).apply {
ListPreference(screen.context).apply {
key = PREF_SUB_KEY
title = PREF_SUB_TITLE
entries = PREF_SUB_ENTRIES
entryValues = PREF_SUB_ENTRIES
setDefaultValue("English")
setDefaultValue(PREF_SUB_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
@ -398,25 +330,25 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
}.also(screen::addPreference)
val markFillers = SwitchPreferenceCompat(screen.context).apply {
SwitchPreferenceCompat(screen.context).apply {
key = MARK_FILLERS_KEY
title = MARK_FILLERS_TITLE
setDefaultValue(MARK_FILLERS_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putBoolean(key, newValue as Boolean).commit()
}
}
screen.addPreference(domainPref)
screen.addPreference(videoQualityPref)
screen.addPreference(epTypePref)
screen.addPreference(subLangPref)
screen.addPreference(markFillers)
}.also(screen::addPreference)
}
// ============================= Utilities ==============================
private inline fun <reified T> Response.parseAs(): T {
return use { it.body.string() }.let(json::decodeFromString)
}
@Serializable
private data class HtmlResponse(val html: String)
private fun parseStatus(statusString: String?): Int {
return when (statusString) {
@ -432,13 +364,12 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
full: Boolean = false,
): String? {
if (isList) {
val elements = select("div.item-list:contains($tag) > a")
return elements.joinToString(", ") { it.text() }
return select("div.item-list:contains($tag) > a").eachText().joinToString()
}
val targetElement = selectFirst("div.item-title:contains($tag)")
?: return null
val value = targetElement.selectFirst("*.name, *.text")!!.text()
return if (full) "\n$tag $value" else value
val value = selectFirst("div.item-title:contains($tag)")
?.selectFirst("*.name, *.text")
?.text()
return if (full && value != null) "\n$tag $value" else value
}
private fun HttpUrl.Builder.addIfNotBlank(query: String, value: String): HttpUrl.Builder {
@ -448,7 +379,7 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
return this
}
private fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> =
private inline fun <A, B> Iterable<A>.parallelMap(crossinline f: suspend (A) -> B): List<B> =
runBlocking {
map { async(Dispatchers.Default) { f(it) } }.awaitAll()
}
@ -457,6 +388,7 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
const val PREFIX_SEARCH = "slug:"
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_TITLE = "Preferred video quality"
private const val PREF_QUALITY_DEFAULT = "720p"
private val PREF_QUALITY_ENTRIES = arrayOf("360p", "720p", "1080p")
private const val PREF_DOMAIN_KEY = "preferred_domain"
@ -467,10 +399,12 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
private const val PREF_TYPE_KEY = "preferred_type"
private const val PREF_TYPE_TITLE = "Preferred episode type/mode"
private const val PREF_TYPE_DEFAULT = "dub"
private val PREF_TYPE_ENTRIES = arrayOf("sub", "dub")
private const val PREF_SUB_KEY = "preferred_subLang"
private const val PREF_SUB_TITLE = "Preferred sub language"
private const val PREF_SUB_DEFAULT = "English"
private val PREF_SUB_ENTRIES = arrayOf(
"English",
"Spanish",

View File

@ -0,0 +1,23 @@
package eu.kanade.tachiyomi.animeextension.en.zoro.dto
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class VideoDto(
val sources: List<VideoLink>,
val tracks: List<TrackDto>? = null,
)
@Serializable
data class SourceResponseDto(
val sources: JsonElement,
val encrypted: Boolean = true,
val tracks: List<TrackDto>? = null,
)
@Serializable
data class VideoLink(val file: String = "")
@Serializable
data class TrackDto(val file: String, val kind: String, val label: String = "")

View File

@ -1,47 +1,69 @@
package eu.kanade.tachiyomi.animeextension.en.zoro.extractors
import eu.kanade.tachiyomi.animeextension.en.zoro.utils.Decryptor
import eu.kanade.tachiyomi.animeextension.en.zoro.dto.SourceResponseDto
import eu.kanade.tachiyomi.animeextension.en.zoro.dto.VideoDto
import eu.kanade.tachiyomi.animeextension.en.zoro.dto.VideoLink
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
import eu.kanade.tachiyomi.network.GET
import okhttp3.CacheControl
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
class AniWatchExtractor(private val client: OkHttpClient) {
// Prevent (automatic) caching the .JS file for different episodes, because it
// changes everytime, and a cached old .js will have a invalid AES password,
// invalidating the decryption algorithm.
// We cache it manually when initializing the class.
private val cacheControl = CacheControl.Builder().noStore().build()
private val newClient = client.newBuilder()
.cache(null)
.build()
private val json: Json by injectLazy()
companion object {
private val SERVER_URL = arrayOf("https://megacloud.tv", "https://rapid-cloud.co")
private val SOURCES_URL = arrayOf("/embed-2/ajax/e-1/getSources?id=", "/ajax/embed-6/getSources?id=")
private val SOURCES_SPLITTER = arrayOf("/e-1/", "/embed-6/")
private val SOURCES_KEY = arrayOf("6", "0")
private val SOURCES_URL = arrayOf("/embed-2/ajax/e-1/getSources?id=", "/ajax/embed-6-v2/getSources?id=")
private val SOURCES_SPLITTER = arrayOf("/e-1/", "/embed-6-v2/")
private val SOURCES_KEY = arrayOf("1", "6")
}
fun getSourcesJson(url: String): String? {
private fun cipherTextCleaner(data: String, type: String): Pair<String, String> {
// TODO: fetch the key only when needed, using a thread-safe map
// (Like ConcurrentMap?) or MUTEX hacks.
val indexPairs = client.newCall(GET("https://raw.githubusercontent.com/Claudemirovsky/keys/e$type/key"))
.execute()
.use { it.body.string() }
.let { json.decodeFromString<List<List<Int>>>(it) }
val (password, ciphertext, _) = indexPairs.fold(Triple("", data, 0)) { previous, item ->
val start = item.first() + previous.third
val end = start + item.last()
val passSubstr = data.substring(start, end)
val passPart = previous.first + passSubstr
val cipherPart = previous.second.replace(passSubstr, "")
Triple(passPart, cipherPart, previous.third + item.last())
}
return Pair(ciphertext, password)
}
private fun tryDecrypting(ciphered: String, type: String, attempts: Int = 0): String {
if (attempts > 2) throw Exception("PLEASE NUKE ANIWATCH AND CLOUDFLARE")
val (ciphertext, password) = cipherTextCleaner(ciphered, type)
return CryptoAES.decrypt(ciphertext, password).ifEmpty {
tryDecrypting(ciphered, type, attempts + 1)
}
}
fun getVideoDto(url: String): VideoDto {
val type = if (url.startsWith("https://megacloud.tv")) 0 else 1
val keyType = SOURCES_KEY[type]
val id = url.substringAfter(SOURCES_SPLITTER[type], "")
.substringBefore("?", "").ifEmpty { return null }
val srcRes = newClient.newCall(GET(SERVER_URL[type] + SOURCES_URL[type] + id, cache = cacheControl))
.substringBefore("?", "").ifEmpty { throw Exception("I HATE THE ANTICHRIST") }
val srcRes = client.newCall(GET(SERVER_URL[type] + SOURCES_URL[type] + id))
.execute()
.body.string()
.use { it.body.string() }
val key = newClient.newCall(GET("https://raw.githubusercontent.com/enimax-anime/key/e$keyType/key.txt"))
.execute()
.body.string()
val data = json.decodeFromString<SourceResponseDto>(srcRes)
if (!data.encrypted) return json.decodeFromString<VideoDto>(srcRes)
if ("\"encrypted\":false" in srcRes) return srcRes
if (!srcRes.contains("{\"sources\":")) return null
val encrypted = srcRes.substringAfter("sources\":\"").substringBefore("\"")
val decrypted = Decryptor.decrypt(encrypted, key) ?: return null
val end = srcRes.replace("\"$encrypted\"", decrypted)
return end
val ciphered = data.sources.jsonPrimitive.content.toString()
val decrypted = json.decodeFromString<List<VideoLink>>(tryDecrypting(ciphered, keyType))
return VideoDto(decrypted, data.tracks)
}
}

View File

@ -1,74 +0,0 @@
package eu.kanade.tachiyomi.animeextension.en.zoro.utils
import android.util.Base64
import java.security.DigestException
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
object Decryptor {
fun decrypt(encodedData: String, remoteKey: String): String? {
val saltedData = Base64.decode(encodedData, Base64.DEFAULT)
val salt = saltedData.copyOfRange(8, 16)
val ciphertext = saltedData.copyOfRange(16, saltedData.size)
val password = remoteKey.toByteArray()
val (key, iv) = generateKeyAndIv(password, salt) ?: return null
val keySpec = SecretKeySpec(key, "AES")
val ivSpec = IvParameterSpec(iv)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec)
val decryptedData = String(cipher.doFinal(ciphertext))
return decryptedData
}
// https://stackoverflow.com/a/41434590/8166854
private fun generateKeyAndIv(
password: ByteArray,
salt: ByteArray,
hashAlgorithm: String = "MD5",
keyLength: Int = 32,
ivLength: Int = 16,
iterations: Int = 1,
): List<ByteArray>? {
val md = MessageDigest.getInstance(hashAlgorithm)
val digestLength = md.getDigestLength()
val targetKeySize = keyLength + ivLength
val requiredLength = (targetKeySize + digestLength - 1) / digestLength * digestLength
var generatedData = ByteArray(requiredLength)
var generatedLength = 0
try {
md.reset()
while (generatedLength < targetKeySize) {
if (generatedLength > 0) {
md.update(
generatedData,
generatedLength - digestLength,
digestLength,
)
}
md.update(password)
md.update(salt, 0, 8)
md.digest(generatedData, generatedLength, digestLength)
for (i in 1 until iterations) {
md.update(generatedData, generatedLength, digestLength)
md.digest(generatedData, generatedLength, digestLength)
}
generatedLength += digestLength
}
val result = listOf(
generatedData.copyOfRange(0, keyLength),
generatedData.copyOfRange(keyLength, targetKeySize),
)
return result
} catch (e: DigestException) {
return null
}
}
}

View File

@ -1,64 +0,0 @@
package eu.kanade.tachiyomi.animeextension.en.zoro.utils
import app.cash.quickjs.QuickJs
object FindPassword {
fun getPassword(js: String): String {
val passVar = js.substringAfter("CryptoJS[")
.substringBefore("JSON")
.substringBeforeLast(")")
.substringAfterLast(",")
.trim()
val passValue = js.substringAfter("const $passVar=", "").substringBefore(";", "")
if (passValue.isNotBlank()) {
if (passValue.startsWith("'")) {
return passValue.trim('\'')
}
return getPasswordFromJS(js, "(" + passValue.substringAfter("("))
}
val jsEnd = js.substringBefore("jwplayer(").substringBeforeLast("var")
val suspiciousPass = jsEnd.substringBeforeLast("'").substringAfterLast("'")
if (suspiciousPass.length < 8) {
// something like (0x420,'NZsZ')
val funcArgs = jsEnd.substringAfterLast("(0x").substringBefore(")")
return getPasswordFromJS(js, "(0x" + funcArgs + ")")
}
return suspiciousPass
}
private fun getPasswordFromJS(js: String, getKeyArgs: String): String {
var script = "(function" + js.substringBefore(",(!function")
.substringAfter("(function") + ")"
val decoderFunName = script.substringAfter("=").substringBefore(",")
val decoderFunPrefix = "function " + decoderFunName
var decoderFunBody = js.substringAfter(decoderFunPrefix)
val decoderFunSuffix = decoderFunName + decoderFunBody.substringBefore("{") + ";}"
decoderFunBody = (
decoderFunPrefix +
decoderFunBody.substringBefore(decoderFunSuffix) +
decoderFunSuffix
)
if ("=[" !in js.substring(0, 20)) {
val superArrayName = decoderFunBody.substringAfter("=")
.substringBefore(";")
val superArrayPrefix = "function " + superArrayName
val superArraySuffix = "return " + superArrayName + ";}"
val superArrayBody = (
superArrayPrefix +
js.substringAfter(superArrayPrefix)
.substringBefore(superArraySuffix) +
superArraySuffix
)
script += "\n\n" + superArrayBody
}
script += "\n\n" + decoderFunBody
script += "\n\n" + decoderFunName + getKeyArgs
val qjs = QuickJs.create()
// this part can be really slow, like 5s or even more >:(
val result = qjs.evaluate(script).toString()
qjs.close()
return result
}
}

View File

@ -1,76 +0,0 @@
package eu.kanade.tachiyomi.animeextension.en.zoro.utils
object JSONUtil {
fun escape(input: String): String {
val output = StringBuilder()
for (ch in input) {
// let's not put any nulls in our strings
val charInt = ch.code
assert(charInt != 0)
// 0x10000 = 65536 = 2^16 = u16 max value
assert(charInt < 0x10000) { "Java stores as u16, so it should never give us a character that's bigger than 2 bytes. It literally can't." }
val escapedChar = when (ch) {
'\b' -> "\\b"
'\u000C' -> "\\f" // '\u000C' == '\f', Kotlin doesnt support \f
'\n' -> "\\n"
'\r' -> "\\r"
'\t' -> "\\t"
'\\' -> "\\\\"
'"' -> "\\\""
else -> {
if (charInt > 127) {
String.format("\\u%04x", charInt)
} else {
ch
}
}
}
output.append(escapedChar)
}
return output.toString()
}
fun unescape(input: String): String {
val builder = StringBuilder()
var index = 0
while (index < input.length) {
val delimiter = input.get(index) // consume letter or backslash
index++
if (delimiter == '\\' && index < input.length) {
// consume first after backslash
val ch = input.get(index)
index++
val unescaped = when (ch) {
'\\', '/', '"', '\'' -> ch // "
'b' -> '\b'
'f' -> '\u000C' // '\f' in java
'n' -> '\n'
'r' -> '\r'
't' -> '\t'
'u' -> {
if ((index + 4) > input.length) {
throw RuntimeException("Not enough unicode digits!")
}
val hex = input.substring(index, index + 4)
if (hex.any { !it.isLetterOrDigit() }) {
throw RuntimeException("Bad character in unicode escape.")
}
hex.toInt(16).toChar()
}
else -> throw RuntimeException("Illegal escape sequence: \\" + ch)
}
builder.append(unescaped)
} else {
builder.append(delimiter)
}
}
return builder.toString()
}
}