AnimeVerse [EN]: Fix Videos, Add Filters/Anime Details & Add Servers (#379)

* Improve some stuff

* Bump to version 3

* Cache catalog

* Cache catalog for all requests

* Make some small improvements

* Back to using proxy/direct MP4

* Fix and add servers

* Rectify some stuff
This commit is contained in:
Alpha-782
2026-06-12 16:00:29 +03:00
committed by GitHub
parent 20566c193a
commit 013e056278
2 changed files with 434 additions and 79 deletions

View File

@@ -1,8 +1,12 @@
ext {
extName = 'AnimeVerse'
extClass = '.AnimeVerse'
extVersionCode = 2
extVersionCode = 3
isNsfw = true
}
apply plugin: "kei.plugins.extension.legacy"
dependencies {
implementation(project(':lib:playlistutils'))
}

View File

@@ -2,12 +2,16 @@ package eu.kanade.tachiyomi.animeextension.en.animeverse
import android.annotation.SuppressLint
import android.util.Base64
import androidx.preference.MultiSelectListPreference
import androidx.preference.PreferenceScreen
import aniyomi.lib.playlistutils.PlaylistUtils
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.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.network.GET
@@ -20,11 +24,12 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
@@ -34,6 +39,7 @@ import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.net.URLDecoder
import java.net.URLEncoder
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
@@ -54,6 +60,7 @@ class AnimeVerse :
}
private val preferences by getPreferencesLazy()
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
private val fingerprint: String by lazy {
preferences.getString("fp_json", null) ?: run {
@@ -164,14 +171,37 @@ class AnimeVerse :
.build()
}
// ========================= Catalog Cache ==========================
@Volatile
private var catalogCache: List<JsonElement>? = null
private var catalogCacheTime: Long = 0L
private val catalogCacheLock = Any()
private val catalogCacheTtl = 5 * 60 * 1000L // 5 minutes
private fun getCatalog(existingResponse: Response? = null): List<JsonElement> = synchronized(catalogCacheLock) {
val now = System.currentTimeMillis()
catalogCache?.takeIf { now - catalogCacheTime < catalogCacheTtl }?.let { return it }
val arr = if (existingResponse != null) {
extractArray(existingResponse.bodyString().parseAs<JsonElement>(json))
} else {
val resp = client.newCall(GET("$baseUrl/api/v1/catalog")).execute()
val list = extractArray(resp.bodyString().parseAs<JsonElement>(json))
resp.close()
list
}
catalogCache = arr
catalogCacheTime = now
arr
}
// =========================== Helpers ==============================
private fun SAnime.slug(): String = url.substringAfter("/series/")
private fun decodeStreamBase64(path: String): String = runCatching {
String(base64UrlDecode(path.substringAfter("/v/").substringBefore(".")))
}.getOrDefault("")
private fun resolveImage(path: String?): String? {
if (path.isNullOrEmpty()) return null
if (path.startsWith("http")) return path
@@ -201,7 +231,13 @@ class AnimeVerse :
private fun JsonObject.double(key: String): Double = this[key]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull() ?: 0.0
private fun JsonObject.stringArray(key: String): List<String> = this[key]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
private fun JsonObject.stringArray(key: String): List<String> = (this[key] as? JsonArray)?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
private fun cleanQuality(q: String): String {
Regex("""(\d{3,4})p""").find(q)?.let { return it.value }
Regex("""\dx(\d+)""").find(q)?.groupValues?.get(1)?.let { return "${it}p" }
return q.substringBefore(" - ").substringBefore("(").trim()
}
private fun jsonToAnime(el: JsonElement): SAnime {
val o = el.jsonObject
@@ -227,26 +263,25 @@ class AnimeVerse :
title = o.string("seriesTitle") ?: "Unknown"
url = "/series/${o.string("seriesSlug")}"
thumbnail_url = resolveImage(o.string("thumb"))
genre = o.string("language")?.uppercase()
genre = o.string("language")?.uppercase() ?: o.string("releaseTime")
status = SAnime.UNKNOWN
}
}
private fun extractArray(root: JsonElement): List<JsonElement> = when (root) {
is JsonArray -> root
is JsonObject -> root.values.filterIsInstance<JsonArray>().firstOrNull()
?: emptyList()
is JsonObject -> (root["items"] ?: root["data"] ?: root.values.firstOrNull { it is JsonArray })
as? JsonArray ?: emptyList()
else -> emptyList()
}
// ============================== Popular ==============================
override fun popularAnimeRequest(page: Int): Request = GET("$baseUrl/api/v1/trending?period=today&page=$page")
override fun popularAnimeRequest(page: Int): Request = GET("$baseUrl/api/v1/trending?period=week&page=$page")
override fun popularAnimeParse(response: Response): AnimesPage {
val root = response.bodyString().parseAs<JsonElement>(json)
val arr = extractArray(root)
// Only paginate if the API explicitly says there's more
val hasNext = (root as? JsonObject)
?.get("hasNext")?.jsonPrimitive?.booleanOrNull == true
return AnimesPage(arr.map(::jsonToAnime), hasNext)
@@ -257,31 +292,74 @@ class AnimeVerse :
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/api/v1/recent")
override fun latestUpdatesParse(response: Response): AnimesPage {
val root = response.bodyString().parseAs<JsonElement>(json).jsonObject
val items = root["items"]?.jsonArray ?: return AnimesPage(emptyList(), false)
val root = response.bodyString().parseAs<JsonElement>(json)
val items = extractArray(root)
return AnimesPage(items.map(::recentToAnime), false)
}
// ============================== Search ==============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request = GET("$baseUrl/api/v1/catalog?q=${URLEncoder.encode(query, "UTF-8")}")
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val dayFilter = filters.filterIsInstance<ScheduleDayFilter>().firstOrNull()
val day = dayFilter?.getValue()
return if (!day.isNullOrEmpty()) {
val url = if (query.isNotBlank()) {
"$baseUrl/api/v1/schedule?day=$day&q=${URLEncoder.encode(query, "UTF-8")}"
} else {
"$baseUrl/api/v1/schedule?day=$day"
}
GET(url)
} else {
val fragment = if (query.isNotBlank()) URLEncoder.encode(query, "UTF-8") else ""
GET("$baseUrl/api/v1/catalog#$fragment")
}
}
override fun searchAnimeParse(response: Response): AnimesPage {
val root = response.bodyString().parseAs<JsonElement>(json)
val arr = extractArray(root)
val q = response.request.url.queryParameter("q")?.lowercase().orEmpty()
val url = response.request.url.toString()
val filtered = if (q.isBlank()) {
arr
} else {
arr.filter { el ->
val o = el.jsonObject
o.string("searchTitle")?.lowercase()?.contains(q) == true ||
o.string("title")?.lowercase()?.contains(q) == true
return if (url.contains("/api/v1/schedule")) {
val root = response.bodyString().parseAs<JsonElement>(json)
val items = extractArray(root)
val q = response.request.url.queryParameter("q")?.lowercase().orEmpty()
val filtered = if (q.isBlank()) {
items
} else {
val catalogArr = getCatalog()
val matchingSlugs = catalogArr.filter { el ->
val o = el.jsonObject
o.string("searchTitle")?.lowercase()?.contains(q) == true ||
o.string("title")?.lowercase()?.contains(q) == true ||
o.string("alternativeTitle")?.lowercase()?.contains(q) == true
}.mapNotNull { it.jsonObject.string("slug") }.toSet()
items.filter { el ->
val o = el.jsonObject
o.string("seriesTitle")?.lowercase()?.contains(q) == true ||
o.string("seriesSlug") in matchingSlugs
}
}
}
AnimesPage(filtered.map(::recentToAnime), false)
} else {
val catalogArr = getCatalog(response)
val q = URLDecoder.decode(response.request.url.fragment ?: "", "UTF-8").lowercase()
return AnimesPage(filtered.map(::jsonToAnime), false)
val filtered = if (q.isBlank()) {
catalogArr
} else {
catalogArr.filter { el ->
val o = el.jsonObject
o.string("searchTitle")?.lowercase()?.contains(q) == true ||
o.string("title")?.lowercase()?.contains(q) == true ||
o.string("alternativeTitle")?.lowercase()?.contains(q) == true
}
}
AnimesPage(filtered.map(::jsonToAnime), false)
}
}
// ============================== Anime Details ==============================
@@ -293,18 +371,15 @@ class AnimeVerse :
val o = client.newCall(GET("$baseUrl/api/v1/anime/$slug"))
.execute().bodyString()
.parseAs<JsonElement>(json)
.jsonObject
.parseAs<JsonElement>(json) as? JsonObject
?: throw Exception("Invalid anime data")
val arr = client.newCall(GET("$baseUrl/api/v1/catalog"))
.execute().bodyString()
.let { extractArray(it.parseAs<JsonElement>(json)) }
val cat = arr.firstOrNull { it.jsonObject.string("slug") == slug }?.jsonObject
val cat = getCatalog().firstOrNull { it.jsonObject.string("slug") == slug }?.jsonObject
val rating = o.double("rating")
val synopsis = o.string("synopsis").orEmpty()
val ratingLine = formatRating(rating)
val epCount = o["episodes"]?.jsonArray?.size ?: 0
val epCount = (o["episodes"] as? JsonArray)?.size ?: 0
val mainTitle = o.string("title") ?: "Unknown"
val altTitle = cat?.string("alternativeTitle")?.takeIf { it.isNotEmpty() && it != mainTitle }
@@ -314,12 +389,14 @@ class AnimeVerse :
val genres = cat?.stringArray("genres")?.takeIf { it.isNotEmpty() }?.joinToString(", ")
val studios = cat?.stringArray("studios")?.takeIf { it.isNotEmpty() }?.joinToString(", ")
val premiered = cat?.string("premiered")
val year = cat?.int("year")?.takeIf { it > 0 }
val animeType = cat?.string("type") ?: o.string("type")
val ratingLabel = o.string("ratingLabel")
val malId = o.int("malId")
val malLink = if (malId > 0) "[**MAL**](https://myanimelist.net/anime/$malId)" else null
val header = listOfNotNull(ratingLine)
// If we used the alt title as the main title, show the original in the footer
val footerAltLine = if (displayTitle == altTitle) {
"**Original:** $mainTitle"
} else {
@@ -330,8 +407,10 @@ class AnimeVerse :
footerAltLine,
animeType?.let { "**Type:** $it" },
premiered?.let { "**Premiered:** $it" },
year?.let { "**Year:** $it" },
ratingLabel?.let { "**Rating:** $it" },
if (epCount > 0) "**Episodes:** $epCount" else null,
malLink,
)
val description = listOf(header.joinToString("\n"), synopsis, footer.joinToString("\n"))
@@ -354,21 +433,39 @@ class AnimeVerse :
override fun episodeListRequest(anime: SAnime): Request = GET("$baseUrl/api/v1/anime/${anime.slug()}")
override fun episodeListParse(response: Response): List<SEpisode> {
val o = response.bodyString().parseAs<JsonElement>(json).jsonObject
val episodes = o["episodes"]?.jsonArray ?: return emptyList()
val o = response.bodyString().parseAs<JsonElement>(json) as? JsonObject ?: return emptyList()
val episodes = o["episodes"] as? JsonArray ?: return emptyList()
val slug = o.string("slug").orEmpty()
val malId = o.int("malId")
return episodes
.groupBy { it.jsonObject.int("number") }
.map { (num, _) ->
.mapNotNull { it as? JsonObject }
.groupBy { it.int("number") }
.map { (num, epList) ->
val kinds = epList.mapNotNull { it.string("kind")?.uppercase() }.distinct().sorted().joinToString(", ")
val payload = buildJsonObject {
put("slug", slug)
put("ep", num)
put("malId", malId)
put(
"items",
buildJsonArray {
epList.forEach { epObj ->
add(
buildJsonObject {
put("id", epObj.string("id").orEmpty())
put("kind", epObj.string("kind") ?: "sub")
},
)
}
},
)
}.toString()
SEpisode.create().apply {
episode_number = num.toFloat()
name = "Episode $num"
url = base64UrlEncode(payload.toByteArray())
scanlator = kinds.ifEmpty { null }
}
}
.sortedByDescending { it.episode_number }
@@ -380,50 +477,297 @@ class AnimeVerse :
override fun videoListParse(response: Response): List<Video> {
val encoded = response.request.url.queryParameter("_d") ?: return emptyList()
val payload = String(base64UrlDecode(encoded)).parseAs<JsonElement>(json).jsonObject
val payload = String(base64UrlDecode(encoded)).parseAs<JsonElement>(json) as? JsonObject ?: return emptyList()
val slug = payload.string("slug").orEmpty()
val epNum = payload.int("ep")
val preferDirect = preferences.getBoolean(PREF_DIRECT_MP4, PREF_DIRECT_MP4_DEFAULT)
val malId = payload.int("malId")
val items = (payload["items"] as? JsonArray) ?: return emptyList()
val seenUrls = mutableSetOf<String>()
val videos = mutableListOf<Video>()
val hosterExclusion = preferences.getStringSet(PREF_HOSTER_EXCLUDE_KEY, PREF_HOSTER_EXCLUDE_DEFAULT)!!
val freshData = client.newCall(GET("$baseUrl/api/v1/anime/$slug"))
.execute().bodyString()
.parseAs<JsonElement>(json)
.jsonObject
for (item in items) {
val o = item.jsonObject
val id = o.string("id").orEmpty()
val kind = (o.string("kind") ?: "sub").uppercase()
val kindPath = kind.lowercase()
val allEpisodes = freshData["episodes"]?.jsonArray ?: return emptyList()
val streams = allEpisodes.map { it.jsonObject }.filter { it.int("number") == epNum }
if (streams.isEmpty()) return emptyList()
for (serverName in SERVERS) {
val serverLabel = SERVERS_DISPLAY[SERVERS.indexOf(serverName)]
val cookie = synchronized(lock) { sessionCookie }
val referer = "$baseUrl/series/$slug/$epNum"
// Skip excluded hosts
if (hosterExclusion.contains(serverLabel)) continue
return streams.map { ep ->
val kind = (ep.string("kind") ?: "sub").uppercase()
val streamPath = ep.string("stream") ?: return@map emptyList()
val directUrl = decodeStreamBase64(streamPath)
try {
// Handle Chiki / MegaPlay directly using malId
if (serverName == "chiki" && malId > 0) {
val megaplayUrl = "https://megaplay.buzz/stream/mal/$malId/$epNum/$kindPath"
try {
val megaplayId = extractMegaplayId(megaplayUrl)
val directVideo = if (directUrl.isNotEmpty()) {
Video(directUrl, "$kind - Direct", directUrl)
} else {
null
if (!megaplayId.isNullOrEmpty()) {
val megaplayVideos = fetchMegaplayVideos(megaplayId, megaplayUrl, kind, serverLabel)
megaplayVideos.filter { seenUrls.add(it.url) }.also { videos.addAll(it) }
}
} catch (_: Exception) {}
continue
}
// Handle AnimeVerse and Choi via API
val apiUrl = buildString {
append("$baseUrl/api/v1/anime/$slug/stream/$epNum")
append("?server=$serverName")
if (id.isNotEmpty()) append("&id=$id")
}
val streamResp = client.newCall(GET(apiUrl)).execute()
val streamObjects = when (val streamBody = streamResp.bodyString().parseAs<JsonElement>(json)) {
is JsonArray -> streamBody.mapNotNull { it as? JsonObject }
is JsonObject -> listOf(streamBody)
else -> emptyList()
}
for (streamObj in streamObjects) {
val streamPath = streamObj.string("stream") ?: continue
// Handle Choi / Vidnest
if (streamPath.contains("vidnest.fun")) {
val vidnestVideos = fetchVidnestVideos(streamPath, kind, serverLabel)
vidnestVideos.filter { seenUrls.add(it.url) }.also { videos.addAll(it) }
} else if (streamPath.startsWith("/hianime/")) {
val vidnestUrl = "https://new.vidnest.fun$streamPath"
val vidnestVideos = fetchVidnestVideos(vidnestUrl, kind, serverLabel)
vidnestVideos.filter { seenUrls.add(it.url) }.also { videos.addAll(it) }
} else if (streamPath.matches(Regex("^\\d+$")) && serverName.equals("choi", ignoreCase = true)) {
val vidnestUrl = "https://new.vidnest.fun/hianime/anime/$streamPath/$epNum/$kindPath"
val vidnestVideos = fetchVidnestVideos(vidnestUrl, kind, serverLabel)
vidnestVideos.filter { seenUrls.add(it.url) }.also { videos.addAll(it) }
}
// Fallback for MegaPlay if AnimeVerse returns a link directly
else if (isMegaplayStream(streamPath)) {
val fullUrl = if (streamPath.startsWith("http")) {
streamPath
} else {
val host = streamObj.string("host").orEmpty()
if (host.isNotEmpty()) "https://$host$streamPath" else "https://megaplay.buzz$streamPath"
}
var megaplayId = when {
fullUrl.contains("id=") -> fullUrl.substringAfter("id=").substringBefore("&").substringBefore("\"").trim()
streamPath.matches(Regex("^\\d+$")) -> streamPath
else -> ""
}
if (megaplayId.isEmpty()) {
megaplayId = extractMegaplayId(fullUrl) ?: ""
}
if (megaplayId.isNotEmpty()) {
val megaplayVideos = fetchMegaplayVideos(megaplayId, fullUrl, kind, serverLabel)
megaplayVideos.filter { seenUrls.add(it.url) }.also { videos.addAll(it) }
}
}
// Standard AnimeVerse Direct streams (Removed Proxy due to 404)
else {
val videoHeaders = Headers.Builder()
.add("Referer", "$baseUrl/")
.add("Origin", baseUrl)
.build()
val directUrl = when {
streamPath.startsWith("/r/") -> {
runCatching { String(base64UrlDecode(streamPath.substringAfter("/r/").substringBefore("."))) }.getOrDefault("")
}
streamPath.startsWith("http") -> streamPath
else -> ""
}
if (directUrl.startsWith("http")) {
val video = Video(directUrl, "$kind - $serverLabel", directUrl, videoHeaders)
if (seenUrls.add(video.url)) videos.add(video)
}
}
}
} catch (_: Exception) {
// Server might be offline or unavailable, skip it
}
}
}
val proxiedVideo = Video(
"$baseUrl$streamPath",
"$kind - Proxied",
"$baseUrl$streamPath",
Headers.Builder()
.add("Referer", referer)
.add("Cookie", "av_session=$cookie")
.build(),
return videos
}
// ========================= MegaPlay / VidWish ==========================
private fun extractBaseUrl(url: String): String {
val schemeEnd = url.indexOf("://")
if (schemeEnd < 0) return url
val pathStart = url.indexOf("/", schemeEnd + 3)
return if (pathStart > 0) url.substring(0, pathStart) else url
}
private fun isMegaplayStream(path: String): Boolean = path.contains("/stream/mal/") ||
path.contains("/stream/ani/") ||
path.contains("/stream/s-") ||
path.contains("megaplay") ||
path.contains("vidwish")
private fun extractMegaplayId(pageUrl: String): String? {
val pageHeaders = Headers.Builder()
.add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.add("Referer", "$baseUrl/")
.add("Sec-Fetch-Dest", "iframe")
.add("Sec-Fetch-Mode", "navigate")
.add("Sec-Fetch-Site", "cross-site")
.build()
val pageResp = client.newCall(GET(pageUrl, pageHeaders)).execute()
val pageBody = pageResp.bodyString()
return Regex("""data-id\s*=\s*["']([^"']+)["']""").find(pageBody)?.groupValues?.get(1)
}
private fun extractM3u8FromSources(sources: JsonElement): String? = when (sources) {
is JsonObject -> sources["file"]?.jsonPrimitive?.contentOrNull
is JsonArray -> sources.firstOrNull()?.let {
when (it) {
is JsonObject -> it["file"]?.jsonPrimitive?.contentOrNull
is JsonPrimitive -> it.contentOrNull
else -> null
}
}
is JsonPrimitive -> sources.contentOrNull
else -> null
}
private fun fetchMegaplayVideos(id: String, referer: String, kind: String, serverName: String): List<Video> {
val videos = mutableListOf<Video>()
try {
val base = extractBaseUrl(referer)
val sourcesUrl = "$base/stream/getSources?id=$id&id=$id"
val headers = Headers.Builder()
.add("Accept", "application/json, text/javascript, */*; q=0.01")
.add("X-Requested-With", "XMLHttpRequest")
.add("Referer", referer)
.add("Origin", base)
.add("Sec-Fetch-Dest", "empty")
.add("Sec-Fetch-Mode", "cors")
.add("Sec-Fetch-Site", "same-origin")
.build()
val resp = client.newCall(GET(sourcesUrl, headers)).execute()
if (!resp.isSuccessful) return videos
val obj = resp.bodyString().parseAs<JsonElement>(json) as? JsonObject ?: return videos
val masterUrl = extractM3u8FromSources(obj["sources"] ?: return videos)
?.takeIf { it.startsWith("http") }
?: return videos
val subtitleTracks = (obj["tracks"] as? JsonArray)
?.mapNotNull { trackEl ->
val trackObj = trackEl as? JsonObject ?: return@mapNotNull null
val file = trackObj.string("file") ?: return@mapNotNull null
val label = trackObj.string("label") ?: ""
val trackKind = trackObj.string("kind") ?: ""
if (trackKind.equals("captions", true)) Track(file, label) else null
} ?: emptyList()
videos.addAll(
playlistUtils.extractFromHls(
masterUrl,
videoNameGen = { q -> "$kind - $serverName - ${cleanQuality(q)}" },
subtitleList = subtitleTracks,
referer = "$base/",
),
)
} catch (_: Exception) {
// Network error, skip this source
}
return videos
}
if (preferDirect) {
listOfNotNull(directVideo, proxiedVideo)
} else {
listOfNotNull(proxiedVideo, directVideo)
// ========================= Vidnest Extractor ==========================
private val vidnestCustomAlphabet = "RB0fpH8ZEyVLkv7c2i6MAJ5u3IKFDxlS1NTsnGaqmXYdUrtzjwObCgQP94hoeW+/"
private val standardAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
private fun decryptVidNestData(encryptedData: String): String {
val translated = encryptedData.map { char ->
val index = vidnestCustomAlphabet.indexOf(char)
if (index >= 0) standardAlphabet[index] else char
}.joinToString("")
return String(Base64.decode(translated, Base64.NO_WRAP), Charsets.UTF_8)
}
private fun fetchVidnestVideos(url: String, kind: String, serverName: String): List<Video> {
val videos = mutableListOf<Video>()
try {
val headers = Headers.Builder()
.add("Referer", "https://vidnest.fun/")
.add("Origin", "https://vidnest.fun")
.build()
val resp = client.newCall(GET(url, headers)).execute()
val body = resp.bodyString()
try {
val jsonElement = body.parseAs<JsonElement>(json)
val sourcesArr = if (jsonElement is JsonObject) {
jsonElement["source"] as? JsonArray ?: jsonElement["sources"] as? JsonArray
} else {
null
}
if (sourcesArr != null) {
for (source in sourcesArr) {
val srcObj = source as? JsonObject ?: continue
val file = srcObj.string("file") ?: srcObj.string("url") ?: continue
val quality = srcObj.string("quality") ?: srcObj.string("label") ?: ""
val type = srcObj.string("type") ?: ""
val label = buildString {
append(kind)
append(" - ")
append(serverName)
if (quality.isNotBlank()) {
append(" - ")
append(quality)
}
if (type.equals("hls", ignoreCase = true)) {
append(" - HLS")
}
}
videos.add(Video(file, label, file, headers))
}
}
} catch (_: Exception) {
if (body.contains("#EXTM3U") || url.contains(".m3u8")) {
videos.add(Video(url, "$kind - $serverName - HLS", url, headers))
} else if (url.contains(".mp4")) {
videos.add(Video(url, "$kind - $serverName - MP4", url, headers))
}
}
}.flatten()
} catch (_: Exception) {
// Network error, skip this source
}
return videos
}
// ============================== Filters ==============================
override fun getFilterList(): AnimeFilterList = AnimeFilterList(
ScheduleDayFilter(),
)
private class ScheduleDayFilter :
AnimeFilter.Select<String>(
"Schedule Day",
arrayOf("None", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"),
) {
private val apiValues = arrayOf("", "mon", "tue", "wed", "thu", "fri", "sat", "sun")
fun getValue(): String = apiValues[state]
}
// ============================== Preferences ==============================
@@ -436,18 +780,25 @@ class AnimeVerse :
default = PREF_USE_ALT_TITLE_DEFAULT,
)
screen.addSwitchPreference(
key = PREF_DIRECT_MP4,
title = "Prefer Direct MP4",
summary = "Use direct Base64 decoded MP4 stream instead of proxy.",
default = PREF_DIRECT_MP4_DEFAULT,
)
MultiSelectListPreference(screen.context).apply {
key = PREF_HOSTER_EXCLUDE_KEY
title = PREF_HOSTER_EXCLUDE_TITLE
entries = SERVERS_DISPLAY
entryValues = SERVERS_DISPLAY
setDefaultValue(PREF_HOSTER_EXCLUDE_DEFAULT)
summary = "Choose which hosts you want to exclude"
}.also(screen::addPreference)
}
companion object {
private const val PREF_USE_ALT_TITLE = "use_alt_title"
private const val PREF_USE_ALT_TITLE_DEFAULT = false
private const val PREF_DIRECT_MP4 = "direct_mp4"
private const val PREF_DIRECT_MP4_DEFAULT = false
private val SERVERS = arrayOf("animeverse", "choi", "chiki")
private val SERVERS_DISPLAY = arrayOf("AnimeVerse", "Choi", "Chiki")
private const val PREF_HOSTER_EXCLUDE_KEY = "hoster_exclusion"
private const val PREF_HOSTER_EXCLUDE_TITLE = "Excluded Hosts"
private val PREF_HOSTER_EXCLUDE_DEFAULT = emptySet<String>()
}
}