add hikari

This commit is contained in:
V3u47ZoN 2025-04-30 15:15:30 +00:00 committed by GitHub
parent 45cff438ce
commit 335d89e5ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 591 additions and 480 deletions

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,52 @@
package eu.kanade.tachiyomi.lib.buzzheavierextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.internal.EMPTY_HEADERS
class BuzzheavierExtractor(
private val client: OkHttpClient,
private val headers: Headers,
) {
@OptIn(ExperimentalSerializationApi::class)
fun videosFromUrl(url: String, prefix: String = "Buzzheavier - ", proxyUrl: String? = null): List<Video> {
val httpUrl = url.toHttpUrl()
val id = httpUrl.pathSegments.first()
val dlHeaders = headers.newBuilder().apply {
add("Accept", "*/*")
add("Host", httpUrl.host)
add("HX-Current-URL", url)
add("HX-Request", "true")
add("Referer", url)
}.build()
val videoHeaders = headers.newBuilder().apply {
add("Referer", url)
}.build()
val path = client.newCall(GET("$url/download", dlHeaders)).execute().headers["hx-redirect"].orEmpty()
return if (path.isNotEmpty()) {
val videoUrl = if (path.startsWith("http")) path else "https://${httpUrl.host}$path"
listOf(Video(videoUrl, "${prefix}Video", videoUrl, videoHeaders))
} else if (proxyUrl?.isNotEmpty() == true) {
val videoUrl = client.newCall(GET(proxyUrl + id)).execute().parseAs<UrlDto>().url
listOf(Video(videoUrl, "${prefix}Video", videoUrl, videoHeaders))
} else {
emptyList()
}
}
@Serializable
data class UrlDto(val url: String)
}

View file

@ -1,73 +1,53 @@
package eu.kanade.tachiyomi.lib.chillxextractor
import android.util.Log
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
class ChillxExtractor(private val client: OkHttpClient, private val headers: Headers) {
private val json: Json by injectLazy()
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
private val webViewResolver by lazy { WebViewResolver(client, headers) }
companion object {
private val REGEX_MASTER_JS = Regex("""\s*=\s*'([^']+)""")
private val REGEX_SOURCES = Regex("""sources:\s*\[\{"file":"([^"]+)""")
private val REGEX_FILE = Regex("""file: ?"([^"]+)"""")
private val REGEX_SOURCE = Regex("""source = ?"([^"]+)"""")
private val REGEX_SUBS = Regex("""\{"file":"([^"]+)","label":"([^"]+)","kind":"captions","default":\w+\}""")
private const val KEY_SOURCE = "https://raw.githubusercontent.com/Rowdy-Avocado/multi-keys/keys/index.html"
}
fun videoFromUrl(url: String, referer: String, prefix: String = "Chillx - "): List<Video> {
val newHeaders = headers.newBuilder()
.set("Referer", "$referer/")
.set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
.set("Accept-Language", "en-US,en;q=0.5")
.build()
fun videoFromUrl(url: String, prefix: String = "Chillx - "): List<Video> {
val data = webViewResolver.getDecryptedData(url) ?: return emptyList()
val body = client.newCall(GET(url, newHeaders)).execute().body.string()
val master = REGEX_MASTER_JS.find(body)?.groupValues?.get(1) ?: return emptyList()
val aesJson = json.decodeFromString<CryptoInfo>(master)
val key = fetchKey() ?: throw ErrorLoadingException("Unable to get key")
val decryptedScript = CryptoAES.decryptWithSalt(aesJson.ciphertext, aesJson.salt, key)
.replace("\\n", "\n")
.replace("\\", "")
val masterUrl = REGEX_SOURCES.find(decryptedScript)?.groupValues?.get(1)
?: REGEX_FILE.find(decryptedScript)?.groupValues?.get(1)
?: REGEX_SOURCE.find(decryptedScript)?.groupValues?.get(1)
val masterUrl = REGEX_SOURCES.find(data)?.groupValues?.get(1)
?: REGEX_FILE.find(data)?.groupValues?.get(1)
?: REGEX_SOURCE.find(data)?.groupValues?.get(1)
?: return emptyList()
val subtitleList = buildList {
val subtitles = REGEX_SUBS.findAll(decryptedScript)
val subtitles = REGEX_SUBS.findAll(data)
subtitles.forEach {
Log.d("ChillxExtractor", "Found subtitle: ${it.groupValues}")
add(Track(it.groupValues[1], decodeUnicodeEscape(it.groupValues[2])))
}
}
return playlistUtils.extractFromHls(
val videoList = playlistUtils.extractFromHls(
playlistUrl = masterUrl,
referer = url,
videoNameGen = { "$prefix$it" },
subtitleList = subtitleList,
)
}
@OptIn(ExperimentalSerializationApi::class)
private fun fetchKey(): String? {
return client.newCall(GET(KEY_SOURCE)).execute().parseAs<KeysData>().keys.firstOrNull()
return videoList.map {
Video(
url = it.url,
quality = it.quality,
videoUrl = it.videoUrl,
audioTracks = it.audioTracks,
subtitleTracks = playlistUtils.fixSubtitles(it.subtitleTracks),
)
}
}
private fun decodeUnicodeEscape(input: String): String {
@ -76,16 +56,4 @@ class ChillxExtractor(private val client: OkHttpClient, private val headers: Hea
it.groupValues[1].toInt(16).toChar().toString()
}
}
@Serializable
data class CryptoInfo(
@SerialName("ct") val ciphertext: String,
@SerialName("s") val salt: String,
)
@Serializable
data class KeysData(
@SerialName("chillx") val keys: List<String>
)
}
class ErrorLoadingException(message: String) : Exception(message)

View file

@ -0,0 +1,124 @@
package eu.kanade.tachiyomi.lib.chillxextractor
import android.annotation.SuppressLint
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import eu.kanade.tachiyomi.network.GET
import okhttp3.Headers
import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayInputStream
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class WebViewResolver(
private val client: OkHttpClient,
private val globalHeaders: Headers,
) {
private val context: Application by injectLazy()
private val handler by lazy { Handler(Looper.getMainLooper()) }
class JsInterface(private val latch: CountDownLatch) {
var result: String? = null
@JavascriptInterface
fun passPayload(payload: String) {
result = payload
latch.countDown()
}
}
@SuppressLint("SetJavaScriptEnabled")
fun getDecryptedData(embedUrl: String): String? {
val latch = CountDownLatch(1)
var webView: WebView? = null
val jsi = JsInterface(latch)
val interfaceName = randomString()
handler.post {
val webview = WebView(context)
webView = webview
with(webview.settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
useWideViewPort = false
loadWithOverviewMode = false
userAgentString = globalHeaders["User-Agent"]
}
webview.addJavascriptInterface(jsi, interfaceName)
webview.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
if (request?.url.toString().contains("assets/js/library")) {
return patchScript(request!!.url.toString(), interfaceName)
?: super.shouldInterceptRequest(view, request)
}
return super.shouldInterceptRequest(view, request)
}
}
webView?.loadUrl(embedUrl)
}
latch.await(TIMEOUT_SEC, TimeUnit.SECONDS)
handler.post {
webView?.stopLoading()
webView?.destroy()
webView = null
}
return jsi.result
}
companion object {
const val TIMEOUT_SEC: Long = 30
}
private fun randomString(length: Int = 10): String {
val charPool = ('a'..'z') + ('A'..'Z')
return List(length) { charPool.random() }.joinToString("")
}
private fun patchScript(scriptUrl: String, interfaceName: String): WebResourceResponse? {
val scriptBody = client.newCall(GET(scriptUrl)).execute().body.string()
val oldFunc = randomString()
val newBody = buildString {
append(
"""
const $oldFunc = Function;
window.Function = function (...args) {
if (args.length == 1) {
window.$interfaceName.passPayload(args[0]);
}
return $oldFunc(...args);
};
""".trimIndent()
)
append(scriptBody)
}
return WebResourceResponse(
"application/javascript",
"utf-8",
200,
"ok",
mapOf("server" to "cloudflare"),
ByteArrayInputStream(newBody.toByteArray()),
)
}
}

View file

@ -1,5 +1,8 @@
package eu.kanade.tachiyomi.lib.filemoonextractor
import android.content.SharedPreferences
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen
import dev.datlag.jsunpacker.JsUnpacker
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
@ -13,20 +16,28 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
class FilemoonExtractor(private val client: OkHttpClient) {
class FilemoonExtractor(
private val client: OkHttpClient,
private val preferences: SharedPreferences? = null,
) {
private val playlistUtils by lazy { PlaylistUtils(client) }
private val json: Json by injectLazy()
fun videosFromUrl(url: String, prefix: String = "Filemoon - ", headers: Headers? = null): List<Video> {
val httpUrl = url.toHttpUrl()
var httpUrl = url.toHttpUrl()
val videoHeaders = (headers?.newBuilder() ?: Headers.Builder())
.set("Referer", url)
.set("Origin", "https://${httpUrl.host}")
.build()
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() ?: run {
val iframeUrl = doc.selectFirst("iframe[src]")!!.attr("src")
httpUrl = iframeUrl.toHttpUrl()
val iframeDoc = client.newCall(GET(iframeUrl, videoHeaders)).execute().asJsoup()
iframeDoc.selectFirst("script:containsData(eval):containsData(m3u8)")!!.data()
}
val unpacked = JsUnpacker.unpackAndCombine(jsEval).orEmpty()
val masterUrl = unpacked.takeIf(String::isNotBlank)
?.substringAfter("{file:\"", "")
@ -50,14 +61,39 @@ class FilemoonExtractor(private val client: OkHttpClient) {
}
}
return playlistUtils.extractFromHls(
val videoList = playlistUtils.extractFromHls(
masterUrl,
subtitleList = subtitleTracks,
referer = "https://${httpUrl.host}/",
videoNameGen = { "$prefix$it" },
)
val subPref = preferences?.getString(PREF_SUBTITLE_KEY, PREF_SUBTITLE_DEFAULT).orEmpty()
return videoList.map {
Video(
url = it.url,
quality = it.quality,
videoUrl = it.videoUrl,
audioTracks = it.audioTracks,
subtitleTracks = it.subtitleTracks.filter { tracks -> tracks.lang.contains(subPref, true) }
)
}
}
@Serializable
data class SubtitleDto(val file: String, val label: String)
companion object {
fun addSubtitlePref(screen: PreferenceScreen) {
EditTextPreference(screen.context).apply {
key = PREF_SUBTITLE_KEY
title = "Filemoon subtitle preference"
summary = "Leave blank to use all subs"
setDefaultValue(PREF_SUBTITLE_DEFAULT)
}.also(screen::addPreference)
}
private const val PREF_SUBTITLE_KEY = "pref_filemoon_sub_lang_key"
private const val PREF_SUBTITLE_DEFAULT = "eng"
}
}

View file

@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,67 @@
package eu.kanade.tachiyomi.lib.savefileextractor
import android.content.SharedPreferences
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
class SavefileExtractor(
private val client: OkHttpClient,
private val preferences: SharedPreferences,
) {
private val playlistUtils by lazy { PlaylistUtils(client) }
fun videosFromUrl(url: String, prefix: String = "Savefile - ", headers: Headers? = null): List<Video> {
val httpUrl = url.toHttpUrl()
val videoHeaders = (headers?.newBuilder() ?: Headers.Builder())
.set("Referer", url)
.set("Origin", "https://${httpUrl.host}")
.build()
val doc = client.newCall(GET(url, videoHeaders)).execute().asJsoup()
val js = doc.selectFirst("script:containsData(m3u8)")!!.data()
val masterUrl = js.takeIf(String::isNotBlank)
?.substringAfter("{file:\"", "")
?.substringBefore("\"}", "")
?.takeIf(String::isNotBlank)
?: return emptyList()
val videoList = playlistUtils.extractFromHls(
masterUrl,
referer = "https://${httpUrl.host}/",
videoNameGen = { "$prefix$it" },
)
val subPref = preferences.getString(PREF_SUBTITLE_KEY, PREF_SUBTITLE_DEFAULT).orEmpty()
return videoList.map {
Video(
url = it.url,
quality = it.quality,
videoUrl = it.videoUrl,
audioTracks = it.audioTracks,
subtitleTracks = it.subtitleTracks.filter { tracks -> tracks.lang.contains(subPref, true) }
)
}
}
companion object {
fun addSubtitlePref(screen: PreferenceScreen) {
EditTextPreference(screen.context).apply {
key = PREF_SUBTITLE_KEY
title = "Savefile subtitle preference"
summary = "Leave blank to use all subs"
setDefaultValue(PREF_SUBTITLE_DEFAULT)
}.also(screen::addPreference)
}
private const val PREF_SUBTITLE_KEY = "pref_savefile_sub_lang_key"
private const val PREF_SUBTITLE_DEFAULT = "eng"
}
}

View file

@ -10,6 +10,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
class StreamWishExtractor(private val client: OkHttpClient, private val headers: Headers) {
@ -30,16 +31,19 @@ class StreamWishExtractor(private val client: OkHttpClient, private val headers:
script
}
}
val masterUrl = scriptBody
?.substringAfter("source", "")
?.substringAfter("file:\"", "")
?.substringBefore("\"", "")
?.takeIf(String::isNotBlank)
val masterUrl = scriptBody?.let {
M3U8_REGEX.find(it)?.value
}
?: return emptyList()
val subtitleList = extractSubtitles(scriptBody)
return playlistUtils.extractFromHls(masterUrl, url, videoNameGen = videoNameGen, subtitleList = subtitleList)
return playlistUtils.extractFromHls(
playlistUrl = masterUrl,
referer = "https://${url.toHttpUrl().host}/",
videoNameGen = videoNameGen,
subtitleList = playlistUtils.fixSubtitles(subtitleList),
)
}
private fun getEmbedUrl(url: String): String {
@ -57,7 +61,11 @@ class StreamWishExtractor(private val client: OkHttpClient, private val headers:
.substringAfter("tracks")
.substringAfter("[")
.substringBefore("]")
json.decodeFromString<List<TrackDto>>("[$subtitleStr]")
val fixedSubtitleStr = FIX_TRACKS_REGEX.replace(subtitleStr) { match ->
"\"${match.value}\""
}
json.decodeFromString<List<TrackDto>>("[$fixedSubtitleStr]")
.filter { it.kind.equals("captions", true) }
.map { Track(it.file, it.label ?: "") }
} catch (e: SerializationException) {
@ -67,4 +75,7 @@ class StreamWishExtractor(private val client: OkHttpClient, private val headers:
@Serializable
private data class TrackDto(val file: String, val kind: String, val label: String? = null)
private val M3U8_REGEX = Regex("""https[^"]*m3u8[^"]*""")
private val FIX_TRACKS_REGEX = Regex("""(?<!["])(file|kind|label)(?!["])""")
}

View file

@ -1,7 +1,7 @@
ext {
extName = 'Hikari'
extClass = '.Hikari'
extVersionCode = 16
extVersionCode = 17
}
apply from: "$rootDir/common.gradle"
@ -9,6 +9,7 @@ apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(':lib:chillx-extractor'))
implementation(project(':lib:filemoon-extractor'))
implementation(project(':lib:savefile-extractor'))
implementation(project(':lib:buzzheavier-extractor'))
implementation(project(':lib:streamwish-extractor'))
implementation(project(':lib:vidhide-extractor'))
}

View file

@ -0,0 +1,92 @@
package eu.kanade.tachiyomi.animeextension.all.hikari
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class CatalogResponseDto<T>(
val next: String? = null,
val results: List<T>,
)
@Serializable
data class AnimeDto(
val uid: String,
@SerialName("ani_ename")
val aniEName: String? = null,
@SerialName("ani_name")
val aniName: String,
@SerialName("ani_poster")
val aniPoster: String? = null,
@SerialName("ani_synopsis")
val aniSynopsis: String? = null,
@SerialName("ani_synonyms")
val aniSynonyms: String? = null,
@SerialName("ani_genre")
val aniGenre: String? = null,
@SerialName("ani_studio")
val aniStudio: String? = null,
@SerialName("ani_stats")
val aniStats: Int? = null,
) {
fun toSAnime(preferEnglish: Boolean): SAnime = SAnime.create().apply {
url = uid
title = if (preferEnglish) aniEName?.takeUnless(String::isBlank) ?: aniName else aniName
thumbnail_url = aniPoster
genre = aniGenre?.split(",")?.joinToString(transform = String::trim)
author = aniStudio
description = buildString {
aniSynopsis?.trim()?.let(::append)
append("\n\n")
aniSynonyms?.let { append("Synonyms: $it") }
}.trim()
status = when (aniStats) {
1 -> SAnime.ONGOING
2 -> SAnime.COMPLETED
else -> SAnime.UNKNOWN
}
}
}
@Serializable
data class LatestEpisodeDto(
val uid: Int,
val title: String,
@SerialName("title_en")
val titleEn: String? = null,
val imageUrl: String,
) {
fun toSAnime(preferEnglish: Boolean): SAnime = SAnime.create().apply {
url = uid.toString()
title = if (preferEnglish) titleEn?.takeUnless(String::isBlank) ?: this@LatestEpisodeDto.title else this@LatestEpisodeDto.title
thumbnail_url = imageUrl
}
}
@Serializable
data class EpisodeDto(
@SerialName("ep_id_name")
val epId: String,
@SerialName("ep_name")
val epName: String? = null,
) {
fun toSEpisode(uid: String): SEpisode = SEpisode.create().apply {
url = "$uid-$epId"
name = epName?.let { "Ep. $epId - $it" } ?: "Episode $epId"
episode_number = epId.toFloatOrNull() ?: 1f
}
}
@Serializable
data class EmbedDto(
@SerialName("embed_type")
val embedType: String,
@SerialName("embed_name")
val embedName: String,
@SerialName("embed_frame")
val embedFrame: String,
)

View file

@ -5,7 +5,7 @@ import okhttp3.HttpUrl
import java.util.Calendar
interface UriFilter {
fun addToUri(url: HttpUrl.Builder)
fun addToUri(builder: HttpUrl.Builder)
}
sealed class UriPartFilter(
@ -20,7 +20,10 @@ sealed class UriPartFilter(
),
UriFilter {
override fun addToUri(builder: HttpUrl.Builder) {
builder.addQueryParameter(param, vals[state].second)
val value = vals[state].second
if (value.isNotEmpty()) {
builder.addQueryParameter(param, value)
}
}
}
@ -33,13 +36,15 @@ sealed class UriMultiSelectFilter(
) : AnimeFilter.Group<UriMultiSelectOption>(name, vals.map { UriMultiSelectOption(it.first, it.second) }), UriFilter {
override fun addToUri(builder: HttpUrl.Builder) {
val checked = state.filter { it.state }
if (checked.isNotEmpty()) {
builder.addQueryParameter(param, checked.joinToString(",") { it.value })
}
}
}
class TypeFilter : UriPartFilter(
"Type",
"type",
"ani_type",
arrayOf(
Pair("All", ""),
Pair("TV", "1"),
@ -50,165 +55,53 @@ class TypeFilter : UriPartFilter(
),
)
class CountryFilter : UriPartFilter(
"Country",
"country",
arrayOf(
Pair("All", ""),
Pair("Japanese", "1"),
Pair("Chinese", "2"),
),
)
class StatusFilter : UriPartFilter(
"Status",
"stats",
"ani_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"),
Pair("Ongoing", "1"),
Pair("Completed", "2"),
Pair("Upcoming", "3"),
),
)
class SeasonFilter : UriPartFilter(
"Season",
"season",
"ani_release_season",
arrayOf(
Pair("All", ""),
Pair("Spring", "1"),
Pair("Summer", "2"),
Pair("Fall", "3"),
Pair("Winter", "4"),
Pair("Winter", "1"),
Pair("Spring", "2"),
Pair("Summer", "3"),
Pair("Fall", "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,
class YearFilter : UriPartFilter(
"Release Year",
"ani_release",
YEARS,
) {
companion object {
private val NEXT_YEAR by lazy {
Calendar.getInstance()[Calendar.YEAR] + 1
private val CURRENT_YEAR by lazy {
Calendar.getInstance()[Calendar.YEAR]
}
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"),
private val YEARS = buildList {
add(Pair("Any", ""))
addAll(
(1990..CURRENT_YEAR).map {
Pair(it.toString(), it.toString())
},
)
}.toTypedArray()
}
}
class GenreFilter : UriMultiSelectFilter(
"Genre",
"genres",
"ani_genre",
arrayOf(
Pair("Action", "Action"),
Pair("Adventure", "Adventure"),
@ -233,7 +126,7 @@ class GenreFilter : UriMultiSelectFilter(
Pair("Music", "Music"),
Pair("Mystery", "Mystery"),
Pair("Parody", "Parody"),
Pair("Police", "Police"),
Pair("Policy", "Policy"),
Pair("Psychological", "Psychological"),
Pair("Romance", "Romance"),
Pair("Samurai", "Samurai"),
@ -253,3 +146,12 @@ class GenreFilter : UriMultiSelectFilter(
Pair("Vampire", "Vampire"),
),
)
class LanguageFilter : UriPartFilter(
"Language",
"ani_genre",
arrayOf(
Pair("Any", ""),
Pair("Portuguese", "Portuguese"),
),
)

View file

@ -1,42 +1,43 @@
package eu.kanade.tachiyomi.animeextension.all.hikari
import android.app.Application
import android.util.Log
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
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.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.lib.buzzheavierextractor.BuzzheavierExtractor
import eu.kanade.tachiyomi.lib.chillxextractor.ChillxExtractor
import eu.kanade.tachiyomi.lib.filemoonextractor.FilemoonExtractor
import eu.kanade.tachiyomi.lib.savefileextractor.SavefileExtractor
import eu.kanade.tachiyomi.lib.streamwishextractor.StreamWishExtractor
import eu.kanade.tachiyomi.lib.vidhideextractor.VidHideExtractor
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
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class Hikari : ParsedAnimeHttpSource(), ConfigurableAnimeSource {
class Hikari : AnimeHttpSource(), ConfigurableAnimeSource {
override val name = "Hikari"
override val baseUrl = "https://watch.hikaritv.xyz"
private val proxyUrl = "https://hikari.gg/hiki-proxy/extract/"
private val apiUrl = "https://api.hikari.gg/api"
override val baseUrl = "https://hikari.gg"
override val lang = "all"
override val versionId = 2
override val supportsLatest = true
override fun headersBuilder() = super.headersBuilder().apply {
@ -50,75 +51,40 @@ class Hikari : ParsedAnimeHttpSource(), ConfigurableAnimeSource {
// ============================== 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 popularAnimeRequest(page: Int) = searchAnimeRequest(page, "", AnimeFilterList())
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
override fun popularAnimeParse(response: Response) = searchAnimeParse(response)
// =============================== 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()
val url = "$apiUrl/episode/new/".toHttpUrl().newBuilder().apply {
addQueryParameter("limit", "100")
addQueryParameter("language", "EN")
}.build()
return GET(url, headers)
}
override fun latestUpdatesParse(response: Response): AnimesPage =
popularAnimeParse(response)
override fun latestUpdatesParse(response: Response): AnimesPage {
val data = response.parseAs<CatalogResponseDto<LatestEpisodeDto>>()
val preferEnglish = preferences.getTitleLang
override fun latestUpdatesSelector(): String =
throw UnsupportedOperationException()
override fun latestUpdatesFromElement(element: Element): SAnime =
throw UnsupportedOperationException()
override fun latestUpdatesNextPageSelector(): String =
throw UnsupportedOperationException()
val animeList = data.results.distinctBy { it.uid }.map { it.toSAnime(preferEnglish) }
return AnimesPage(animeList, false)
}
// =============================== 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)
val url = "$apiUrl/anime/".toHttpUrl().newBuilder().apply {
addQueryParameter("sort", "created_at")
addQueryParameter("order", "asc")
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")
addQueryParameter("search", query)
}
}.build()
@ -126,280 +92,148 @@ class Hikari : ParsedAnimeHttpSource(), ConfigurableAnimeSource {
}
override fun searchAnimeParse(response: Response): AnimesPage {
return if (response.request.url.encodedPath.startsWith("/search")) {
super.searchAnimeParse(response)
} else {
popularAnimeParse(response)
val data = response.parseAs<CatalogResponseDto<AnimeDto>>()
val preferEnglish = preferences.getTitleLang
val animeList = data.results.map { it.toSAnime(preferEnglish) }
val hasNextPage = data.next != null
return AnimesPage(animeList, hasNextPage)
}
}
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(),
YearFilter(),
GenreFilter(),
LanguageFilter(),
)
// =========================== 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()
}
override fun getAnimeUrl(anime: SAnime): String {
return "$baseUrl/info/${anime.url}"
}
private fun Element?.parseStatus(): Int = when (this?.text()?.lowercase()) {
"currently airing" -> SAnime.ONGOING
"finished" -> SAnime.COMPLETED
else -> SAnime.UNKNOWN
override fun animeDetailsRequest(anime: SAnime): Request {
return GET("$apiUrl/anime/uid/${anime.url}/", headers)
}
override fun animeDetailsParse(response: Response): SAnime {
return response.parseAs<AnimeDto>().toSAnime(preferences.getTitleLang)
}
// ============================== 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)
return GET("$apiUrl/episode/uid/${anime.url}/", headers)
}
override fun episodeListParse(response: Response): List<SEpisode> {
return response.parseAs<HtmlResponseDto>().toHtml(baseUrl)
.select(episodeListSelector())
.map(::episodeFromElement)
.reversed()
}
val guid = response.request.url.pathSegments[3]
override fun episodeListSelector() = "a[class~=ep-item]"
override fun episodeFromElement(element: Element): SEpisode {
val epText = element.selectFirst(".ssli-order")?.text()?.trim()
?: element.attr("data-number").trim()
val ep = epText.toFloatOrNull() ?: 0F
val nameText = element.selectFirst(".ep-name")?.text()?.trim()
?: element.attr("title").replace("Episode-", "Ep. ") ?: ""
return SEpisode.create().apply {
setUrlWithoutDomain(element.attr("abs:href"))
episode_number = ep
name = "Ep. $ep - $nameText"
}
return response.parseAs<List<EpisodeDto>>().map { it.toSEpisode(guid) }.reversed()
}
// ============================ Video Links =============================
private val filemoonExtractor by lazy { FilemoonExtractor(client) }
private val vidHideExtractor by lazy { VidHideExtractor(client, headers) }
private val filemoonExtractor by lazy { FilemoonExtractor(client, preferences) }
private val savefileExtractor by lazy { SavefileExtractor(client, preferences) }
private val buzzheavierExtractor by lazy { BuzzheavierExtractor(client, headers) }
private val chillxExtractor by lazy { ChillxExtractor(client, headers) }
private val streamwishExtractor by lazy { StreamWishExtractor(client, headers) }
private val embedRegex = Regex("""getEmbed\(\s*(\d+)\s*,\s*(\d+)\s*,\s*'(\d+)'""")
private fun getEmbedTypeName(type: String): String {
return when (type) {
"2" -> "[SUB] "
"3" -> "[DUB] "
"4" -> "[MULTI AUDIO] "
"8" -> "[HARD-SUB] "
else -> ""
}
}
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)
val (guid, epId) = episode.url.split("-")
return GET("$apiUrl/embed/$guid/$epId/", headers)
}
override fun videoListParse(response: Response): List<Video> {
val html = response.parseAs<HtmlResponseDto>().toHtml(baseUrl)
Log.d("Hikari", html.toString())
val data = response.parseAs<List<EmbedDto>>()
val headers = headersBuilder()
.set("Referer", response.request.url.toString())
.build()
return data.parallelCatchingFlatMapBlocking { embed ->
val prefix = getEmbedTypeName(embed.embedType) + embed.embedName
val embedName = embed.embedName.lowercase()
val subEmbedUrls = html.select(".servers-sub div.item.server-item").flatMap { item ->
val name = item.text().trim() + " (Sub)"
val onClick = item.selectFirst("a")?.attr("onclick")
if (onClick == null) {
Log.e("Hikari", "onClick attribute is null for item: $item")
return@flatMap emptyList<Pair<String, String>>()
}
val match = embedRegex.find(onClick)?.groupValues
if (match == null) {
Log.e("Hikari", "No match found for onClick: $onClick")
return@flatMap emptyList<Pair<String, String>>()
}
val url = "$baseUrl/ajax/embed/${match[1]}/${match[2]}/${match[3]}"
val iframeList = client.newCall(
GET(url, headers),
).execute().parseAs<List<String>>()
iframeList.map {
val iframeSrc = Jsoup.parseBodyFragment(it).selectFirst("iframe")?.attr("src")
if (iframeSrc == null) {
Log.e("Hikari", "iframe src is null for URL: $url")
return@map Pair("", "")
}
Pair(iframeSrc, name)
}.filter { it.first.isNotEmpty() }
}
val dubEmbedUrls = html.select(".servers-dub div.item.server-item").flatMap { item ->
val name = item.text().trim() + " (Dub)"
val onClick = item.selectFirst("a")?.attr("onclick")
if (onClick == null) {
Log.e("Hikari", "onClick attribute is null for item: $item")
return@flatMap emptyList<Pair<String, String>>()
}
val match = embedRegex.find(onClick)?.groupValues
if (match == null) {
Log.e("Hikari", "No match found for onClick: $onClick")
return@flatMap emptyList<Pair<String, String>>()
}
val url = "$baseUrl/ajax/embed/${match[1]}/${match[2]}/${match[3]}"
val iframeList = client.newCall(
GET(url, headers),
).execute().parseAs<List<String>>()
iframeList.map {
val iframeSrc = Jsoup.parseBodyFragment(it).selectFirst("iframe")?.attr("src")
if (iframeSrc == null) {
Log.e("Hikari", "iframe src is null for URL: $url")
return@map Pair("", "")
}
Pair(iframeSrc, name)
}.filter { it.first.isNotEmpty() }
}
val sdEmbedUrls = html.select(".servers-sub.\\&.dub div.item.server-item").flatMap { item ->
val name = item.text().trim() + " (Sub + Dub)"
val onClick = item.selectFirst("a")?.attr("onclick")
if (onClick == null) {
Log.e("Hikari", "onClick attribute is null for item: $item")
return@flatMap emptyList<Pair<String, String>>()
}
val match = embedRegex.find(onClick)?.groupValues
if (match == null) {
Log.e("Hikari", "No match found for onClick: $onClick")
return@flatMap emptyList<Pair<String, String>>()
}
val url = "$baseUrl/ajax/embed/${match[1]}/${match[2]}/${match[3]}"
val iframeList = client.newCall(
GET(url, headers),
).execute().parseAs<List<String>>()
iframeList.map {
val iframeSrc = Jsoup.parseBodyFragment(it).selectFirst("iframe")?.attr("src")
if (iframeSrc == null) {
Log.e("Hikari", "iframe src is null for URL: $url")
return@map Pair("", "")
}
Pair(iframeSrc, name)
}.filter { it.first.isNotEmpty() }
}
return sdEmbedUrls.parallelCatchingFlatMapBlocking {
getVideosFromEmbed(it.first, it.second)
}.ifEmpty {
(subEmbedUrls + dubEmbedUrls).parallelCatchingFlatMapBlocking {
getVideosFromEmbed(it.first, it.second)
when (embedName) {
"streamwish" -> streamwishExtractor.videosFromUrl(embed.embedFrame, videoNameGen = { "$prefix - $it" })
"filemoon" -> filemoonExtractor.videosFromUrl(embed.embedFrame, "$prefix - ")
"sv" -> savefileExtractor.videosFromUrl(embed.embedFrame, "$prefix - ")
"playerx" -> chillxExtractor.videoFromUrl(embed.embedFrame, "$prefix - ")
"hiki" -> buzzheavierExtractor.videosFromUrl(embed.embedFrame, "$prefix - ", proxyUrl)
else -> emptyList()
}
}
}
private fun getVideosFromEmbed(embedUrl: String, name: String): List<Video> = when {
name.contains("vidhide", true) -> vidHideExtractor.videosFromUrl(embedUrl, videoNameGen = { s -> "$name - $s" })
embedUrl.contains("filemoon", true) -> filemoonExtractor.videosFromUrl(embedUrl, prefix = "$name - ", headers = headers)
name.contains("streamwish", true) -> streamwishExtractor.videosFromUrl(embedUrl, prefix = "$name - ")
else -> chillxExtractor.videoFromUrl(embedUrl, referer = baseUrl, prefix = "$name - ")
}
override fun videoListSelector() = ".server-item:has(a[onclick~=getEmbed])"
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
val type = preferences.getString(PREF_TYPE_KEY, PREF_TYPE_DEFAULT)!!
val hoster = preferences.getString(PREF_HOSTER_KEY, PREF_HOSTER_DEFAULT)!!
return sortedWith(
compareBy(
{ it.quality.startsWith(type) },
{ it.quality.contains(quality) },
{ QUALITY_REGEX.find(it.quality)?.groupValues?.get(1)?.toIntOrNull() ?: 0 },
{ it.quality.contains(hoster, true) },
),
).reversed()
}
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,
)
}
companion object {
private val QUALITY_REGEX = Regex("""(\d+)p""")
private const val PREF_ENGLISH_TITLE_KEY = "preferred_title_lang"
private const val PREF_ENGLISH_TITLE_DEFAULT = true
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_DEFAULT = "1080"
private val PREF_QUALITY_VALUES = arrayOf("1080", "720", "480", "360")
private val PREF_QUALITY_ENTRIES = PREF_QUALITY_VALUES.map {
"${it}p"
}.toTypedArray()
private val TYPE_LIST = arrayOf("[SUB] ", "[DUB] ", "[MULTI AUDIO] ", "[HARD-SUB] ")
private const val PREF_TYPE_KEY = "pref_type"
private const val PREF_TYPE_DEFAULT = ""
private val PREF_TYPE_VALUES = arrayOf("") + TYPE_LIST
private val PREF_TYPE_ENTRIES = arrayOf("Any") + TYPE_LIST
private val HOSTER_LIST = arrayOf("Streamwish", "Filemoon", "SV", "PlayerX", "Hiki")
private const val PREF_HOSTER_KEY = "pref_hoster"
private const val PREF_HOSTER_DEFAULT = ""
private val PREF_HOSTER_VALUES = arrayOf("") + HOSTER_LIST
private val PREF_HOSTER_ENTRIES = arrayOf("Any") + HOSTER_LIST
}
// ============================== Settings ==============================
private val SharedPreferences.getTitleLang
get() = getBoolean(PREF_ENGLISH_TITLE_KEY, PREF_ENGLISH_TITLE_DEFAULT)
override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply {
key = PREF_ENGLISH_TITLE_KEY
title = "Prefer english titles"
setDefaultValue(PREF_ENGLISH_TITLE_DEFAULT)
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = "Preferred quality"
@ -407,13 +241,27 @@ class Hikari : ParsedAnimeHttpSource(), ConfigurableAnimeSource {
entryValues = PREF_QUALITY_VALUES
setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_TYPE_KEY
title = "Preferred type"
entries = PREF_TYPE_ENTRIES
entryValues = PREF_TYPE_VALUES
setDefaultValue(PREF_TYPE_DEFAULT)
summary = "%s"
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_HOSTER_KEY
title = "Preferred hoster"
entries = PREF_HOSTER_ENTRIES
entryValues = PREF_HOSTER_VALUES
setDefaultValue(PREF_HOSTER_DEFAULT)
summary = "%s"
}.also(screen::addPreference)
FilemoonExtractor.addSubtitlePref(screen)
SavefileExtractor.addSubtitlePref(screen)
}
}