feat(en/hanime): Episode grouping (#3072)

This commit is contained in:
imper1aldev 2024-03-23 04:27:13 -06:00 committed by GitHub
parent 7449da50c5
commit 9af8932a69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 344 additions and 170 deletions

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'hanime.tv' extName = 'hanime.tv'
extClass = '.Hanime' extClass = '.Hanime'
extVersionCode = 17 extVersionCode = 18
isNsfw = true isNsfw = true
} }

View File

@ -0,0 +1,255 @@
package eu.kanade.tachiyomi.animeextension.en.hanime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
data class SearchParameters(
val includedTags: ArrayList<String>,
val blackListedTags: ArrayList<String>,
val brands: ArrayList<String>,
val tagsMode: String,
val orderBy: String,
val ordering: String,
)
@Serializable
data class HAnimeResponse(
val page: Long,
val nbPages: Long,
val nbHits: Long,
val hitsPerPage: Long,
val hits: String,
)
@Serializable
data class HitsModel(
val id: Long? = null,
val name: String,
val titles: List<String> = emptyList(),
val slug: String? = null,
val description: String? = null,
val views: Long? = null,
val interests: Long? = null,
@SerialName("poster_url")
val posterUrl: String? = null,
@SerialName("cover_url")
val coverUrl: String? = null,
val brand: String? = null,
@SerialName("brand_id")
val brandId: Long? = null,
@SerialName("duration_in_ms")
val durationInMs: Long? = null,
@SerialName("is_censored")
val isCensored: Boolean? = false,
val rating: Long? = null,
val likes: Long? = null,
val dislikes: Long? = null,
val downloads: Long? = null,
@SerialName("monthly_rank")
val monthlyRank: Long? = null,
val tags: List<String> = emptyList(),
@SerialName("created_at")
val createdAt: Long? = null,
@SerialName("released_at")
val releasedAt: Long? = null,
)
@Serializable
data class VideoModel(
@SerialName("player_base_url")
val playerBaseUrl: String? = null,
@SerialName("hentai_video")
val hentaiVideo: HentaiVideo? = HentaiVideo(),
@SerialName("hentai_tags")
val hentaiTags: List<HentaiTag>? = emptyList(),
@SerialName("hentai_franchise_hentai_videos")
val hentaiFranchiseHentaiVideos: List<HentaiFranchiseHentaiVideo>? = emptyList(),
@SerialName("videos_manifest")
val videosManifest: VideosManifest? = VideosManifest(),
)
@Serializable
data class HentaiVideo(
val id: Long? = null,
@SerialName("is_visible")
val isVisible: Boolean? = false,
val name: String? = null,
val slug: String? = null,
@SerialName("created_at")
val createdAt: String? = null,
@SerialName("released_at")
val releasedAt: String? = null,
val description: String? = null,
val views: Long? = null,
val interests: Long? = null,
@SerialName("poster_url")
val posterUrl: String? = null,
@SerialName("cover_url")
val coverUrl: String? = null,
@SerialName("is_hard_subtitled")
val isHardSubtitled: Boolean? = false,
val brand: String? = null,
@SerialName("duration_in_ms")
val durationInMs: Long? = null,
@SerialName("is_censored")
val isCensored: Boolean? = false,
val rating: Double? = null,
val likes: Long? = null,
val dislikes: Long? = null,
val downloads: Long? = null,
@SerialName("monthly_rank")
val monthlyRank: Long? = null,
@SerialName("brand_id")
val brandId: String? = null,
@SerialName("is_banned_in")
val isBannedIn: String? = null,
@SerialName("created_at_unix")
val createdAtUnix: Long? = null,
@SerialName("released_at_unix")
val releasedAtUnix: Long? = null,
@SerialName("hentai_tags")
val hentaiTags: List<HentaiTag>? = emptyList(),
)
@Serializable
data class HentaiTag(
val id: Long? = null,
val text: String? = null,
val count: Long? = null,
val description: String? = null,
@SerialName("wide_image_url")
val wideImageUrl: String? = null,
@SerialName("tall_image_url")
val tallImageUrl: String? = null,
)
@Serializable
data class HentaiFranchiseHentaiVideo(
val id: Long? = null,
val name: String? = null,
val slug: String? = null,
@SerialName("created_at")
val createdAt: String? = null,
@SerialName("released_at")
val releasedAt: String? = null,
val views: Long? = null,
val interests: Long? = null,
@SerialName("poster_url")
val posterUrl: String? = null,
@SerialName("cover_url")
val coverUrl: String? = null,
@SerialName("is_hard_subtitled")
val isHardSubtitled: Boolean? = false,
val brand: String? = null,
@SerialName("duration_in_ms")
val durationInMs: Long? = null,
@SerialName("is_censored")
val isCensored: Boolean? = false,
val rating: Double? = null,
val likes: Long? = null,
val dislikes: Long? = null,
val downloads: Long? = null,
@SerialName("monthly_rank")
val monthlyRank: Long? = null,
@SerialName("brand_id")
val brandId: String? = null,
@SerialName("is_banned_in")
val isBannedIn: String? = null,
@SerialName("created_at_unix")
val createdAtUnix: Long? = null,
@SerialName("released_at_unix")
val releasedAtUnix: Long? = null,
)
@Serializable
data class VideosManifest(
val servers: List<Server>? = emptyList(),
)
@Serializable
data class Server(
val id: Long? = null,
val name: String? = null,
val slug: String? = null,
@SerialName("na_rating")
val naRating: Long? = null,
@SerialName("eu_rating")
val euRating: Long? = null,
@SerialName("asia_rating")
val asiaRating: Long? = null,
val sequence: Long? = null,
@SerialName("is_permanent")
val isPermanent: Boolean? = false,
val streams: List<Stream> = emptyList(),
)
@Serializable
data class Stream(
val id: Long? = null,
@SerialName("server_id")
val serverId: Long? = null,
val slug: String? = null,
val kind: String? = null,
val extension: String? = null,
@SerialName("mime_type")
val mimeType: String? = null,
val width: Long? = null,
val height: String,
@SerialName("duration_in_ms")
val durationInMs: Long? = null,
@SerialName("filesize_mbs")
val filesizeMbs: Long? = null,
val filename: String? = null,
val url: String,
@SerialName("is_guest_allowed")
val isGuestAllowed: Boolean? = false,
@SerialName("is_member_allowed")
val isMemberAllowed: Boolean? = false,
@SerialName("is_premium_allowed")
val isPremiumAllowed: Boolean? = false,
@SerialName("is_downloadable")
val isDownloadable: Boolean? = false,
val compatibility: String? = null,
@SerialName("hv_id")
val hvId: Long? = null,
@SerialName("server_sequence")
val serverSequence: Long? = null,
@SerialName("video_stream_group_id")
val videoStreamGroupId: String? = null,
)
@Serializable
data class WindowNuxt(
val state: State,
) {
@Serializable
data class State(
val data: Data,
) {
@Serializable
data class Data(
val video: DataVideo,
) {
@Serializable
data class DataVideo(
val videos_manifest: VideosManifest,
) {
@Serializable
data class VideosManifest(
val servers: List<Server>,
) {
@Serializable
data class Server(
val streams: List<Stream>,
) {
@Serializable
data class Stream(
val height: String,
val url: String,
)
}
}
}
}
}
}

View File

@ -15,16 +15,8 @@ import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.Serializable import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@ -73,137 +65,106 @@ class Hanime : ConfigurableAnimeSource, AnimeHttpSource() {
private val popularRequestHeaders = private val popularRequestHeaders =
Headers.headersOf("authority", "search.htv-services.com", "accept", "application/json, text/plain, */*", "content-type", "application/json;charset=UTF-8") Headers.headersOf("authority", "search.htv-services.com", "accept", "application/json, text/plain, */*", "content-type", "application/json;charset=UTF-8")
override fun popularAnimeRequest(page: Int): Request { override fun popularAnimeRequest(page: Int): Request =
return POST("https://search.htv-services.com/", popularRequestHeaders, searchRequestBody("", page, AnimeFilterList())) POST("https://search.htv-services.com/", popularRequestHeaders, searchRequestBody("", page, AnimeFilterList()))
override fun popularAnimeParse(response: Response) = parseSearchJson(response)
private fun parseSearchJson(response: Response): AnimesPage {
val jsonLine = response.body.string().ifEmpty { return AnimesPage(emptyList(), false) }
val jResponse = jsonLine.parseAs<HAnimeResponse>()
val hasNextPage = jResponse.page < jResponse.nbPages - 1
val array = jResponse.hits.parseAs<Array<HitsModel>>()
val animeList = array.groupBy { getTitle(it.name) }.map { (_, items) -> items.first() }.map { item ->
SAnime.create().apply {
title = getTitle(item.name)
thumbnail_url = item.coverUrl
author = item.brand
description = item.description?.replace(Regex("<[^>]*>"), "")
status = SAnime.UNKNOWN
genre = item.tags.joinToString { it }
initialized = true
setUrlWithoutDomain("https://hanime.tv/videos/hentai/" + item.slug)
}
} }
override fun popularAnimeParse(response: Response): AnimesPage {
val responseString = response.body.string()
return parseSearchJson(responseString)
}
private fun parseSearchJson(jsonLine: String?): AnimesPage {
val jsonData = jsonLine ?: return AnimesPage(emptyList(), false)
val jObject = json.decodeFromString<JsonObject>(jsonData)
val nbPages = jObject["nbPages"]!!.jsonPrimitive.int
val page = jObject["page"]!!.jsonPrimitive.int
val hasNextPage = page < nbPages - 1
val arrayString = jObject["hits"]!!.jsonPrimitive.content
val array = json.decodeFromString<JsonArray>(arrayString)
val animeList = mutableListOf<SAnime>()
for (item in array) {
val anime = SAnime.create()
anime.title = item.jsonObject["name"]!!.jsonPrimitive.content
anime.thumbnail_url = item.jsonObject["cover_url"]!!.jsonPrimitive.content
anime.setUrlWithoutDomain("https://hanime.tv/videos/hentai/" + item.jsonObject["slug"]!!.jsonPrimitive.content)
anime.author = item.jsonObject["brand"]!!.jsonPrimitive.content
anime.description = item.jsonObject["description"]!!.jsonPrimitive.content.replace("<p>", "").replace("</p>", "")
anime.status = SAnime.COMPLETED
val tags = item.jsonObject["tags"]!!.jsonArray
anime.genre = tags.joinToString(", ") { it.jsonPrimitive.content }
anime.initialized = true
animeList.add(anime)
}
return AnimesPage(animeList, hasNextPage) return AnimesPage(animeList, hasNextPage)
} }
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request = POST("https://search.htv-services.com/", popularRequestHeaders, searchRequestBody(query, page, filters)) private fun isNumber(num: String) = (num.toIntOrNull() != null)
override fun searchAnimeParse(response: Response): AnimesPage { private fun getTitle(title: String): String {
val responseString = response.body.string() return if (title.contains(" Ep ")) {
return parseSearchJson(responseString) title.split(" Ep ")[0].trim()
} else {
if (isNumber(title.trim().split(" ").last())) {
val split = title.trim().split(" ")
split.slice(0..split.size - 2).joinToString(" ").trim()
} else {
title.trim()
} }
}
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request =
POST("https://search.htv-services.com/", popularRequestHeaders, searchRequestBody(query, page, filters))
override fun searchAnimeParse(response: Response): AnimesPage = parseSearchJson(response)
override fun animeDetailsParse(response: Response): SAnime { override fun animeDetailsParse(response: Response): SAnime {
val document = response.asJsoup() val document = response.asJsoup()
return SAnime.create().apply { return SAnime.create().apply {
title = document.select("h1.tv-title").text() title = getTitle(document.select("h1.tv-title").text())
thumbnail_url = document.select("img.hvpi-cover").attr("src") thumbnail_url = document.select("img.hvpi-cover").attr("src")
setUrlWithoutDomain(document.location())
author = document.select("a.hvpimbc-text").text() author = document.select("a.hvpimbc-text").text()
description = document.select("div.hvpist-description p") description = document.select("div.hvpist-description p").joinToString("\n\n") { it.text() }
.joinToString("\n\n") { it.text() } status = SAnime.UNKNOWN
status = SAnime.COMPLETED
genre = document.select("div.hvpis-text div.btn__content").joinToString { it.text() } genre = document.select("div.hvpis-text div.btn__content").joinToString { it.text() }
initialized = true initialized = true
setUrlWithoutDomain(document.location())
} }
} }
override fun videoListRequest(episode: SEpisode): Request { override fun videoListRequest(episode: SEpisode) = GET(episode.url)
return GET(episode.url)
}
override suspend fun getVideoList(episode: SEpisode): List<Video> { override suspend fun getVideoList(episode: SEpisode): List<Video> {
setAuthCookie() setAuthCookie()
if (authCookie != null) { if (authCookie != null) {
return fetchVideoListPremium(episode) return fetchVideoListPremium(episode)
} }
return super.getVideoList(episode) return super.getVideoList(episode)
} }
private fun fetchVideoListPremium(episode: SEpisode): List<Video> { private fun fetchVideoListPremium(episode: SEpisode): List<Video> {
val videoList = mutableListOf<Video>()
val id = episode.url.substringAfter("?id=") val id = episode.url.substringAfter("?id=")
val headers = headers.newBuilder() val headers = headers.newBuilder().add("cookie", authCookie!!)
.add("cookie", authCookie!!) val document = client.newCall(GET("$baseUrl/videos/hentai/$id", headers = headers.build())).execute().asJsoup()
val document = client.newCall(
GET("$baseUrl/videos/hentai/$id", headers = headers.build()), val parsed = document.selectFirst("script:containsData(__NUXT__)")!!.data()
).execute().asJsoup() .substringAfter("__NUXT__=").substringBeforeLast(";").parseAs<WindowNuxt>()
val data = document.selectFirst("script:containsData(__NUXT__)")!!.data()
.substringAfter("__NUXT__=").substringBeforeLast(";") return parsed.state.data.video.videos_manifest.servers.flatMap { server ->
val parsed = json.decodeFromString<WindowNuxt>(data) server.streams.map { stream -> Video(stream.url, stream.height + "p", stream.url) }
parsed.state.data.video.videos_manifest.servers.forEach { server ->
server.streams.forEach { stream ->
videoList.add(
Video(
stream.url,
stream.height + "p",
stream.url,
),
)
} }
} }
return videoList
}
override fun videoListParse(response: Response): List<Video> { override fun videoListParse(response: Response): List<Video> {
val responseString = response.body.string() val responseString = response.body.string().ifEmpty { return emptyList() }
val jObject = json.decodeFromString<JsonObject>(responseString) return responseString.parseAs<VideoModel>().videosManifest?.servers?.get(0)?.streams?.filter { it.kind != "premium_alert" }?.map {
val server = jObject["videos_manifest"]!!.jsonObject["servers"]!!.jsonArray[0].jsonObject Video(it.url, "${it.height}p", it.url)
val streams = server["streams"]!!.jsonArray } ?: emptyList()
val linkList = mutableListOf<Video>()
for (stream in streams) {
val streamObject = stream.jsonObject
if (streamObject["kind"]!!.jsonPrimitive.content != "premium_alert") {
linkList.add(
Video(
url = streamObject["url"]!!.jsonPrimitive.content,
quality = streamObject["height"]!!.jsonPrimitive.content + "p",
videoUrl = streamObject["url"]!!.jsonPrimitive.content,
),
)
}
}
return linkList
} }
override fun List<Video>.sort(): List<Video> { override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString("preferred_quality", null) val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
if (quality != null) { return this.sortedWith(
val newList = mutableListOf<Video>() compareBy(
var preferred = 0 { it.quality.contains(quality) },
for (video in this) { { Regex("""(\d+)p""").find(it.quality)?.groupValues?.get(1)?.toIntOrNull() ?: 0 },
if (video.quality.contains(quality)) { ),
newList.add(preferred, video) ).reversed()
preferred++
} else {
newList.add(video)
}
}
return newList
}
return this
} }
override fun episodeListRequest(anime: SAnime): Request { override fun episodeListRequest(anime: SAnime): Request {
@ -212,14 +173,15 @@ class Hanime : ConfigurableAnimeSource, AnimeHttpSource() {
} }
override fun episodeListParse(response: Response): List<SEpisode> { override fun episodeListParse(response: Response): List<SEpisode> {
val responseString = response.body.string() val responseString = response.body.string().ifEmpty { return emptyList() }
val jObject = json.decodeFromString<JsonObject>(responseString) return responseString.parseAs<VideoModel>().hentaiFranchiseHentaiVideos?.mapIndexed { idx, it ->
val episode = SEpisode.create() SEpisode.create().apply {
episode.date_upload = jObject.jsonObject["hentai_video"]!!.jsonObject["released_at_unix"]!!.jsonPrimitive.long * 1000 episode_number = idx + 1f
episode.name = jObject.jsonObject["hentai_video"]!!.jsonObject["name"]!!.jsonPrimitive.content name = "Episode ${idx + 1}"
episode.url = response.request.url.toString() date_upload = (it.releasedAtUnix ?: 0) * 1000
episode.episode_number = 1F url = "$baseUrl/api/v8/video?id=${it.id}"
return listOf(episode) }
}?.reversed() ?: emptyList()
} }
private fun setAuthCookie() { private fun setAuthCookie() {
@ -244,12 +206,9 @@ class Hanime : ConfigurableAnimeSource, AnimeHttpSource() {
""".trimIndent().toRequestBody("application/json".toMediaType()) """.trimIndent().toRequestBody("application/json".toMediaType())
} }
override fun latestUpdatesRequest(page: Int): Request = POST("https://search.htv-services.com/", popularRequestHeaders, latestSearchRequestBody(page)) override fun latestUpdatesRequest(page: Int) = POST("https://search.htv-services.com/", popularRequestHeaders, latestSearchRequestBody(page))
override fun latestUpdatesParse(response: Response): AnimesPage { override fun latestUpdatesParse(response: Response) = parseSearchJson(response)
val responseString = response.body.string()
return parseSearchJson(responseString)
}
// Filters // Filters
override fun getFilterList(): AnimeFilterList = AnimeFilterList( override fun getFilterList(): AnimeFilterList = AnimeFilterList(
@ -257,23 +216,13 @@ class Hanime : ConfigurableAnimeSource, AnimeHttpSource() {
BrandList(getBrands()), BrandList(getBrands()),
SortFilter(sortableList.map { it.first }.toTypedArray()), SortFilter(sortableList.map { it.first }.toTypedArray()),
TagInclusionMode(), TagInclusionMode(),
) )
internal class Tag(val id: String, name: String) : AnimeFilter.TriState(name) internal class Tag(val id: String, name: String) : AnimeFilter.TriState(name)
internal class Brand(val id: String, name: String) : AnimeFilter.CheckBox(name) internal class Brand(val id: String, name: String) : AnimeFilter.CheckBox(name)
private class TagList(tags: List<Tag>) : AnimeFilter.Group<Tag>("Tags", tags) private class TagList(tags: List<Tag>) : AnimeFilter.Group<Tag>("Tags", tags)
private class BrandList(brands: List<Brand>) : AnimeFilter.Group<Brand>("Brands", brands) private class BrandList(brands: List<Brand>) : AnimeFilter.Group<Brand>("Brands", brands)
private class TagInclusionMode : private class TagInclusionMode : AnimeFilter.Select<String>("Included tags mode", arrayOf("And", "Or"), 0)
AnimeFilter.Select<String>("Included tags mode", arrayOf("And", "Or"), 0)
data class SearchParameters(
val includedTags: ArrayList<String>,
val blackListedTags: ArrayList<String>,
val brands: ArrayList<String>,
val tagsMode: String,
val orderBy: String,
val ordering: String,
)
private fun getSearchParameters(filters: AnimeFilterList): SearchParameters { private fun getSearchParameters(filters: AnimeFilterList): SearchParameters {
val includedTags = ArrayList<String>() val includedTags = ArrayList<String>()
@ -567,13 +516,19 @@ class Hanime : ConfigurableAnimeSource, AnimeHttpSource() {
class SortFilter(sortables: Array<String>) : AnimeFilter.Sort("Sort", sortables, Selection(2, false)) class SortFilter(sortables: Array<String>) : AnimeFilter.Sort("Sort", sortables, Selection(2, false))
// Preferences // Preferences
companion object {
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_DEFAULT = "1080p"
private val QUALITY_LIST = arrayOf("1080p", "720p", "480p", "360p")
}
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
val videoQualityPref = ListPreference(screen.context).apply { val videoQualityPref = ListPreference(screen.context).apply {
key = "preferred_quality" key = PREF_QUALITY_KEY
title = "Preferred quality" title = "Preferred quality"
entries = arrayOf("1080p", "720p", "480p", "360p") entries = QUALITY_LIST
entryValues = arrayOf("1080", "720", "480", "360") entryValues = QUALITY_LIST
setDefaultValue("720") setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s" summary = "%s"
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
@ -585,40 +540,4 @@ class Hanime : ConfigurableAnimeSource, AnimeHttpSource() {
} }
screen.addPreference(videoQualityPref) screen.addPreference(videoQualityPref)
} }
@Serializable
data class WindowNuxt(
val state: State,
) {
@Serializable
data class State(
val data: Data,
) {
@Serializable
data class Data(
val video: DataVideo,
) {
@Serializable
data class DataVideo(
val videos_manifest: VideosManifest,
) {
@Serializable
data class VideosManifest(
val servers: List<Server>,
) {
@Serializable
data class Server(
val streams: List<Stream>,
) {
@Serializable
data class Stream(
val height: String,
val url: String,
)
}
}
}
}
}
}
} }