From 9af8932a69ef1549e5289d501e63fd411ac2ba73 Mon Sep 17 00:00:00 2001 From: imper1aldev <23511335+imper1aldev@users.noreply.github.com> Date: Sat, 23 Mar 2024 04:27:13 -0600 Subject: [PATCH] feat(en/hanime): Episode grouping (#3072) --- src/en/hanime/build.gradle | 2 +- .../animeextension/en/hanime/DataModel.kt | 255 +++++++++++++++++ .../animeextension/en/hanime/Hanime.kt | 259 ++++++------------ 3 files changed, 345 insertions(+), 171 deletions(-) create mode 100644 src/en/hanime/src/eu/kanade/tachiyomi/animeextension/en/hanime/DataModel.kt diff --git a/src/en/hanime/build.gradle b/src/en/hanime/build.gradle index 01ed60c3c2..24e87e7e17 100644 --- a/src/en/hanime/build.gradle +++ b/src/en/hanime/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'hanime.tv' extClass = '.Hanime' - extVersionCode = 17 + extVersionCode = 18 isNsfw = true } diff --git a/src/en/hanime/src/eu/kanade/tachiyomi/animeextension/en/hanime/DataModel.kt b/src/en/hanime/src/eu/kanade/tachiyomi/animeextension/en/hanime/DataModel.kt new file mode 100644 index 0000000000..f976e8947e --- /dev/null +++ b/src/en/hanime/src/eu/kanade/tachiyomi/animeextension/en/hanime/DataModel.kt @@ -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, + val blackListedTags: ArrayList, + val brands: ArrayList, + 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 = 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 = 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? = emptyList(), + @SerialName("hentai_franchise_hentai_videos") + val hentaiFranchiseHentaiVideos: List? = 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? = 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? = 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 = 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, + ) { + @Serializable + data class Server( + val streams: List, + ) { + @Serializable + data class Stream( + val height: String, + val url: String, + ) + } + } + } + } + } +} diff --git a/src/en/hanime/src/eu/kanade/tachiyomi/animeextension/en/hanime/Hanime.kt b/src/en/hanime/src/eu/kanade/tachiyomi/animeextension/en/hanime/Hanime.kt index f444d34b45..d04fdcb8c0 100644 --- a/src/en/hanime/src/eu/kanade/tachiyomi/animeextension/en/hanime/Hanime.kt +++ b/src/en/hanime/src/eu/kanade/tachiyomi/animeextension/en/hanime/Hanime.kt @@ -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): 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(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(arrayString) - val animeList = mutableListOf() - 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("

", "").replace("

", "") - anime.status = SAnime.COMPLETED - val tags = item.jsonObject["tags"]!!.jsonArray - anime.genre = tags.joinToString(", ") { it.jsonPrimitive.content } - anime.initialized = true - animeList.add(anime) + 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() + val hasNextPage = jResponse.page < jResponse.nbPages - 1 + val array = jResponse.hits.parseAs>() + + 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) + } } + 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)) - - override fun searchAnimeParse(response: Response): AnimesPage { - val responseString = response.body.string() - return parseSearchJson(responseString) + private fun isNumber(num: String) = (num.toIntOrNull() != null) + + 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