Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ gen
generated-src/
.kotlin
.history
/.opencode/
/.ocx/
2 changes: 1 addition & 1 deletion src/en/hanime/build.gradle
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
ext {
extName = 'hanime.tv'
extClass = '.Hanime'
extVersionCode = 18
extVersionCode = 19
isNsfw = true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,13 +194,13 @@ data class Stream(
@SerialName("mime_type")
val mimeType: String? = null,
val width: Long? = null,
val height: String,
val height: String? = null,
@SerialName("duration_in_ms")
val durationInMs: Long? = null,
@SerialName("filesize_mbs")
val filesizeMbs: Long? = null,
val filename: String? = null,
val url: String,
val url: String? = null,
@SerialName("is_guest_allowed")
val isGuestAllowed: Boolean? = false,
@SerialName("is_member_allowed")
Expand Down Expand Up @@ -228,7 +228,8 @@ data class WindowNuxt(
) {
@Serializable
data class Data(
val video: DataVideo,
val video: DataVideo? = null,
val hentai_videos: List<HentaiVideo> = emptyList(),
) {
@Serializable
data class DataVideo(
Expand All @@ -244,8 +245,8 @@ data class WindowNuxt(
) {
@Serializable
data class Stream(
val height: String,
val url: String,
val height: String? = null,
val url: String? = null,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,18 @@ class Hanime :

override fun animeDetailsParse(response: Response): SAnime {
val document = response.asJsoup()
val slug = document.location().substringAfterLast("/")

val nuxtData = document.selectFirst("script:containsData(__NUXT__)")?.data()
val coverUrl = nuxtData?.let {
it.substringAfter("__NUXT__=").substringBeforeLast(";")
.parseAs<WindowNuxt>().state.data.hentai_videos
.firstOrNull { video -> video.slug == slug }?.coverUrl
}

return SAnime.create().apply {
title = getTitle(document.select("h1.tv-title").text())
thumbnail_url = document.select("img.hvpi-cover").attr("src")
thumbnail_url = coverUrl ?: document.select("img.hvpi-cover").attr("src")
author = document.select("a.hvpimbc-text").text()
description = document.select("div.hvpist-description p").joinToString("\n\n") { it.text() }
status = SAnime.UNKNOWN
Expand Down Expand Up @@ -138,15 +147,21 @@ class Hanime :
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 parsed.state.data.video?.videos_manifest?.servers?.flatMap { server ->
server.streams.mapNotNull { stream ->
val url = stream.url ?: return@mapNotNull null
val height = stream.height ?: return@mapNotNull null
Video(url, "${height}p", url)
}
} ?: emptyList()
}

override fun videoListParse(response: Response): List<Video> {
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)
return responseString.parseAs<VideoModel>().videosManifest?.servers?.firstOrNull()?.streams?.filter { it.kind != "premium_alert" }?.mapNotNull { stream ->
val url = stream.url ?: return@mapNotNull null
val height = stream.height ?: return@mapNotNull null
Video(url, "${height}p", url)
} ?: emptyList()
}

Expand Down
2 changes: 1 addition & 1 deletion src/en/hstream/build.gradle
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
ext {
extName = 'Hstream'
extClass = '.Hstream'
extVersionCode = 10
extVersionCode = 11
isNsfw = true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
Expand Down Expand Up @@ -56,14 +57,17 @@ class Hstream :
override fun popularAnimeSelector() = "div.items-center div.w-full > a"

override fun popularAnimeFromElement(element: Element) = SAnime.create().apply {
setUrlWithoutDomain(element.attr("href"))
val href = element.attr("href")
setUrlWithoutDomain(getSeriesBaseUrl(href))
title = element.selectFirst("img")!!.attr("alt")
val episode = url.substringAfterLast("-").substringBefore("/")
thumbnail_url = "$baseUrl/images${url.substringBeforeLast("-")}/cover-ep-$episode.webp"
val episode = href.substringAfterLast("-").substringBefore("/")
thumbnail_url = "$baseUrl/images${href.substringBeforeLast("-")}/cover-ep-$episode.webp"
}

override fun popularAnimeNextPageSelector() = "span[aria-current] + a"

override fun popularAnimeParse(response: Response) = parseAnimeList(response, ::popularAnimeSelector, ::popularAnimeFromElement, ::popularAnimeNextPageSelector)

// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/search?order=recently-uploaded&page=$page")

Expand All @@ -73,6 +77,8 @@ class Hstream :

override fun latestUpdatesNextPageSelector() = popularAnimeNextPageSelector()

override fun latestUpdatesParse(response: Response) = parseAnimeList(response, ::latestUpdatesSelector, ::latestUpdatesFromElement, ::latestUpdatesNextPageSelector)

// =============================== Search ===============================
override fun getFilterList() = HstreamFilters.FILTER_LIST

Expand Down Expand Up @@ -114,6 +120,8 @@ class Hstream :

override fun searchAnimeNextPageSelector() = popularAnimeNextPageSelector()

override fun searchAnimeParse(response: Response) = parseAnimeList(response, ::searchAnimeSelector, ::searchAnimeFromElement, ::searchAnimeNextPageSelector)

// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document) = SAnime.create().apply {
status = SAnime.COMPLETED
Expand All @@ -129,17 +137,45 @@ class Hstream :
}

// ============================== Episodes ==============================
override fun episodeListRequest(anime: SAnime): Request {
val seriesUrl = getSeriesBaseUrl(anime.url)
return GET("$baseUrl$seriesUrl-1/")
}

override fun episodeListParse(response: Response): List<SEpisode> {
val currentUrl = response.request.url.encodedPath
val seriesPath = getSeriesBaseUrl(currentUrl)
val episodes = mutableListOf<SEpisode>()

// Parse episode 1 from the current response
val doc = response.asJsoup()
val episode = SEpisode.create().apply {
date_upload = doc.selectFirst("a:has(i.fa-upload)")?.ownText().toDate()
setUrlWithoutDomain(doc.location())
val num = url.substringAfterLast("-").substringBefore("/")
episode_number = num.toFloatOrNull() ?: 1F
name = "Episode $num"
episodes.add(parseEpisodeFromDoc(doc, 1, "$seriesPath-1/"))

// Probe for more episodes (2..50), break on first failure
for (epNum in 2..MAX_EPISODE_PROBE) {
val epPath = "$seriesPath-$epNum/"
try {
val resp = client.newCall(GET("$baseUrl$epPath")).execute()
if (resp.code != 200) {
resp.close()
break
}
val epDoc = resp.asJsoup()
episodes.add(parseEpisodeFromDoc(epDoc, epNum, epPath))
resp.close()
} catch (e: Exception) {
break
}
}

return listOf(episode)
return episodes
}

private fun parseEpisodeFromDoc(doc: Document, epNum: Int, url: String): SEpisode = SEpisode.create().apply {
date_upload = doc.selectFirst("a:has(i.fa-upload)")?.ownText().toDate()
setUrlWithoutDomain(url)
episode_number = epNum.toFloat()
name = "Episode $epNum"
}

override fun episodeListSelector(): String = throw UnsupportedOperationException()
Expand All @@ -163,7 +199,7 @@ class Hstream :
set("X-XSRF-TOKEN", URLDecoder.decode(token, "utf-8"))
}.build()

val body = """{"episode_id": "$episodeId"}""".toRequestBody("application/json".toMediaType())
val body = json.encodeToString(EpisodeRequest(episodeId)).toRequestBody("application/json".toMediaType())
val data = client.newCall(POST("$baseUrl/player/api", newHeaders, body)).execute()
.parseAs<PlayerApiResponse>()

Expand All @@ -187,6 +223,9 @@ class Hstream :
"/$resolution/manifest.mpd"
}

@Serializable
data class EpisodeRequest(val episode_id: String)

@Serializable
data class PlayerApiResponse(
val legacy: Int = 0,
Expand Down Expand Up @@ -221,15 +260,31 @@ class Hstream :
}

// ============================= Utilities ==============================
private fun parseAnimeList(
response: Response,
selector: () -> String,
fromElement: (Element) -> SAnime,
nextPageSelector: () -> String,
): AnimesPage {
val document = response.asJsoup()
val animeList = document.select(selector())
.map(fromElement)
.distinctBy { it.title }
val hasNextPage = document.selectFirst(nextPageSelector()) != null
return AnimesPage(animeList, hasNextPage)
}

private fun getSeriesBaseUrl(url: String): String = url.replace(Regex("-\\d+/?$"), "").trimEnd('/')

private fun String?.toDate(): Long = runCatching { DATE_FORMATTER.parse(orEmpty().trim(' ', '|'))?.time }
.getOrNull() ?: 0L

override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!

return sortedWith(
compareBy { it.quality.contains(quality) },
).reversed()
compareByDescending { it.quality.contains(quality) },
)
}

companion object {
Expand All @@ -239,6 +294,7 @@ class Hstream :

const val PREFIX_SEARCH = "id:"

private const val MAX_EPISODE_PROBE = 50
private const val PREF_QUALITY_KEY = "pref_quality_key"
private const val PREF_QUALITY_TITLE = "Preferred quality"
private const val PREF_QUALITY_DEFAULT = "720p"
Expand Down