mirror of
https://github.com/yuzono/anime-extensions.git
synced 2026-06-13 05:29:44 +00:00
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:
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user