feat(src/all): New source: Hikari (#3193)

This commit is contained in:
Secozzi 2024-04-27 22:44:34 +00:00 committed by GitHub
parent 58083d47fb
commit 7e6b571c7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 579 additions and 33 deletions

View File

@ -14,47 +14,48 @@ import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class FilemoonExtractor(private val client: OkHttpClient) { class FilemoonExtractor(private val client: OkHttpClient) {
private val playlistUtils by lazy { PlaylistUtils(client) }
private val json: Json by injectLazy() private val json: Json by injectLazy()
fun videosFromUrl(url: String, prefix: String = "Filemoon - ", headers: Headers? = null): List<Video> { fun videosFromUrl(url: String, prefix: String = "Filemoon - ", headers: Headers? = null): List<Video> {
return runCatching { val httpUrl = url.toHttpUrl()
val httpUrl = url.toHttpUrl() val videoHeaders = (headers?.newBuilder() ?: Headers.Builder())
val videoHeaders = (headers?.newBuilder() ?: Headers.Builder()) .set("Referer", url)
.set("Referer", url) .set("Origin", "https://${httpUrl.host}")
.set("Origin", "https://${httpUrl.host}") .build()
.build()
val doc = client.newCall(GET(url, videoHeaders)).execute().asJsoup() val doc = client.newCall(GET(url, videoHeaders)).execute().asJsoup()
val jsEval = doc.selectFirst("script:containsData(eval):containsData(m3u8)")!!.data() val jsEval = doc.selectFirst("script:containsData(eval):containsData(m3u8)")!!.data()
val unpacked = JsUnpacker.unpackAndCombine(jsEval).orEmpty() val unpacked = JsUnpacker.unpackAndCombine(jsEval).orEmpty()
val masterUrl = unpacked.takeIf(String::isNotBlank) val masterUrl = unpacked.takeIf(String::isNotBlank)
?.substringAfter("{file:\"", "") ?.substringAfter("{file:\"", "")
?.substringBefore("\"}", "") ?.substringBefore("\"}", "")
?.takeIf(String::isNotBlank) ?.takeIf(String::isNotBlank)
?: return emptyList() ?: return emptyList()
val subtitleTracks = buildList { val subtitleTracks = buildList {
// Subtitles from a external URL // Subtitles from a external URL
val subUrl = httpUrl.queryParameter("sub.info") val subUrl = httpUrl.queryParameter("sub.info")
?: unpacked.substringAfter("fetch('", "") ?: unpacked.substringAfter("fetch('", "")
.substringBefore("').") .substringBefore("').")
.takeIf(String::isNotBlank) .takeIf(String::isNotBlank)
if (subUrl != null) { if (subUrl != null) {
runCatching { // to prevent failures on serialization errors runCatching { // to prevent failures on serialization errors
client.newCall(GET(subUrl, videoHeaders)).execute() client.newCall(GET(subUrl, videoHeaders)).execute()
.body.string() .body.string()
.let { json.decodeFromString<List<SubtitleDto>>(it) } .let { json.decodeFromString<List<SubtitleDto>>(it) }
.forEach { add(Track(it.file, it.label)) } .forEach { add(Track(it.file, it.label)) }
}
} }
} }
}
PlaylistUtils(client, videoHeaders).extractFromHls( return playlistUtils.extractFromHls(
masterUrl, masterUrl,
subtitleList = subtitleTracks, subtitleList = subtitleTracks,
videoNameGen = { "$prefix$it" }, referer = "https://${httpUrl.host}/",
) videoNameGen = { "$prefix$it" },
}.getOrElse { emptyList() } )
} }
@Serializable @Serializable

View File

@ -0,0 +1,11 @@
ext {
extName = 'Hikari'
extClass = '.Hikari'
extVersionCode = 1
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(':lib:filemoon-extractor'))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -0,0 +1,255 @@
package eu.kanade.tachiyomi.animeextension.all.hikari
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import okhttp3.HttpUrl
import java.util.Calendar
interface UriFilter {
fun addToUri(url: HttpUrl.Builder)
}
sealed class UriPartFilter(
name: String,
private val param: String,
private val vals: Array<Pair<String, String>>,
defaultValue: String? = null,
) : AnimeFilter.Select<String>(
name,
vals.map { it.first }.toTypedArray(),
vals.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0,
),
UriFilter {
override fun addToUri(builder: HttpUrl.Builder) {
builder.addQueryParameter(param, vals[state].second)
}
}
class UriMultiSelectOption(name: String, val value: String) : AnimeFilter.CheckBox(name)
sealed class UriMultiSelectFilter(
name: String,
private val param: String,
private val vals: Array<Pair<String, String>>,
) : AnimeFilter.Group<UriMultiSelectOption>(name, vals.map { UriMultiSelectOption(it.first, it.second) }), UriFilter {
override fun addToUri(builder: HttpUrl.Builder) {
val checked = state.filter { it.state }
builder.addQueryParameter(param, checked.joinToString(",") { it.value })
}
}
class TypeFilter : UriPartFilter(
"Type",
"type",
arrayOf(
Pair("All", ""),
Pair("TV", "1"),
Pair("Movie", "2"),
Pair("OVA", "3"),
Pair("ONA", "4"),
Pair("Special", "5"),
),
)
class CountryFilter : UriPartFilter(
"Country",
"country",
arrayOf(
Pair("All", ""),
Pair("Japanese", "1"),
Pair("Chinese", "2"),
),
)
class StatusFilter : UriPartFilter(
"Status",
"stats",
arrayOf(
Pair("All", ""),
Pair("Currently Airing", "1"),
Pair("Finished Airing", "2"),
Pair("Not yet Aired", "3"),
),
)
class RatingFilter : UriPartFilter(
"Rating",
"rate",
arrayOf(
Pair("All", ""),
Pair("G", "1"),
Pair("PG", "2"),
Pair("PG-13", "3"),
Pair("R-17+", "4"),
Pair("R+", "5"),
Pair("Rx", "6"),
),
)
class SourceFilter : UriPartFilter(
"Source",
"source",
arrayOf(
Pair("All", ""),
Pair("LightNovel", "1"),
Pair("Manga", "2"),
Pair("Original", "3"),
),
)
class SeasonFilter : UriPartFilter(
"Season",
"season",
arrayOf(
Pair("All", ""),
Pair("Spring", "1"),
Pair("Summer", "2"),
Pair("Fall", "3"),
Pair("Winter", "4"),
),
)
class LanguageFilter : UriPartFilter(
"Language",
"language",
arrayOf(
Pair("All", ""),
Pair("Raw", "1"),
Pair("Sub", "2"),
Pair("Dub", "3"),
Pair("Turk", "4"),
),
)
class SortFilter : UriPartFilter(
"Sort",
"sort",
arrayOf(
Pair("Default", "default"),
Pair("Recently Added", "recently_added"),
Pair("Recently Updated", "recently_updated"),
Pair("Score", "score"),
Pair("Name A-Z", "name_az"),
Pair("Released Date", "released_date"),
Pair("Most Watched", "most_watched"),
),
)
class YearFilter(name: String, param: String) : UriPartFilter(
name,
param,
YEARS,
) {
companion object {
private val NEXT_YEAR by lazy {
Calendar.getInstance()[Calendar.YEAR] + 1
}
private val YEARS = Array(NEXT_YEAR - 1917) { year ->
if (year == 0) {
Pair("Any", "")
} else {
(NEXT_YEAR - year).toString().let { Pair(it, it) }
}
}
}
}
class MonthFilter(name: String, param: String) : UriPartFilter(
name,
param,
MONTHS,
) {
companion object {
private val MONTHS = Array(13) { months ->
if (months == 0) {
Pair("Any", "")
} else {
val monthStr = "%02d".format(months)
Pair(monthStr, monthStr)
}
}
}
}
class DayFilter(name: String, param: String) : UriPartFilter(
name,
param,
DAYS,
) {
companion object {
private val DAYS = Array(32) { day ->
if (day == 0) {
Pair("Any", "")
} else {
val dayStr = "%02d".format(day)
Pair(dayStr, dayStr)
}
}
}
}
class AiringDateFilter(
private val values: List<UriPartFilter> = PARTS,
) : AnimeFilter.Group<UriPartFilter>("Airing Date", values), UriFilter {
override fun addToUri(builder: HttpUrl.Builder) {
values.forEach {
it.addToUri(builder)
}
}
companion object {
private val PARTS = listOf(
YearFilter("Year", "aired_year"),
MonthFilter("Month", "aired_month"),
DayFilter("Day", "aired_day"),
)
}
}
class GenreFilter : UriMultiSelectFilter(
"Genre",
"genres",
arrayOf(
Pair("Action", "Action"),
Pair("Adventure", "Adventure"),
Pair("Cars", "Cars"),
Pair("Comedy", "Comedy"),
Pair("Dementia", "Dementia"),
Pair("Demons", "Demons"),
Pair("Drama", "Drama"),
Pair("Ecchi", "Ecchi"),
Pair("Fantasy", "Fantasy"),
Pair("Game", "Game"),
Pair("Harem", "Harem"),
Pair("Historical", "Historical"),
Pair("Horror", "Horror"),
Pair("Isekai", "Isekai"),
Pair("Josei", "Josei"),
Pair("Kids", "Kids"),
Pair("Magic", "Magic"),
Pair("Martial Arts", "Martial Arts"),
Pair("Mecha", "Mecha"),
Pair("Military", "Military"),
Pair("Music", "Music"),
Pair("Mystery", "Mystery"),
Pair("Parody", "Parody"),
Pair("Police", "Police"),
Pair("Psychological", "Psychological"),
Pair("Romance", "Romance"),
Pair("Samurai", "Samurai"),
Pair("School", "School"),
Pair("Sci-Fi", "Sci-Fi"),
Pair("Seinen", "Seinen"),
Pair("Shoujo", "Shoujo"),
Pair("Shoujo Ai", "Shoujo Ai"),
Pair("Shounen", "Shounen"),
Pair("Shounen Ai", "Shounen Ai"),
Pair("Slice of Life", "Slice of Life"),
Pair("Space", "Space"),
Pair("Sports", "Sports"),
Pair("Super Power", "Super Power"),
Pair("Supernatural", "Supernatural"),
Pair("Thriller", "Thriller"),
Pair("Vampire", "Vampire"),
),
)

View File

@ -0,0 +1,279 @@
package eu.kanade.tachiyomi.animeextension.all.hikari
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
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.lib.filemoonextractor.FilemoonExtractor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.parallelCatchingFlatMapBlocking
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.Serializable
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
class Hikari : ParsedAnimeHttpSource() {
override val name = "Hikari"
override val baseUrl = "https://watch.hikaritv.xyz"
override val lang = "all"
override val supportsLatest = true
override fun headersBuilder() = super.headersBuilder().apply {
add("Origin", baseUrl)
add("Referer", "$baseUrl/")
}
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int): Request {
val url = "$baseUrl/ajax/getfilter?type=&country=&stats=&rate=&source=&season=&language=&aired_year=&aired_month=&aired_day=&sort=score&genres=&page=$page"
val headers = headersBuilder().set("Referer", "$baseUrl/filter").build()
return GET(url, headers)
}
override fun popularAnimeParse(response: Response): AnimesPage {
val parsed = response.parseAs<HtmlResponseDto>()
val hasNextPage = response.request.url.queryParameter("page")!!.toInt() < parsed.page!!.totalPages
val animeList = parsed.toHtml(baseUrl).select(popularAnimeSelector())
.map(::popularAnimeFromElement)
return AnimesPage(animeList, hasNextPage)
}
override fun popularAnimeSelector(): String = ".flw-item"
override fun popularAnimeFromElement(element: Element): SAnime = SAnime.create().apply {
setUrlWithoutDomain(element.selectFirst("a[data-id]")!!.attr("abs:href"))
thumbnail_url = element.selectFirst("img")!!.attr("abs:src")
title = element.selectFirst(".film-name")!!.text()
}
override fun popularAnimeNextPageSelector(): String? = null
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request {
val url = "$baseUrl/ajax/getfilter?type=&country=&stats=&rate=&source=&season=&language=&aired_year=&aired_month=&aired_day=&sort=recently_updated&genres=&page=$page"
val headers = headersBuilder().set("Referer", "$baseUrl/filter").build()
return GET(url, headers)
}
override fun latestUpdatesParse(response: Response): AnimesPage =
popularAnimeParse(response)
override fun latestUpdatesSelector(): String =
throw UnsupportedOperationException()
override fun latestUpdatesFromElement(element: Element): SAnime =
throw UnsupportedOperationException()
override fun latestUpdatesNextPageSelector(): String =
throw UnsupportedOperationException()
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val url = baseUrl.toHttpUrl().newBuilder().apply {
if (query.isNotEmpty()) {
addPathSegment("search")
addQueryParameter("keyword", query)
addQueryParameter("page", page.toString())
} else {
addPathSegment("ajax")
addPathSegment("getfilter")
filters.filterIsInstance<UriFilter>().forEach {
it.addToUri(this)
}
addQueryParameter("page", page.toString())
}
}.build()
val headers = headersBuilder().apply {
if (query.isNotEmpty()) {
set("Referer", url.toString().substringBeforeLast("&page"))
} else {
set("Referer", "$baseUrl/filter")
}
}.build()
return GET(url, headers)
}
override fun searchAnimeParse(response: Response): AnimesPage {
return if (response.request.url.encodedPath.startsWith("/search")) {
super.searchAnimeParse(response)
} else {
popularAnimeParse(response)
}
}
override fun searchAnimeSelector(): String = popularAnimeSelector()
override fun searchAnimeFromElement(element: Element): SAnime = popularAnimeFromElement(element)
override fun searchAnimeNextPageSelector(): String = "ul.pagination > li.active + li"
// ============================== Filters ===============================
override fun getFilterList(): AnimeFilterList = AnimeFilterList(
AnimeFilter.Header("Note: text search ignores filters"),
AnimeFilter.Separator(),
TypeFilter(),
CountryFilter(),
StatusFilter(),
RatingFilter(),
SourceFilter(),
SeasonFilter(),
LanguageFilter(),
SortFilter(),
AiringDateFilter(),
GenreFilter(),
)
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime = SAnime.create().apply {
with(document.selectFirst("#ani_detail")!!) {
title = selectFirst(".film-name")!!.text()
thumbnail_url = selectFirst(".film-poster img")!!.attr("abs:src")
description = selectFirst(".film-description > .text")?.text()
genre = select(".item-list:has(span:contains(Genres)) > a").joinToString { it.text() }
author = select(".item:has(span:contains(Studio)) > a").joinToString { it.text() }
status = selectFirst(".item:has(span:contains(Status)) > .name").parseStatus()
}
}
private fun Element?.parseStatus(): Int = when (this?.text()?.lowercase()) {
"currently airing" -> SAnime.ONGOING
"finished" -> SAnime.COMPLETED
else -> SAnime.UNKNOWN
}
// ============================== Episodes ==============================
private val specialCharRegex = Regex("""(?![\-_])\W{1,}""")
override fun episodeListRequest(anime: SAnime): Request {
val animeId = anime.url.split("/")[2]
val sanitized = anime.title.replace(" ", "_")
val refererUrl = baseUrl.toHttpUrl().newBuilder().apply {
addPathSegment("watch")
addQueryParameter("anime", specialCharRegex.replace(sanitized, ""))
addQueryParameter("uid", animeId)
addQueryParameter("eps", "1")
}.build()
val headers = headersBuilder()
.set("Referer", refererUrl.toString())
.build()
return GET("$baseUrl/ajax/episodelist/$animeId", headers)
}
override fun episodeListParse(response: Response): List<SEpisode> {
return response.parseAs<HtmlResponseDto>().toHtml(baseUrl)
.select(episodeListSelector())
.map(::episodeFromElement)
.reversed()
}
override fun episodeListSelector() = "a[class~=ep-item]"
override fun episodeFromElement(element: Element): SEpisode {
val ep = element.selectFirst(".ssli-order")!!.text()
return SEpisode.create().apply {
setUrlWithoutDomain(element.attr("abs:href"))
episode_number = ep.toFloat()
name = "Ep. $ep - ${element.selectFirst(".ep-name")?.text() ?: ""}"
}
}
// ============================ Video Links =============================
private val filemoonExtractor by lazy { FilemoonExtractor(client) }
private val embedRegex = Regex("""getEmbed\(\s*(\d+)\s*,\s*(\d+)\s*,\s*'(\d+)'""")
override fun videoListRequest(episode: SEpisode): Request {
val url = (baseUrl + episode.url).toHttpUrl()
val animeId = url.queryParameter("uid")!!
val episodeNum = url.queryParameter("eps")!!
val headers = headersBuilder()
.set("Referer", baseUrl + episode.url)
.build()
return GET("$baseUrl/ajax/embedserver/$animeId/$episodeNum", headers)
}
override fun videoListParse(response: Response): List<Video> {
val html = response.parseAs<HtmlResponseDto>().toHtml(baseUrl)
val headers = headersBuilder()
.set("Referer", response.request.url.toString())
.build()
val embedUrls = html.select(videoListSelector()).flatMap {
val name = it.text()
val onClick = it.selectFirst("a")!!.attr("onclick")
val match = embedRegex.find(onClick)!!.groupValues
val url = "$baseUrl/ajax/embed/${match[1]}/${match[2]}/${match[3]}"
val iframeList = client.newCall(
GET(url, headers),
).execute().parseAs<List<String>>()
iframeList.map {
Pair(Jsoup.parseBodyFragment(it).selectFirst("iframe")!!.attr("src"), name)
}
}
return embedUrls.parallelCatchingFlatMapBlocking {
getVideosFromEmbed(it.first, it.second)
}
}
private fun getVideosFromEmbed(embedUrl: String, name: String): List<Video> {
return when {
embedUrl.contains("filemoon", true) -> {
filemoonExtractor.videosFromUrl(embedUrl, prefix = "$name - ", headers = headers)
}
else -> emptyList()
}
}
override fun videoListSelector() = ".server-item:has(a[onclick~=getEmbed])"
override fun videoFromElement(element: Element): Video =
throw UnsupportedOperationException()
override fun videoUrlParse(document: Document): String =
throw UnsupportedOperationException()
// ============================= Utilities ==============================
@Serializable
class HtmlResponseDto(
val html: String,
val page: PageDto? = null,
) {
fun toHtml(baseUrl: String): Document = Jsoup.parseBodyFragment(html, baseUrl)
@Serializable
class PageDto(
val totalPages: Int,
)
}
}