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' plugins {
apply plugin: 'kotlin-android' alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
}
ext { ext {
extName = 'AniWatch.to' extName = 'AniWatch.to'
pkgNameSuffix = 'en.zoro' pkgNameSuffix = 'en.zoro'
extClass = '.AniWatch' extClass = '.AniWatch'
extVersionCode = 32 extVersionCode = 33
libVersion = '13' libVersion = '13'
} }
dependencies { dependencies {
implementation(project(':lib-streamtape-extractor')) implementation(project(':lib-streamtape-extractor'))
implementation(project(':lib-streamsb-extractor')) implementation(project(':lib-cryptoaes'))
implementation(project(':lib-playlist-utils'))
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"

View File

@ -5,8 +5,8 @@ import android.content.SharedPreferences
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat 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.extractors.AniWatchExtractor
import eu.kanade.tachiyomi.animeextension.en.zoro.utils.JSONUtil
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage 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.Track
import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.ParsedAnimeHttpSource 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.lib.streamtapeextractor.StreamTapeExtractor
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
@ -23,14 +23,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json 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.Headers
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@ -59,6 +57,8 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
private val json: Json by injectLazy() private val json: Json by injectLazy()
private val ajaxRoute by lazy { if (baseUrl == "https://kaido.to") "" else "/v2" }
private val preferences: SharedPreferences by lazy { private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000) 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]" 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 ============================== // ============================== Episodes ==============================
override fun episodeListSelector() = "ul#episode_page li a" override fun episodeListSelector() = "ul#episode_page li a"
override fun episodeListRequest(anime: SAnime): Request { override fun episodeListRequest(anime: SAnime): Request {
val id = anime.url.substringAfterLast("-") val id = anime.url.substringAfterLast("-")
val referer = Headers.headersOf("Referer", baseUrl + anime.url) 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) return GET("$baseUrl/ajax$ajaxRoute/episode/list/$id", referer)
} }
override fun episodeListParse(response: Response): List<SEpisode> { override fun episodeListParse(response: Response): List<SEpisode> {
val data = response.body.string() val document = Jsoup.parse(response.parseAs<HtmlResponse>().html)
.substringAfter("\"html\":\"")
.substringBefore("<script>") return document.select("a.ep-item")
val unescapedData = JSONUtil.unescape(data) .map(::episodeFromElement)
val document = Jsoup.parse(unescapedData) .reversed()
val episodeList = document.select("a.ep-item").map { }
SEpisode.create().apply {
episode_number = it.attr("data-number").toFloat() override fun episodeFromElement(element: Element) = SEpisode.create().apply {
name = "Episode ${it.attr("data-number")}: ${it.attr("title")}" episode_number = element.attr("data-number").toFloatOrNull() ?: 1F
url = it.attr("href") name = "Episode ${element.attr("data-number")}: ${element.attr("title")}"
if (it.hasClass("ssl-item-filler") && preferences.getBoolean(MARK_FILLERS_KEY, MARK_FILLERS_DEFAULT)) { setUrlWithoutDomain(element.attr("href"))
if (element.hasClass("ssl-item-filler") && preferences.getBoolean(MARK_FILLERS_KEY, MARK_FILLERS_DEFAULT)) {
scanlator = "Filler Episode" scanlator = "Filler Episode"
} }
} }
}
return episodeList.reversed()
}
override fun episodeFromElement(element: Element) = throw Exception("not used")
// ============================ Video Links ============================= // ============================ Video Links =============================
override fun videoListRequest(episode: SEpisode): Request { override fun videoListRequest(episode: SEpisode): Request {
val id = episode.url.substringAfterLast("?ep=") val id = episode.url.substringAfterLast("?ep=")
val referer = Headers.headersOf("Referer", baseUrl + episode.url) 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) return GET("$baseUrl/ajax$ajaxRoute/episode/servers?episodeId=$id", referer)
} }
private val aniwatchExtractor by lazy { AniWatchExtractor(client) }
override fun videoListParse(response: Response): List<Video> { override fun videoListParse(response: Response): List<Video> {
val body = response.body.string()
val episodeReferer = Headers.headersOf("Referer", response.request.header("referer")!!) val episodeReferer = Headers.headersOf("Referer", response.request.header("referer")!!)
val data = body.substringAfter("\"html\":\"").substringBefore("<script>") val serversDoc = Jsoup.parse(response.parseAs<HtmlResponse>().html)
val unescapedData = JSONUtil.unescape(data) return serversDoc.select("div.server-item")
val serversHtml = Jsoup.parse(unescapedData)
val extractor = AniWatchExtractor(client)
val videoList = serversHtml.select("div.server-item")
.parallelMap { server -> .parallelMap { server ->
val name = server.text() val name = server.text()
val id = server.attr("data-id") val id = server.attr("data-id")
val subDub = server.attr("data-type") 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 url = "$baseUrl/ajax$ajaxRoute/episode/sources?id=$id"
val reqBody = client.newCall(GET(url, episodeReferer)).execute() val reqBody = client.newCall(GET(url, episodeReferer)).execute()
.body.string() .use { it.body.string() }
val sourceUrl = reqBody.substringAfter("\"link\":\"") val sourceUrl = reqBody.substringAfter("\"link\":\"")
.substringBefore("\"") .substringBefore("\"")
runCatching { runCatching {
when { when {
"Vidstreaming" in name || "Vidcloud" in name -> { "Vidstreaming" in name || "Vidcloud" in name -> {
val source = extractor.getSourcesJson(sourceUrl) aniwatchExtractor.getVideoDto(sourceUrl).let {
source?.let { getVideosFromServer(it, subDub, name) } getVideosFromServer(it, subDub, name)
} }
"StreamSB" in name -> {
StreamSBExtractor(client)
.videosFromUrl(sourceUrl, headers, suffix = "- $subDub")
} }
"Streamtape" in name -> "Streamtape" in name ->
StreamTapeExtractor(client) StreamTapeExtractor(client)
.videoFromUrl(sourceUrl, "StreamTape - $subDub") .videoFromUrl(sourceUrl, "StreamTape - $subDub")
?.let { listOf(it) } ?.let(::listOf)
else -> null else -> null
} }
}.getOrNull() }.onFailure { it.printStackTrace() }.getOrNull() ?: emptyList()
} }.flatten()
.filterNotNull()
.flatten()
return videoList
} }
private fun getVideosFromServer(source: String, subDub: String, name: String): List<Video>? { private val playlistUtils by lazy { PlaylistUtils(client, headers) }
if (!source.contains("{\"sources\":[{\"file\":\"")) return null
val json = json.decodeFromString<JsonObject>(source) private fun getVideosFromServer(video: VideoDto, subDub: String, name: String): List<Video> {
val masterUrl = json["sources"]!!.jsonArray[0].jsonObject["file"]!!.jsonPrimitive.content val masterUrl = video.sources.first().file
val subs2 = mutableListOf<Track>() val subs2 = video.tracks
json["tracks"]?.jsonArray ?.filter { it.kind == "captions" }
?.filter { it.jsonObject["kind"]!!.jsonPrimitive.content == "captions" } ?.mapNotNull { Track(it.file, it.label) }
?.map { track -> ?: emptyList<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()
val subs = subLangOrder(subs2) val subs = subLangOrder(subs2)
val prefix = "#EXT-X-STREAM-INF:" return playlistUtils.extractFromHls(
val playlist = client.newCall(GET(masterUrl)).execute() masterUrl,
.body.string() videoNameGen = { "$name - $it - $subDub" },
val videoList = playlist.substringAfter(prefix).split(prefix).map { subtitleList = subs,
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
} }
override fun videoListSelector() = throw Exception("not used") override fun videoListSelector() = throw Exception("not used")
@ -199,144 +248,27 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override fun videoUrlParse(document: Document) = throw Exception("not used") 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> { override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, "720p")!! val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
val type = preferences.getString(PREF_TYPE_KEY, "dub")!! val type = preferences.getString(PREF_TYPE_KEY, PREF_TYPE_DEFAULT)!!
val newList = this.sortIfContains(type).sortIfContains(quality) return sortedWith(
return newList compareBy(
{ it.quality.contains(quality) },
{ it.quality.contains(type) },
),
).reversed()
} }
private fun subLangOrder(tracks: List<Track>): List<Track> { private fun subLangOrder(tracks: List<Track>): List<Track> {
val language = preferences.getString(PREF_SUB_KEY, null) val language = preferences.getString(PREF_SUB_KEY, PREF_SUB_DEFAULT)!!
if (language != null) { return tracks.sortedWith(
val newList = mutableListOf<Track>() compareBy { it.lang.contains(language) },
var preferred = 0 ).reversed()
for (track in tracks) {
if (track.lang == language) {
newList.add(preferred, track)
preferred++
} else {
newList.add(track)
} }
}
return newList
}
return tracks
}
// =============================== 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 ============================== // ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
val domainPref = ListPreference(screen.context).apply { ListPreference(screen.context).apply {
key = PREF_DOMAIN_KEY key = PREF_DOMAIN_KEY
title = PREF_DOMAIN_TITLE title = PREF_DOMAIN_TITLE
entries = PREF_DOMAIN_ENTRIES entries = PREF_DOMAIN_ENTRIES
@ -350,14 +282,14 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val entry = entryValues[index] as String val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit() preferences.edit().putString(key, entry).commit()
} }
} }.also(screen::addPreference)
val videoQualityPref = ListPreference(screen.context).apply { ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY key = PREF_QUALITY_KEY
title = PREF_QUALITY_TITLE title = PREF_QUALITY_TITLE
entries = PREF_QUALITY_ENTRIES entries = PREF_QUALITY_ENTRIES
entryValues = PREF_QUALITY_ENTRIES entryValues = PREF_QUALITY_ENTRIES
setDefaultValue("720p") setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s" summary = "%s"
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
@ -366,14 +298,14 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val entry = entryValues[index] as String val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit() preferences.edit().putString(key, entry).commit()
} }
} }.also(screen::addPreference)
val epTypePref = ListPreference(screen.context).apply { ListPreference(screen.context).apply {
key = PREF_TYPE_KEY key = PREF_TYPE_KEY
title = PREF_TYPE_TITLE title = PREF_TYPE_TITLE
entries = PREF_TYPE_ENTRIES entries = PREF_TYPE_ENTRIES
entryValues = PREF_TYPE_ENTRIES entryValues = PREF_TYPE_ENTRIES
setDefaultValue("dub") setDefaultValue(PREF_TYPE_DEFAULT)
summary = "%s" summary = "%s"
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
@ -382,14 +314,14 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val entry = entryValues[index] as String val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit() preferences.edit().putString(key, entry).commit()
} }
} }.also(screen::addPreference)
val subLangPref = ListPreference(screen.context).apply { ListPreference(screen.context).apply {
key = PREF_SUB_KEY key = PREF_SUB_KEY
title = PREF_SUB_TITLE title = PREF_SUB_TITLE
entries = PREF_SUB_ENTRIES entries = PREF_SUB_ENTRIES
entryValues = PREF_SUB_ENTRIES entryValues = PREF_SUB_ENTRIES
setDefaultValue("English") setDefaultValue(PREF_SUB_DEFAULT)
summary = "%s" summary = "%s"
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
@ -398,25 +330,25 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val entry = entryValues[index] as String val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit() preferences.edit().putString(key, entry).commit()
} }
} }.also(screen::addPreference)
val markFillers = SwitchPreferenceCompat(screen.context).apply { SwitchPreferenceCompat(screen.context).apply {
key = MARK_FILLERS_KEY key = MARK_FILLERS_KEY
title = MARK_FILLERS_TITLE title = MARK_FILLERS_TITLE
setDefaultValue(MARK_FILLERS_DEFAULT) setDefaultValue(MARK_FILLERS_DEFAULT)
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
preferences.edit().putBoolean(key, newValue as Boolean).commit() preferences.edit().putBoolean(key, newValue as Boolean).commit()
} }
} }.also(screen::addPreference)
screen.addPreference(domainPref)
screen.addPreference(videoQualityPref)
screen.addPreference(epTypePref)
screen.addPreference(subLangPref)
screen.addPreference(markFillers)
} }
// ============================= Utilities ============================== // ============================= 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 { private fun parseStatus(statusString: String?): Int {
return when (statusString) { return when (statusString) {
@ -432,13 +364,12 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
full: Boolean = false, full: Boolean = false,
): String? { ): String? {
if (isList) { if (isList) {
val elements = select("div.item-list:contains($tag) > a") return select("div.item-list:contains($tag) > a").eachText().joinToString()
return elements.joinToString(", ") { it.text() }
} }
val targetElement = selectFirst("div.item-title:contains($tag)") val value = selectFirst("div.item-title:contains($tag)")
?: return null ?.selectFirst("*.name, *.text")
val value = targetElement.selectFirst("*.name, *.text")!!.text() ?.text()
return if (full) "\n$tag $value" else value return if (full && value != null) "\n$tag $value" else value
} }
private fun HttpUrl.Builder.addIfNotBlank(query: String, value: String): HttpUrl.Builder { private fun HttpUrl.Builder.addIfNotBlank(query: String, value: String): HttpUrl.Builder {
@ -448,7 +379,7 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
return this 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 { runBlocking {
map { async(Dispatchers.Default) { f(it) } }.awaitAll() map { async(Dispatchers.Default) { f(it) } }.awaitAll()
} }
@ -457,6 +388,7 @@ class AniWatch : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
const val PREFIX_SEARCH = "slug:" const val PREFIX_SEARCH = "slug:"
private const val PREF_QUALITY_KEY = "preferred_quality" private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_TITLE = "Preferred video 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 val PREF_QUALITY_ENTRIES = arrayOf("360p", "720p", "1080p")
private const val PREF_DOMAIN_KEY = "preferred_domain" 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_KEY = "preferred_type"
private const val PREF_TYPE_TITLE = "Preferred episode type/mode" 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 val PREF_TYPE_ENTRIES = arrayOf("sub", "dub")
private const val PREF_SUB_KEY = "preferred_subLang" private const val PREF_SUB_KEY = "preferred_subLang"
private const val PREF_SUB_TITLE = "Preferred sub language" private const val PREF_SUB_TITLE = "Preferred sub language"
private const val PREF_SUB_DEFAULT = "English"
private val PREF_SUB_ENTRIES = arrayOf( private val PREF_SUB_ENTRIES = arrayOf(
"English", "English",
"Spanish", "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 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 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 okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
class AniWatchExtractor(private val client: OkHttpClient) { class AniWatchExtractor(private val client: OkHttpClient) {
private val json: Json by injectLazy()
// 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()
companion object { companion object {
private val SERVER_URL = arrayOf("https://megacloud.tv", "https://rapid-cloud.co") 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_URL = arrayOf("/embed-2/ajax/e-1/getSources?id=", "/ajax/embed-6-v2/getSources?id=")
private val SOURCES_SPLITTER = arrayOf("/e-1/", "/embed-6/") private val SOURCES_SPLITTER = arrayOf("/e-1/", "/embed-6-v2/")
private val SOURCES_KEY = arrayOf("6", "0") 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 type = if (url.startsWith("https://megacloud.tv")) 0 else 1
val keyType = SOURCES_KEY[type] val keyType = SOURCES_KEY[type]
val id = url.substringAfter(SOURCES_SPLITTER[type], "") val id = url.substringAfter(SOURCES_SPLITTER[type], "")
.substringBefore("?", "").ifEmpty { return null } .substringBefore("?", "").ifEmpty { throw Exception("I HATE THE ANTICHRIST") }
val srcRes = newClient.newCall(GET(SERVER_URL[type] + SOURCES_URL[type] + id, cache = cacheControl)) val srcRes = client.newCall(GET(SERVER_URL[type] + SOURCES_URL[type] + id))
.execute() .execute()
.body.string() .use { it.body.string() }
val key = newClient.newCall(GET("https://raw.githubusercontent.com/enimax-anime/key/e$keyType/key.txt")) val data = json.decodeFromString<SourceResponseDto>(srcRes)
.execute() if (!data.encrypted) return json.decodeFromString<VideoDto>(srcRes)
.body.string()
if ("\"encrypted\":false" in srcRes) return srcRes val ciphered = data.sources.jsonPrimitive.content.toString()
if (!srcRes.contains("{\"sources\":")) return null val decrypted = json.decodeFromString<List<VideoLink>>(tryDecrypting(ciphered, keyType))
val encrypted = srcRes.substringAfter("sources\":\"").substringBefore("\"") return VideoDto(decrypted, data.tracks)
val decrypted = Decryptor.decrypt(encrypted, key) ?: return null
val end = srcRes.replace("\"$encrypted\"", decrypted)
return end
} }
} }

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()
}
}