feat(hi/animesaga): Convert to dooplay multisrc (#2145)

This commit is contained in:
Claudemirovsky 2023-09-04 07:27:48 -03:00 committed by GitHub
parent ac23f4700e
commit 3341c3db12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 76 additions and 500 deletions

View File

@ -10,6 +10,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.OkHttpClient
import org.jsoup.Jsoup
import uy.kohesive.injekt.injectLazy
class ChillxExtractor(private val client: OkHttpClient, private val headers: Headers) {
@ -23,6 +24,7 @@ class ChillxExtractor(private val client: OkHttpClient, private val headers: Hea
private val REGEX_MASTER_JS by lazy { Regex("""MasterJS\s*=\s*'([^']+)""") }
private val REGEX_SOURCES by lazy { Regex("""sources:\s*\[\{"file":"([^"]+)""") }
private val REGEX_FILE by lazy { Regex("""file: ?"([^"]+)"""") }
private val REGEX_SOURCE by lazy { Regex("""source = ?"([^"]+)"""")}
// matches "[language]https://...,"
private val REGEX_SUBS by lazy { Regex("""\[(.*?)\](.*?)"?\,""") }
@ -41,9 +43,17 @@ class ChillxExtractor(private val client: OkHttpClient, private val headers: Hea
val masterUrl = REGEX_SOURCES.find(decryptedScript)?.groupValues?.get(1)
?: REGEX_FILE.find(decryptedScript)?.groupValues?.get(1)
?: REGEX_SOURCE.find(decryptedScript)?.groupValues?.get(1)
?: return emptyList()
val subtitleList = buildList<Track> {
body.takeIf { it.contains("<track kind=\"captions\"") }
?.let(Jsoup::parse)
?.select("track[kind=captions]")
?.forEach {
add(Track(it.attr("src"), it.attr("label")))
}
decryptedScript.takeIf { it.contains("subtitle:") }
?.substringAfter("subtitle: ")
?.substringBefore("\n")

View File

@ -0,0 +1,3 @@
dependencies {
implementation(project(":lib-chillx-extractor"))
}

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,62 @@
package eu.kanade.tachiyomi.animeextension.hi.animesaga
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.chillxextractor.ChillxExtractor
import eu.kanade.tachiyomi.multisrc.dooplay.DooPlay
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.FormBody
import okhttp3.Response
import org.jsoup.nodes.Element
class AnimeSAGA : DooPlay(
"hi",
"AnimeSAGA",
"https://www.animesaga.in",
) {
private val videoHost = "https://cdn.animesaga.in"
// ============================== Popular ===============================
override fun popularAnimeSelector() = "div.top-imdb-list > div.top-imdb-item"
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val playerUrls = response.use { it.asJsoup() }
.select("ul#playeroptionsul li:not([id=player-option-trailer])")
.map(::getPlayerUrl)
return playerUrls.flatMap { url ->
runCatching {
getPlayerVideos(url)
}.getOrElse { emptyList() }
}
}
private val chillxExtractor by lazy { ChillxExtractor(client, headers) }
private fun getPlayerVideos(url: String): List<Video> {
return when {
videoHost in url -> chillxExtractor.videoFromUrl(url, "$baseUrl/")
else -> emptyList()
}
}
private fun getPlayerUrl(player: Element): String {
val body = FormBody.Builder()
.add("action", "doo_player_ajax")
.add("post", player.attr("data-post"))
.add("nume", player.attr("data-nume"))
.add("type", player.attr("data-type"))
.build()
return client.newCall(POST("$baseUrl/wp-admin/admin-ajax.php", headers, body))
.execute()
.use { response ->
response
.use { it.body.string() }
.substringAfter("\"embed_url\":\"")
.substringBefore("\",")
.replace("\\", "")
}
}
}

View File

@ -17,6 +17,7 @@ class DooPlayGenerator : ThemeSourceGenerator {
SingleLang("AnimePlayer", "https://animeplayer.com.br", "pt-BR", isNsfw = true, overrideVersionCode = 1),
SingleLang("AnimePlayer", "https://animeplayer.com.br", "pt-BR", isNsfw = true),
SingleLang("AnimeSync", "https://animesync.org", "pt-BR", isNsfw = true),
SingleLang("AnimeSAGA", "https://www.animesaga.in", "hi", isNsfw = false, overrideVersionCode = 4),
SingleLang("AnimesFox BR", "https://animesfox.net", "pt-BR", isNsfw = false, overrideVersionCode = 2),
SingleLang("Animes House", "https://animeshouse.net", "pt-BR", isNsfw = false, overrideVersionCode = 5),
SingleLang("Cinemathek", "https://cinemathek.net", "de", isNsfw = true, overrideVersionCode = 15),

View File

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@ -1,16 +0,0 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
ext {
extName = 'AnimeSAGA'
pkgNameSuffix = 'hi.animesaga'
extClass = '.AnimeSAGA'
extVersionCode = 4
libVersion = '13'
}
dependencies {
implementation(project(':lib-chillx-extractor'))
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

@ -1,343 +0,0 @@
package eu.kanade.tachiyomi.animeextension.hi.animesaga
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.ParsedAnimeHttpSource
import eu.kanade.tachiyomi.lib.chillxextractor.ChillxExtractor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.lang.Exception
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.ceil
import kotlin.math.floor
class AnimeSAGA : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val name = "AnimeSAGA"
override val baseUrl = "https://www.animesaga.in"
private val videoHost = "cdn.animesaga.in"
override val lang = "hi"
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int): Request = GET("$baseUrl/series?sorting=popular".addPage(page), headers)
override fun popularAnimeSelector(): String = "div#content > div#content.row > div"
override fun popularAnimeFromElement(element: Element): SAnime = SAnime.create().apply {
setUrlWithoutDomain(element.selectFirst("a[href]")!!.attr("href"))
thumbnail_url = element.selectFirst("picture")?.getImage() ?: ""
title = element.selectFirst(".title")!!.text()
}
override fun popularAnimeNextPageSelector(): String = "ul.pagination > li.active + li:has(a)"
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request =
GET("$baseUrl/series?sorting=newest&released=1960;2023".addPage(page), headers)
override fun latestUpdatesSelector(): String = popularAnimeSelector()
override fun latestUpdatesFromElement(element: Element): SAnime = popularAnimeFromElement(element)
override fun latestUpdatesNextPageSelector(): String = popularAnimeNextPageSelector()
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
return if (query.isNotBlank()) {
require(query.length > 2) { "Search query must be longer than three letters" }
GET("$baseUrl/search/${query.replace("+", "")}".addPage(page), headers)
} else {
val filters = AnimeSAGAFilters.getSearchParameters(filters)
// Validation for ratings
if (filters.ratingStart.isNotEmpty()) {
require(filters.ratingEnd.isNotEmpty()) { "Both start and end must either be empty or populated" }
}
if (filters.ratingEnd.isNotEmpty()) {
require(filters.ratingStart.isNotEmpty()) { "Both start and end must either be empty or populated" }
}
if (filters.ratingStart.isNotEmpty()) {
require(filters.ratingStart.toFloatOrNull() != null) { "${filters.ratingStart} is not a float." }
require(filters.ratingEnd.toFloatOrNull() != null) { "${filters.ratingEnd} is not a float." }
require(filters.ratingStart.toFloat() in 5.0..10.0) { "Start must be between 5.0 and 10.0" }
require(filters.ratingEnd.toFloat() in 5.0..10.0) { "End must be between 5.0 and 10.0" }
}
// Create url
val url = baseUrl.toHttpUrl().newBuilder().apply {
addPathSegment(filters.type)
if (filters.sorting.isNotEmpty()) addEncodedQueryParameter("sorting", filters.sorting)
if (filters.genre.isNotEmpty()) addEncodedQueryParameter("genre", filters.genre)
if (filters.ratingStart.isNotEmpty()) addEncodedQueryParameter("imdb", "${filters.ratingStart.toFloat().stringify()};${filters.ratingEnd.toFloat().stringify()}")
addEncodedQueryParameter("released", "${filters.yearStart};${filters.yearEnd}")
}.toString().addPage(page)
GET(url, headers)
}
}
override fun searchAnimeParse(response: Response): AnimesPage {
return if (response.request.url.pathSegments.first() == "search") {
val document = response.asJsoup()
val animeList = document
.select("div.layout-section > div.row > div")
.map(::popularAnimeFromElement)
val hasNextPage = document.selectFirst(popularAnimeSelector()) != null
AnimesPage(animeList, hasNextPage)
} else {
super.searchAnimeParse(response)
}
}
override fun searchAnimeSelector(): String = popularAnimeSelector()
override fun searchAnimeFromElement(element: Element): SAnime = popularAnimeFromElement(element)
override fun searchAnimeNextPageSelector(): String = popularAnimeNextPageSelector()
// ============================== Filters ===============================
override fun getFilterList(): AnimeFilterList = AnimeSAGAFilters.FILTER_LIST
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime = SAnime.create().apply {
description = document.select("div.col-md > p.fs-sm").text()
genre = document.select("div.card-tag > a").joinToString(", ") { it.text() }
}
// ============================== Episodes ==============================
override fun episodeListParse(response: Response): List<SEpisode> {
val document = response.asJsoup()
val episodeList = mutableListOf<SEpisode>()
val seasonList = document.select("div#seasonAccordion > div.accordion-item")
// For movies
if (seasonList.size == 0) {
val dateStr: String? = document.select("ul.list-inline > li:not(:has(a))")
.firstOrNull { t ->
Regex("^[A-Za-z]{3}\\. \\d{2}, \\d{4}$").matches(t.text().trim())
}?.text()
episodeList.add(
SEpisode.create().apply {
setUrlWithoutDomain(response.request.url.toString())
name = "Movie"
episode_number = 1F
date_upload = dateStr?.let { parseDate(it) } ?: 0L
},
)
} else {
seasonList.forEach { season ->
val seasonText = season.selectFirst("div.accordion-header")!!.text().trim()
season.select(episodeListSelector()).forEachIndexed { index, ep ->
val epNumber = ep.selectFirst("a.episode")!!.text().trim().substringAfter("pisode ")
episodeList.add(
SEpisode.create().apply {
setUrlWithoutDomain(ep.selectFirst("a[href]")!!.attr("abs:href"))
name = "$seasonText Ep. $epNumber ${ep.selectFirst("a.name")?.text()?.trim() ?: ""}"
episode_number = epNumber.toFloatOrNull() ?: (index + 1).toFloat()
},
)
}
}
}
return episodeList.reversed()
}
override fun episodeListSelector() = "div.episodes > div.card-episode"
override fun episodeFromElement(element: Element): SEpisode = throw Exception("Not used")
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val document = response.asJsoup()
val videoList = document.select("div.card-stream > button[data-id]").mapNotNull { stream ->
val postBody = FormBody.Builder()
.add("id", stream.attr("data-id"))
.build()
val postHeaders = headers.newBuilder()
.add("Accept", "*/*")
.add("Content-Length", postBody.contentLength().toString())
.add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
.add("Host", baseUrl.toHttpUrl().host)
.add("Origin", baseUrl)
.add("Referer", response.request.url.toString())
.add("X-Requested-With", "XMLHttpRequest")
.build()
val doc = client.newCall(
POST("$baseUrl/ajax/embed", body = postBody, headers = postHeaders),
).execute().asJsoup()
doc.selectFirst("iframe[data-src]")?.attr("abs:data-src")
}.parallelMap { iframeUrl ->
runCatching {
extractVideosFromIframe(iframeUrl)
}.getOrElse { emptyList() }
}.flatten()
require(videoList.isNotEmpty()) { "Failed to fetch videos" }
return videoList.sort()
}
private fun extractVideosFromIframe(iframeUrl: String): List<Video> {
return when {
iframeUrl.toHttpUrl().host.equals(videoHost) -> {
ChillxExtractor(client, headers).videoFromUrl(iframeUrl, "$baseUrl/")
}
else -> emptyList()
}
}
override fun videoListSelector() = throw Exception("not used")
override fun videoFromElement(element: Element) = throw Exception("not used")
override fun videoUrlParse(document: Document) = throw Exception("not used")
// ============================= Utilities ==============================
private fun String.addPage(page: Int): String {
return if (page == 1) {
this
} else {
this.toHttpUrl().newBuilder()
.addQueryParameter("page", page.toString())
.toString()
}
}
private fun Element.getImage(): String {
return this.selectFirst("source[data-srcset][type=image/png]")?.attr("abs:data-srcset")
?: this.selectFirst("img[data-src]")?.attr("abs:data-src")
?: ""
}
private fun Float.stringify(): String {
return when {
ceil(this) == floor(this) -> this.toInt().toString()
else -> "%.1f".format(this).replace(",", ".")
}
}
private fun parseDate(dateStr: String): Long {
return runCatching { DATE_FORMATTER.parse(dateStr)?.time }
.getOrNull() ?: 0L
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
val server = preferences.getString(PREF_SERVER_KEY, PREF_SERVER_DEFAULT)!!
return this.sortedWith(
compareBy(
{ it.quality.contains(quality) },
{ Regex("""(\d+)p""").find(it.quality)?.groupValues?.get(1)?.toIntOrNull() ?: 0 },
{ it.quality.contains(server, true) },
),
).reversed()
}
// From Dopebox
private fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> =
runBlocking {
map { async(Dispatchers.Default) { f(it) } }.awaitAll()
}
companion object {
private val DATE_FORMATTER by lazy {
SimpleDateFormat("MMM. dd, yyyy", Locale.ENGLISH)
}
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_DEFAULT = "1080"
private const val PREF_SERVER_KEY = "preferred_server"
private const val PREF_SERVER_DEFAULT = "chillx"
}
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = "Preferred quality"
entries = arrayOf("1080p", "720p", "480p", "360p")
entryValues = arrayOf("1080", "720", "480", "360")
setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_SERVER_KEY
title = "Preferred server"
entries = arrayOf("Chillx")
entryValues = arrayOf("chillx")
setDefaultValue(PREF_SERVER_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
}
}

View File

@ -1,139 +0,0 @@
package eu.kanade.tachiyomi.animeextension.hi.animesaga
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
object AnimeSAGAFilters {
open class QueryPartFilter(
displayName: String,
val vals: Array<Pair<String, String>>,
) : AnimeFilter.Select<String>(
displayName,
vals.map { it.first }.toTypedArray(),
) {
fun toQueryPart() = vals[state].second
}
open class CheckBoxFilterList(name: String, pairs: Array<Pair<String, String>>) :
AnimeFilter.Group<AnimeFilter.CheckBox>(name, pairs.map { CheckBoxVal(it.first, false) })
private class CheckBoxVal(name: String, state: Boolean = false) : AnimeFilter.CheckBox(name, state)
private inline fun <reified R> AnimeFilterList.asQueryPart(): String {
return (getFirst<R>() as QueryPartFilter).toQueryPart()
}
private inline fun <reified R> AnimeFilterList.getFirst(): R {
return first { it is R } as R
}
private inline fun <reified R> AnimeFilterList.parseCheckbox(
options: Array<Pair<String, String>>,
): String {
return (getFirst<R>() as CheckBoxFilterList).state
.filter { it.state }
.map { checkbox -> options.find { it.first == checkbox.name }!!.second }
.filter(String::isNotBlank)
.joinToString(",")
}
class TypeFilter : QueryPartFilter("Type", AnimeSAGAFiltersData.TYPE_LIST)
class GenreFilter : CheckBoxFilterList("Genres", AnimeSAGAFiltersData.GENRE_LIST)
class YearStart : QueryPartFilter("Release date start", AnimeSAGAFiltersData.YEAR_START)
class YearEnd : QueryPartFilter("Release date end", AnimeSAGAFiltersData.YEAR_END)
class SortFilter : QueryPartFilter("Sorting", AnimeSAGAFiltersData.SORTING_LIST)
class RatingStartFilter : AnimeFilter.Text("Rating lower bound")
class RatingEndFilter : AnimeFilter.Text("Rating upper bound")
val FILTER_LIST get() = AnimeFilterList(
AnimeFilter.Header("Text search ignores filters"),
TypeFilter(),
GenreFilter(),
YearStart(),
YearEnd(),
SortFilter(),
AnimeFilter.Separator(),
AnimeFilter.Header("Ratings must be a float between 5.0 and 10.0"),
RatingStartFilter(),
RatingEndFilter(),
)
data class FilterSearchParams(
val type: String = "",
val genre: String = "",
val yearStart: String = "",
val yearEnd: String = "",
val sorting: String = "",
val ratingStart: String = "",
val ratingEnd: String = "",
)
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
if (filters.isEmpty()) return FilterSearchParams()
return FilterSearchParams(
filters.asQueryPart<TypeFilter>(),
filters.parseCheckbox<GenreFilter>(AnimeSAGAFiltersData.GENRE_LIST),
filters.asQueryPart<YearStart>(),
filters.asQueryPart<YearEnd>(),
filters.asQueryPart<SortFilter>(),
filters.filterIsInstance<RatingStartFilter>().first().state,
filters.filterIsInstance<RatingEndFilter>().first().state,
)
}
private object AnimeSAGAFiltersData {
val TYPE_LIST = arrayOf(
Pair("TV Shows", "series"),
Pair("Movies", "movies"),
)
// $("div.form-category > label.form-check").map((i,el) => `Pair("${$(el).find("span").first().text().trim()}", "${$(el).find("input").first().attr('value').trim()}")`).get().join(',\n')
// on /series
val GENRE_LIST = arrayOf(
Pair("Action", "1"),
Pair("Adventure", "2"),
Pair("Animation", "3"),
Pair("Comedy", "4"),
Pair("Crime", "5"),
Pair("Documentary", "6"),
Pair("Drama", "7"),
Pair("Family", "8"),
Pair("Fantasy", "9"),
Pair("History", "10"),
Pair("Horror", "11"),
Pair("Music", "12"),
Pair("Mystery", "13"),
Pair("Romance", "14"),
Pair("Science Fiction", "15"),
Pair("TV Movie", "16"),
Pair("Thriller", "17"),
Pair("War", "18"),
Pair("Western", "19"),
)
val YEAR_START = (2023 downTo 1960).reversed().map {
Pair(it.toString(), it.toString())
}.toTypedArray()
val YEAR_END = (2023 downTo 1960).map {
Pair(it.toString(), it.toString())
}.toTypedArray()
// $("div.form-category > label.form-check").map((i,el) => `Pair("${$(el).find("span").first().text().trim()}", "${$(el).find("input").first().attr('value').trim()}")`).get().join(',\n')
// on /series
val SORTING_LIST = arrayOf(
Pair("<select>", ""),
Pair("Newest", "newest"),
Pair("Most popular", "popular"),
Pair("Release date", "released"),
Pair("Imdb rating", "imdb"),
)
}
}