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 {
extName = 'hanime.tv'
extClass = '.Hanime'
extVersionCode = 17
extVersionCode = 18
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.POST
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import eu.kanade.tachiyomi.util.parseAs
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.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
@ -73,137 +65,106 @@ class Hanime : ConfigurableAnimeSource, AnimeHttpSource() {
private val popularRequestHeaders =
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 {
return POST("https://search.htv-services.com/", popularRequestHeaders, searchRequestBody("", page, AnimeFilterList()))
override fun popularAnimeRequest(page: Int): Request =
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)
}
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 {
val responseString = response.body.string()
return parseSearchJson(responseString)
private fun getTitle(title: String): String {
return if (title.contains(" Ep ")) {
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 {
val document = response.asJsoup()
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")
setUrlWithoutDomain(document.location())
author = document.select("a.hvpimbc-text").text()
description = document.select("div.hvpist-description p")
.joinToString("\n\n") { it.text() }
status = SAnime.COMPLETED
description = document.select("div.hvpist-description p").joinToString("\n\n") { it.text() }
status = SAnime.UNKNOWN
genre = document.select("div.hvpis-text div.btn__content").joinToString { it.text() }
initialized = true
setUrlWithoutDomain(document.location())
}
}
override fun videoListRequest(episode: SEpisode): Request {
return GET(episode.url)
}
override fun videoListRequest(episode: SEpisode) = GET(episode.url)
override suspend fun getVideoList(episode: SEpisode): List<Video> {
setAuthCookie()
if (authCookie != null) {
return fetchVideoListPremium(episode)
}
return super.getVideoList(episode)
}
private fun fetchVideoListPremium(episode: SEpisode): List<Video> {
val videoList = mutableListOf<Video>()
val id = episode.url.substringAfter("?id=")
val headers = headers.newBuilder()
.add("cookie", authCookie!!)
val document = client.newCall(
GET("$baseUrl/videos/hentai/$id", headers = headers.build()),
).execute().asJsoup()
val data = document.selectFirst("script:containsData(__NUXT__)")!!.data()
.substringAfter("__NUXT__=").substringBeforeLast(";")
val parsed = json.decodeFromString<WindowNuxt>(data)
parsed.state.data.video.videos_manifest.servers.forEach { server ->
server.streams.forEach { stream ->
videoList.add(
Video(
stream.url,
stream.height + "p",
stream.url,
),
)
val headers = headers.newBuilder().add("cookie", authCookie!!)
val document = client.newCall(GET("$baseUrl/videos/hentai/$id", headers = headers.build())).execute().asJsoup()
val parsed = document.selectFirst("script:containsData(__NUXT__)")!!.data()
.substringAfter("__NUXT__=").substringBeforeLast(";").parseAs<WindowNuxt>()
return parsed.state.data.video.videos_manifest.servers.flatMap { server ->
server.streams.map { stream -> Video(stream.url, stream.height + "p", stream.url) }
}
}
return videoList
}
override fun videoListParse(response: Response): List<Video> {
val responseString = response.body.string()
val jObject = json.decodeFromString<JsonObject>(responseString)
val server = jObject["videos_manifest"]!!.jsonObject["servers"]!!.jsonArray[0].jsonObject
val streams = server["streams"]!!.jsonArray
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
val responseString = response.body.string().ifEmpty { return emptyList() }
return responseString.parseAs<VideoModel>().videosManifest?.servers?.get(0)?.streams?.filter { it.kind != "premium_alert" }?.map {
Video(it.url, "${it.height}p", it.url)
} ?: emptyList()
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString("preferred_quality", null)
if (quality != null) {
val newList = mutableListOf<Video>()
var preferred = 0
for (video in this) {
if (video.quality.contains(quality)) {
newList.add(preferred, video)
preferred++
} else {
newList.add(video)
}
}
return newList
}
return this
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
return this.sortedWith(
compareBy(
{ it.quality.contains(quality) },
{ Regex("""(\d+)p""").find(it.quality)?.groupValues?.get(1)?.toIntOrNull() ?: 0 },
),
).reversed()
}
override fun episodeListRequest(anime: SAnime): Request {
@ -212,14 +173,15 @@ class Hanime : ConfigurableAnimeSource, AnimeHttpSource() {
}
override fun episodeListParse(response: Response): List<SEpisode> {
val responseString = response.body.string()
val jObject = json.decodeFromString<JsonObject>(responseString)
val episode = SEpisode.create()
episode.date_upload = jObject.jsonObject["hentai_video"]!!.jsonObject["released_at_unix"]!!.jsonPrimitive.long * 1000
episode.name = jObject.jsonObject["hentai_video"]!!.jsonObject["name"]!!.jsonPrimitive.content
episode.url = response.request.url.toString()
episode.episode_number = 1F
return listOf(episode)
val responseString = response.body.string().ifEmpty { return emptyList() }
return responseString.parseAs<VideoModel>().hentaiFranchiseHentaiVideos?.mapIndexed { idx, it ->
SEpisode.create().apply {
episode_number = idx + 1f
name = "Episode ${idx + 1}"
date_upload = (it.releasedAtUnix ?: 0) * 1000
url = "$baseUrl/api/v8/video?id=${it.id}"
}
}?.reversed() ?: emptyList()
}
private fun setAuthCookie() {
@ -244,12 +206,9 @@ class Hanime : ConfigurableAnimeSource, AnimeHttpSource() {
""".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 {
val responseString = response.body.string()
return parseSearchJson(responseString)
}
override fun latestUpdatesParse(response: Response) = parseSearchJson(response)
// Filters
override fun getFilterList(): AnimeFilterList = AnimeFilterList(
@ -257,23 +216,13 @@ class Hanime : ConfigurableAnimeSource, AnimeHttpSource() {
BrandList(getBrands()),
SortFilter(sortableList.map { it.first }.toTypedArray()),
TagInclusionMode(),
)
internal class Tag(val id: String, name: String) : AnimeFilter.TriState(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 BrandList(brands: List<Brand>) : AnimeFilter.Group<Brand>("Brands", brands)
private class TagInclusionMode :
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 class TagInclusionMode : AnimeFilter.Select<String>("Included tags mode", arrayOf("And", "Or"), 0)
private fun getSearchParameters(filters: AnimeFilterList): SearchParameters {
val includedTags = ArrayList<String>()
@ -567,13 +516,19 @@ class Hanime : ConfigurableAnimeSource, AnimeHttpSource() {
class SortFilter(sortables: Array<String>) : AnimeFilter.Sort("Sort", sortables, Selection(2, false))
// 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) {
val videoQualityPref = ListPreference(screen.context).apply {
key = "preferred_quality"
key = PREF_QUALITY_KEY
title = "Preferred quality"
entries = arrayOf("1080p", "720p", "480p", "360p")
entryValues = arrayOf("1080", "720", "480", "360")
setDefaultValue("720")
entries = QUALITY_LIST
entryValues = QUALITY_LIST
setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
@ -585,40 +540,4 @@ class Hanime : ConfigurableAnimeSource, AnimeHttpSource() {
}
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,
)
}
}
}
}
}
}
}