feat(es/hentaila): Add video servers, filters and new extractor (#1985)

This commit is contained in:
Rolando Lecca
2023-07-31 03:42:41 -05:00
committed by GitHub
parent 57232e6a30
commit 47740636ef
6 changed files with 263 additions and 103 deletions

View File

@ -0,0 +1,18 @@
plugins {
id("com.android.library")
id("kotlinx-serialization")
kotlin("android")
}
android {
compileSdk = AndroidConfig.compileSdk
namespace = "eu.kanade.tachiyomi.lib.burstcloudextractor"
defaultConfig {
minSdk = AndroidConfig.minSdk
}
}
dependencies {
compileOnly(libs.bundles.common)
}

View File

@ -0,0 +1,40 @@
package eu.kanade.tachiyomi.lib.burstcloudextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
class BurstCloudExtractor(private val client: OkHttpClient) {
private val json: Json by injectLazy()
fun videoFromUrl(url: String, headers: Headers, name: String = "BurstCloud", prefix: String = ""): List<Video> {
val newHeaders = headers.newBuilder().add("referer", "https://www.burstcloud.co/").build()
return runCatching {
val response = client.newCall(GET(url, headers = newHeaders)).execute()
val document = response.asJsoup()
val videoId = document.selectFirst("div#player")!!.attr("data-file-id")
val formBody = FormBody.Builder()
.add("fileId", videoId)
.build()
val jsonHeaders = headers.newBuilder().add("referer", document.location()).build()
val jsonString = client.newCall(POST("https://www.burstcloud.co/file/play-request/", jsonHeaders, formBody)).execute().body.string()
val jsonObj = json.decodeFromString<BurstCloudDto>(jsonString)
val videoUrl = jsonObj.purchase.cdnUrl
if (videoUrl.isNotEmpty()) {
val quality = prefix + name
listOf(Video(videoUrl, quality, videoUrl, newHeaders))
} else {
null
}
}.getOrNull() ?: emptyList<Video>()
}
}

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.lib.burstcloudextractor
import kotlinx.serialization.Serializable
@Serializable
data class BurstCloudDto(
val purchase: Purchase,
)
@Serializable
data class Purchase(
val cdnUrl: String,
)

View File

@ -1,13 +1,21 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'Hentaila'
pkgNameSuffix = 'es.hentaila'
extClass = '.Hentaila'
extVersionCode = 12
extVersionCode = 13
libVersion = '13'
containsNsfw = true
}
dependencies {
implementation(project(':lib-burstcloud-extractor'))
implementation(project(':lib-mp4upload-extractor'))
implementation(project(':lib-streamsb-extractor'))
implementation(project(':lib-yourupload-extractor'))
}
apply from: "$rootDir/common.gradle"

View File

@ -11,25 +11,27 @@ 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.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.lib.burstcloudextractor.BurstCloudExtractor
import eu.kanade.tachiyomi.lib.mp4uploadextractor.Mp4uploadExtractor
import eu.kanade.tachiyomi.lib.streamsbextractor.StreamSBExtractor
import eu.kanade.tachiyomi.lib.youruploadextractor.YourUploadExtractor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.json.JSONObject
import org.jsoup.Connection
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.lang.Exception
class Hentaila : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
class Hentaila : ConfigurableAnimeSource, AnimeHttpSource() {
override val name = "Hentaila"
@ -39,7 +41,7 @@ class Hentaila : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
private val json: Json by injectLazy()
override val supportsLatest = false
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient
@ -47,21 +49,123 @@ class Hentaila : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun popularAnimeSelector(): String = "div.columns main section.section div.grid.hentais article.hentai"
override fun popularAnimeRequest(page: Int) = GET(baseUrl, headers)
override fun popularAnimeRequest(page: Int): Request = GET("https://hentaila.com/directorio?p=$page")
override fun popularAnimeFromElement(element: Element): SAnime {
val anime = SAnime.create()
anime.setUrlWithoutDomain(
element.select("a").attr("href"),
)
anime.title = element.select("header.h-header h2").text()
anime.thumbnail_url = baseUrl + element.select("div.h-thumb figure img").attr("src")
return anime
override fun popularAnimeParse(response: Response): AnimesPage {
val document = response.asJsoup()
val elements = document.select("section.latest-hentais div.slider > div.item")
val animes = elements.map { element ->
SAnime.create().apply {
setUrlWithoutDomain(element.select("h2.h-title a").attr("abs:href"))
title = element.selectFirst("h2.h-title a")!!.text()
thumbnail_url = element.selectFirst("figure.bg img")!!.attr("abs:src").replace("/fondos/", "/portadas/")
}
}
return AnimesPage(animes, false)
}
override fun popularAnimeNextPageSelector(): String = "a.btn.rnd.npd.fa-arrow-right"
override fun latestUpdatesRequest(page: Int) = GET(baseUrl, headers)
override fun latestUpdatesParse(response: Response): AnimesPage {
val document = response.asJsoup()
val elements = document.select("section.hentai-list div.hentais article.hentai")
val animes = elements.map { element ->
SAnime.create().apply {
setUrlWithoutDomain(element.select("a").attr("abs:href"))
title = element.selectFirst("h2.h-title")!!.text()
thumbnail_url = element.selectFirst("figure img")!!.attr("abs:src")
}
}
return AnimesPage(animes, false)
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val filterList = if (filters.isEmpty()) getFilterList() else filters
val genreFilter = filterList.find { it is GenreFilter } as GenreFilter
if (query.isNotEmpty()) {
if (query.length < 2) throw IOException("La búsqueda debe tener al menos 2 caracteres")
return POST("$baseUrl/api/search", headers, FormBody.Builder().add("value", query).build())
}
var url = "$baseUrl/directorio?p=$page".toHttpUrl().newBuilder()
if (genreFilter.state != 0) {
url = "$baseUrl/genero/${genreFilter.toUriPart()}?p=$page".toHttpUrl().newBuilder()
}
filterList.forEach { filter ->
when (filter) {
is OrderFilter -> {
url.addQueryParameter("filter", filter.toUriPart())
}
is StatusOngoingFilter -> {
if (filter.state) {
url.addQueryParameter("status[1]", "on")
}
}
is StatusCompletedFilter -> {
if (filter.state) {
url.addQueryParameter("status[2]", "on")
}
}
is UncensoredFilter -> {
if (filter.state) {
url.addQueryParameter("uncensored", "on")
}
}
else -> {}
}
}
return GET(url.build().toString(), headers)
}
override fun searchAnimeParse(response: Response): AnimesPage {
if (response.request.url.toString().startsWith("$baseUrl/api/search")) {
val jsonString = response.body.string()
val results = json.decodeFromString<List<HentailaDto>>(jsonString)
val animeList = results.map { anime ->
SAnime.create().apply {
title = anime.title
thumbnail_url = "$baseUrl/uploads/portadas/${anime.id}.jpg"
setUrlWithoutDomain("$baseUrl/hentai-${anime.slug}")
}
}
return AnimesPage(animeList, false)
}
val document = response.asJsoup()
val animes = document.select("div.columns main section.section div.grid.hentais article.hentai").map {
SAnime.create().apply {
title = it.select("header.h-header h2").text()
setUrlWithoutDomain(it.select("a").attr("abs:href"))
thumbnail_url = it.select("div.h-thumb figure img").attr("abs:src")
}
}
val hasNextPage = document.select("a.btn.rnd.npd.fa-arrow-right").isNullOrEmpty().not()
return AnimesPage(animes, hasNextPage)
}
override fun animeDetailsParse(response: Response): SAnime {
val document = response.asJsoup()
return SAnime.create().apply {
thumbnail_url = baseUrl + document.selectFirst("div.h-thumb figure img")!!.attr("src")
with(document.selectFirst("article.hentai-single")!!) {
title = selectFirst("header.h-header h1")!!.text()
description = select("div.h-content p").text()
genre = select("footer.h-footer nav.genres a.btn.sm").joinToString { it.text() }
status = if (selectFirst("span.status-on") != null) {
SAnime.ONGOING
} else {
SAnime.COMPLETED
}
}
}
}
override fun episodeListParse(response: Response): List<SEpisode> {
val episodes = mutableListOf<SEpisode>()
@ -81,10 +185,6 @@ class Hentaila : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
return episodes
}
override fun episodeListSelector() = throw Exception("not used")
override fun episodeFromElement(element: Element) = throw Exception("not used")
override fun videoListParse(response: Response): List<Video> {
val document = response.asJsoup()
val videoList = mutableListOf<Video>()
@ -100,27 +200,38 @@ class Hentaila : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val videoUrl = urlServer.substringAfter("#")
videoList.add(Video(videoUrl, "Arc", videoUrl))
}
if (nameServer.lowercase() == "yupi") {
videoList.addAll(YourUploadExtractor(client).videoFromUrl(urlServer, headers = headers))
}
if (nameServer.lowercase() == "mp4upload") {
videoList.addAll(Mp4uploadExtractor(client).videosFromUrl(urlServer, headers = headers))
}
if (nameServer.lowercase() == "stream") {
videoList.addAll(StreamSBExtractor(client).videosFromUrl(urlServer, headers = headers))
}
if (nameServer.lowercase() == "burst") {
videoList.addAll(BurstCloudExtractor(client).videoFromUrl(urlServer, headers = headers))
}
}
return videoList
}
override fun videoListSelector() = throw Exception("not used")
override fun videoUrlParse(document: Document) = throw Exception("not used")
override fun videoFromElement(element: Element) = throw Exception("not used")
override fun List<Video>.sort(): List<Video> {
return try {
val videoSorted = this.sortedWith(
compareBy<Video> { it.quality.replace("[0-9]".toRegex(), "") }.thenByDescending { getNumberFromString(it.quality) },
).toTypedArray()
val userPreferredQuality = preferences.getString("preferred_quality", "Arc")
).toMutableList()
val userPreferredQuality = preferences.getString("preferred_quality", "YourUpload")
val preferredIdx = videoSorted.indexOfFirst { x -> x.quality == userPreferredQuality }
if (preferredIdx != -1) {
videoSorted.drop(preferredIdx + 1)
videoSorted[0] = videoSorted[preferredIdx]
val temp = videoSorted[preferredIdx]
videoSorted.removeAt(preferredIdx)
videoSorted.add(0, temp)
}
videoSorted.toList()
} catch (e: Exception) {
@ -132,78 +243,35 @@ class Hentaila : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
return epsStr.filter { it.isDigit() }.ifEmpty { "0" }
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val filterList = if (filters.isEmpty()) getFilterList() else filters
val genreFilter = filterList.find { it is GenreFilter } as GenreFilter
if (genreFilter.state == 0) {
val results = Jsoup.connect("https://www3.hentaila.com/api/search").method(Connection.Method.POST).data("value", query).execute().body()
val jsonObject = json.decodeFromString<JsonArray>(results)
val animeSlug = JSONObject(jsonObject[0].toString())["slug"]
val ultimateHentaiLink = "$baseUrl/hentai-$animeSlug"
if (query.isNotBlank() && jsonObject.toString() != "[]") {
return GET(ultimateHentaiLink)
}
}
return when {
genreFilter.state != 0 -> GET("$baseUrl/genero/${genreFilter.toUriPart()}?p=$page")
else -> GET("$baseUrl/directorio?p=$page")
}
}
override fun searchAnimeFromElement(element: Element) = throw Exception("not used")
override fun searchAnimeParse(response: Response): AnimesPage {
val anime = mutableListOf<SAnime>()
val element = response.asJsoup()
if (!element.select("article.hentai-single").isNullOrEmpty()) {
val animeSearch = SAnime.create()
val mainUrl = element.select("section.section:nth-child(2) > script:nth-child(3)").toString().substringAfter("this.page.url = \"").substringBefore("\"")
animeSearch.setUrlWithoutDomain(mainUrl)
animeSearch.title = element.select("article.hentai-single header.h-header h1").text()
animeSearch.thumbnail_url = baseUrl + element.select("article.hentai-single div.h-thumb figure img").attr("src")
anime.add(animeSearch)
return AnimesPage(anime, false)
}
val animes = element.select("div.columns main section.section div.grid.hentais article.hentai").map {
popularAnimeFromElement(it)
}
return AnimesPage(animes, false)
}
override fun searchAnimeNextPageSelector(): String = popularAnimeNextPageSelector()
override fun searchAnimeSelector(): String = throw Exception("not used")
override fun animeDetailsParse(document: Document): SAnime {
val anime = SAnime.create()
anime.thumbnail_url = baseUrl + document.selectFirst("div.h-thumb figure img")!!.attr("src")
anime.title = document.selectFirst("article.hentai-single header.h-header h1")!!.text()
anime.description = document.select("article.hentai-single div.h-content p").text()
anime.genre = document.select("article.hentai-single footer.h-footer nav.genres a.btn.sm").joinToString { it.text() }
anime.status = SAnime.COMPLETED
return anime
}
override fun latestUpdatesNextPageSelector() = throw Exception("not used")
override fun latestUpdatesFromElement(element: Element) = throw Exception("not used")
override fun latestUpdatesRequest(page: Int) = throw Exception("not used")
override fun latestUpdatesSelector() = throw Exception("not used")
override fun getFilterList(): AnimeFilterList = AnimeFilterList(
AnimeFilter.Header("La busqueda por texto ignora el filtro"),
GenreFilter(),
AnimeFilter.Separator(),
OrderFilter(),
AnimeFilter.Separator(),
StatusOngoingFilter(),
StatusCompletedFilter(),
AnimeFilter.Separator(),
UncensoredFilter(),
)
private class StatusOngoingFilter : AnimeFilter.CheckBox("En Emision")
private class StatusCompletedFilter : AnimeFilter.CheckBox("Finalizado")
private class UncensoredFilter : AnimeFilter.CheckBox("Sin Censura")
private class OrderFilter : UriPartFilter(
"Ordenar por",
arrayOf(
Pair("<Seleccionar>", ""),
Pair("Populares", "popular"),
Pair("Recientes", "recent"),
),
)
private class GenreFilter : UriPartFilter(
"Generos",
arrayOf(
Pair("<selecionar>", ""),
Pair("<Seleccionar>", ""),
Pair("3D", "3d"),
Pair("Ahegao", "ahegao"),
Pair("Anal", "anal"),
@ -218,6 +286,7 @@ class Hentaila : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
Pair("Hardcore", "hardcore"),
Pair("Incesto", "incesto"),
Pair("Juegos Sexuales", "juegos-sexuales"),
Pair("Maids", "maids"),
Pair("Milfs", "milfs"),
Pair("Netorare", "netorare"),
Pair("Ninfomania", "ninfomania"),
@ -232,7 +301,7 @@ class Hentaila : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
Pair("Tetonas", "tetonas"),
Pair("Vanilla", "vanilla"),
Pair("Violacion", "violacion"),
Pair("Virgenes(como tu)", "virgenes"),
Pair("Virgenes", "virgenes"),
Pair("Yaoi", "Yaoi"),
Pair("Yuri", "yuri"),
Pair("Bondage", "bondage"),
@ -246,14 +315,15 @@ class Hentaila : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val qualities = arrayOf(
"Arc", // video servers without resolution
"YourUpload",
"BurstCloud",
)
val videoQualityPref = ListPreference(screen.context).apply {
key = "preferred_quality"
title = "Preferred quality"
entries = qualities
entryValues = qualities
setDefaultValue("Arc")
setDefaultValue("YourUpload")
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->

View File

@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.animeextension.es.hentaila
import kotlinx.serialization.Serializable
@Serializable
data class HentailaDto(
val id: String,
val slug: String,
val title: String,
val type: String,
)