AnimeOnsen [Multi]: Fix Error 401, Add Filters & Title Language Preference (#402)

* Fix auth, add genres, language preference

* Reformat the genres

* Update title preference

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

* Update interceptor

* Add new animedetails info

* Fix title entry's name

* Update

* Update 2

* Hotfix

* refactor: improve error handling and optimize response parsing

---------

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
This commit is contained in:
Alpha-782
2026-06-12 10:54:57 +03:00
committed by GitHub
parent 1bb2dfa753
commit 20566c193a
6 changed files with 425 additions and 72 deletions

View File

@@ -1,7 +1,7 @@
ext {
extName = 'AnimeOnsen'
extClass = '.AnimeOnsen'
extVersionCode = 9
extVersionCode = 10
}
apply plugin: "kei.plugins.extension.legacy"

View File

@@ -1,55 +1,114 @@
package eu.kanade.tachiyomi.animeextension.all.animeonsen
import eu.kanade.tachiyomi.animeextension.all.animeonsen.AnimeOnsen.Companion.AO_USER_AGENT
import eu.kanade.tachiyomi.network.POST
import keiyoushi.utils.bodyString
import keiyoushi.utils.parseAs
import keiyoushi.utils.toJsonRequestBody
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
class AOAPIInterceptor(client: OkHttpClient, apiUrl: String) : Interceptor {
class AOAPIInterceptor(private val client: OkHttpClient, apiUrl: String) : Interceptor {
private val token: String by lazy {
runCatching {
val body = """
{
"client_id": "f296be26-28b5-4358-b5a1-6259575e23b7",
"client_secret": "349038c4157d0480784753841217270c3c5b35f4281eaee029de21cb04084235",
"grant_type": "client_credentials"
}
""".trimIndent().toJsonRequestBody()
private var token: String? = null
val headers = Headers.headersOf("user-agent", AO_USER_AGENT)
private val host: String = apiUrl.toHttpUrlOrNull()?.host ?: apiUrl
val tokenObject = client.newCall(
// Create a separate client for fetching the token to avoid infinite recursion
private val tokenClient by lazy {
client.newBuilder()
.apply { interceptors().removeAll { it is AOAPIInterceptor } }
.build()
}
@Synchronized
private fun fetchToken(): String? {
return try {
val formBody = FormBody.Builder()
.add("client_id", "f296be26-28b5-4358-b5a1-6259575e23b7")
.add("client_secret", "349038c4157d0480784753841217270c3c5b35f4281eaee029de21cb04084235")
.add("grant_type", "client_credentials")
.build()
val headers = Headers.headersOf(
"User-Agent",
AO_USER_AGENT,
"Accept",
"application/json",
"Origin",
"https://www.animeonsen.xyz",
"Referer",
"https://www.animeonsen.xyz/",
)
val response = tokenClient.newCall(
POST(
"https://auth.animeonsen.xyz/oauth/token",
headers,
body,
formBody,
),
).execute().parseAs<JsonObject>()
).execute()
tokenObject["access_token"]!!.jsonPrimitive.content
}.getOrElse { "" }
val responseBody = response.bodyString()
// If we still get an HTML page (Cloudflare block or wrong endpoint), fail gracefully
if (responseBody.isBlank() || responseBody.trimStart().startsWith("<")) {
return null
}
val tokenObject = responseBody.parseAs<JsonObject>()
tokenObject["access_token"]?.jsonPrimitive?.content
} catch (_: Throwable) {
// Silently fail so we don't break endpoints that don't require auth (like Search)
null
}
}
private val host: String = apiUrl.toHttpUrlOrNull()?.host ?: apiUrl
@Synchronized
private fun getOrRefreshToken(oldToken: String?): String? {
if (token == oldToken) {
token = fetchToken()
}
return token
}
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
if (originalRequest.url.host == host) {
val newRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
return chain.proceed(newRequest)
// Only apply API token to the API host, let SearchInterceptor handle the search host
if (originalRequest.url.host != host) {
return chain.proceed(originalRequest)
}
return chain.proceed(originalRequest)
val currentToken = getOrRefreshToken(null)
val request = if (currentToken != null) {
originalRequest.newBuilder()
.addHeader("Authorization", "Bearer $currentToken")
.build()
} else {
originalRequest
}
val response = chain.proceed(request)
if (response.code == 401) {
response.close()
val newToken = getOrRefreshToken(currentToken)
if (newToken != null) {
val newRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer $newToken")
.build()
return chain.proceed(newRequest)
}
}
return response
}
}

View File

@@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.animeextension.all.animeonsen.dto.AnimeDetails
import eu.kanade.tachiyomi.animeextension.all.animeonsen.dto.AnimeListItem
import eu.kanade.tachiyomi.animeextension.all.animeonsen.dto.AnimeListResponse
import eu.kanade.tachiyomi.animeextension.all.animeonsen.dto.EpisodeDto
import eu.kanade.tachiyomi.animeextension.all.animeonsen.dto.MeilisearchResponse
import eu.kanade.tachiyomi.animeextension.all.animeonsen.dto.SearchResponse
import eu.kanade.tachiyomi.animeextension.all.animeonsen.dto.VideoData
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
@@ -18,6 +19,7 @@ import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import keiyoushi.utils.firstInstanceOrNull
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import keiyoushi.utils.toJsonRequestBody
@@ -28,6 +30,7 @@ import kotlinx.serialization.json.put
import okhttp3.Headers
import okhttp3.Request
import okhttp3.Response
import kotlin.math.roundToInt
class AnimeOnsen :
AnimeHttpSource(),
@@ -54,17 +57,25 @@ class AnimeOnsen :
private val preferences by getPreferencesLazy()
override fun headersBuilder() = Headers.Builder().add("user-agent", AO_USER_AGENT)
override fun headersBuilder() = Headers.Builder()
.add("User-Agent", AO_USER_AGENT)
.add("Accept", "application/json, text/plain, */*")
.add("Accept-Language", "en-US,en;q=0.9")
.add("Referer", "$baseUrl/")
.add("Origin", baseUrl)
.add("Sec-Fetch-Dest", "empty")
.add("Sec-Fetch-Mode", "cors")
.add("Sec-Fetch-Site", "same-site")
private val preferredTitle: String
get() = preferences.getString(PREF_TITLE_KEY, PREF_TITLE_DEFAULT)!!
// ============================== Popular ===============================
// The site doesn't have a popular anime tab, so we use the home page instead (latest anime).
override fun popularAnimeRequest(page: Int) = GET("$apiUrl/content/index?start=${(page - 1) * 20}&limit=20")
override fun popularAnimeRequest(page: Int) = GET("$apiUrl/content/index?start=${(page - 1) * 30}&limit=30", headers)
override fun popularAnimeParse(response: Response): AnimesPage {
val responseJson = response.parseAs<AnimeListResponse>()
val animes = responseJson.content.map { it.toSAnime() }
// we can't (easily) serialize this thing because it returns a array with
// two types: a boolean and a integer.
val hasNextPage = responseJson.cursor.next.firstOrNull()?.jsonPrimitive?.boolean == true
return AnimesPage(animes, hasNextPage)
}
@@ -74,48 +85,125 @@ class AnimeOnsen :
override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException()
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val postBody = buildJsonObject {
put("q", query)
}.toJsonRequestBody()
override fun getFilterList(): AnimeFilterList = AnimeOnsenFilters.FILTER_LIST
return POST("$searchUrl/indexes/content/search", body = postBody)
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
if (query.isNotBlank()) {
val postBody = buildJsonObject {
put("q", query)
}.toJsonRequestBody()
return POST("$searchUrl/indexes/content/search", headers, postBody)
}
val genre = filters.firstInstanceOrNull<AnimeOnsenFilters.GenreFilter>()?.getValue()
return if (!genre.isNullOrBlank()) {
GET("$apiUrl/content/index/genre/$genre", headers)
} else {
val start = (page - 1) * 30
GET("$apiUrl/content/index?start=$start&limit=30", headers)
}
}
override fun searchAnimeParse(response: Response): AnimesPage {
val searchResult = response.parseAs<SearchResponse>().hits
val results = searchResult.map { it.toSAnime() }
return AnimesPage(results, false)
val requestUrl = response.request.url.toString()
return if (requestUrl.contains("indexes/content/search")) {
val searchResult = response.parseAs<MeilisearchResponse>().hits
val results = searchResult.map { it.toSAnime() }
AnimesPage(results, false)
} else if (requestUrl.contains("/genre/")) {
val searchResult = response.parseAs<SearchResponse>().result
val results = searchResult.map { it.toSAnime() }
AnimesPage(results, false)
} else {
val responseJson = response.parseAs<AnimeListResponse>()
val animes = responseJson.content.map { it.toSAnime() }
val hasNextPage = responseJson.cursor.next.firstOrNull()?.jsonPrimitive?.boolean == true
AnimesPage(animes, hasNextPage)
}
}
// =========================== Anime Details ============================
override fun animeDetailsRequest(anime: SAnime) = GET("$apiUrl/content/${anime.url}/extensive")
override fun animeDetailsRequest(anime: SAnime) = GET("$apiUrl/content/${anime.url}/extensive", headers)
override fun getAnimeUrl(anime: SAnime) = "$baseUrl/details/${anime.url}"
override fun animeDetailsParse(response: Response) = SAnime.create().apply {
val details = response.parseAs<AnimeDetails>()
url = details.content_id
title = details.content_title ?: details.content_title_en!!
title = when (preferredTitle) {
"english" -> details.content_title_en ?: details.content_title!!
else -> details.content_title ?: details.content_title_en!!
}
status = parseStatus(details.mal_data?.status)
author = details.mal_data?.studios?.joinToString { it.name }
genre = details.mal_data?.genres?.joinToString { it.name }
description = details.mal_data?.synopsis
thumbnail_url = "$apiUrl/image/210x300/${details.content_id}"
val descBuilder = StringBuilder()
details.mal_data?.mean_score?.let { score ->
val starCount = (score / 2.0).roundToInt().coerceIn(0, 5)
val stars = "".repeat(starCount) + "".repeat(5 - starCount)
descBuilder.append("$stars $score\n\n")
}
// Main synopsis
details.mal_data?.synopsis?.let { descBuilder.append(it) }
val subsList = try {
val epsResponse = client.newCall(GET("$apiUrl/content/${details.content_id}/episodes", headers)).execute()
val epsJson = epsResponse.parseAs<Map<String, EpisodeDto>>()
val firstEpNum = epsJson.keys.firstOrNull()
if (firstEpNum != null) {
val videoResponse = client.newCall(GET("$apiUrl/content/${details.content_id}/video/$firstEpNum", headers)).execute()
val videoData = videoResponse.parseAs<VideoData>()
videoData.metadata.subtitles.values
} else {
emptyList()
}
} catch (_: Exception) {
emptyList()
}
val extras = buildList {
details.mal_data?.rating?.let {
val formattedRating = it.replace("_", " ").uppercase()
add("**Rating:** $formattedRating")
}
if (subsList.isNotEmpty()) {
add("**Subtitles:** ${subsList.joinToString(", ")}")
}
details.mal_id?.let { add("[MAL](https://myanimelist.net/anime/$it)") }
}
if (extras.isNotEmpty()) {
descBuilder.append("\n\n")
descBuilder.append(extras.joinToString("\n"))
}
description = descBuilder.toString().trimEnd()
}
// ============================== Episodes ==============================
override fun episodeListRequest(anime: SAnime) = GET("$apiUrl/content/${anime.url}/episodes")
override fun episodeListRequest(anime: SAnime) = GET("$apiUrl/content/${anime.url}/episodes", headers)
override fun episodeListParse(response: Response): List<SEpisode> {
val contentId = response.request.url.toString().substringBeforeLast("/episodes")
val contentId = response.request.url.toString().removeSuffix("/")
.substringBeforeLast("/episodes")
.substringAfterLast("/")
val responseJson = response.parseAs<Map<String, EpisodeDto>>()
return responseJson.map { (epNum, item) ->
SEpisode.create().apply {
url = "$contentId/video/$epNum"
episode_number = epNum.toFloat()
name = "Episode $epNum: ${item.name}"
episode_number = epNum.toFloatOrNull() ?: -1f
name = when (preferredTitle) {
"english" -> "Episode $epNum: ${item.nameEn ?: item.nameJp ?: ""}"
else -> "Episode $epNum: ${item.nameJp ?: item.nameEn ?: ""}"
}
}
}.sortedByDescending { it.episode_number }
}
@@ -125,10 +213,9 @@ class AnimeOnsen :
val videoData = response.parseAs<VideoData>()
val videoUrl = videoData.uri.stream
val subtitleLangs = videoData.metadata.subtitles
val headers = headersBuilder().add("referer", baseUrl).build()
val subs = videoData.uri.subtitles.sortSubs().map { (langPrefix, subUrl) ->
val language = subtitleLangs[langPrefix]!!
val subs = videoData.uri.subtitles.sortSubs().mapNotNull { (langPrefix, subUrl) ->
val language = subtitleLangs[langPrefix] ?: return@mapNotNull null
Track(subUrl, language)
}
@@ -136,12 +223,21 @@ class AnimeOnsen :
return listOf(video)
}
override fun videoListRequest(episode: SEpisode) = GET("$apiUrl/content/${episode.url}")
override fun videoListRequest(episode: SEpisode) = GET("$apiUrl/content/${episode.url}", headers)
override fun videoUrlParse(response: Response) = throw UnsupportedOperationException()
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_TITLE_KEY
title = PREF_TITLE_TITLE
entries = PREF_TITLE_ENTRIES
entryValues = PREF_TITLE_VALUES
setDefaultValue(PREF_TITLE_DEFAULT)
summary = "%s"
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_SUB_KEY
title = PREF_SUB_TITLE
@@ -160,8 +256,12 @@ class AnimeOnsen :
private fun AnimeListItem.toSAnime() = SAnime.create().apply {
url = content_id
title = content_title ?: content_title_en!!
thumbnail_url = "$apiUrl/image/210x300/$content_id"
title = when (preferredTitle) {
"english" -> content_title_en ?: content_title ?: content_title_jp!!
else -> content_title ?: content_title_jp ?: content_title_en!!
}
// Reference way: dynamically construct the thumbnail URL from the ID if not provided directly by search
thumbnail_url = thumbnail ?: content_image ?: "$apiUrl/image/210x300/$content_id"
}
private fun Map<String, String>.sortSubs(): List<Map.Entry<String, String>> {
@@ -171,19 +271,30 @@ class AnimeOnsen :
compareBy { it.key.contains(sub) },
).reversed()
}
}
const val AO_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.3"
private const val PREF_SUB_KEY = "preferred_subLang"
private const val PREF_SUB_TITLE = "Preferred sub language"
const val PREF_SUB_DEFAULT = "en-US"
private val PREF_SUB_ENTRIES = arrayOf(
"العربية", "Deutsch", "English", "Español (Spain)",
"Español (Latin)", "Français", "Italiano",
"Português (Brasil)", "Русский",
)
private val PREF_SUB_VALUES = arrayOf(
"ar-ME", "de-DE", "en-US", "es-ES",
"es-LA", "fr-FR", "it-IT",
"pt-BR", "ru-RU",
)
companion object {
const val AO_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Mobile Safari/537.3"
// Title and episode name preferences
private const val PREF_TITLE_KEY = "preferred_title"
private const val PREF_TITLE_TITLE = "Preferred Title Language"
private const val PREF_TITLE_DEFAULT = "romaji"
private val PREF_TITLE_ENTRIES = arrayOf("Romaji", "English")
private val PREF_TITLE_VALUES = arrayOf("romaji", "english")
// Subtitle preferences
private const val PREF_SUB_KEY = "preferred_subLang"
private const val PREF_SUB_TITLE = "Preferred sub language"
const val PREF_SUB_DEFAULT = "en-US"
private val PREF_SUB_ENTRIES = arrayOf(
"العربية", "Deutsch", "English", "Español (Spain)",
"Español (Latin)", "Français", "Italiano",
"Português (Brasil)", "Русский",
)
private val PREF_SUB_VALUES = arrayOf(
"ar-ME", "de-DE", "en-US", "es-ES",
"es-LA", "fr-FR", "it-IT",
"pt-BR", "ru-RU",
)
}
}

View File

@@ -0,0 +1,170 @@
package eu.kanade.tachiyomi.animeextension.all.animeonsen
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
object AnimeOnsenFilters {
class GenreFilter : AnimeFilter.Select<String>("Genre", GENRE_ENTRIES.toTypedArray(), 0) {
fun getValue() = if (state == 0) null else GENRE_VALUES[state]
companion object {
private val GENRE_ENTRIES = listOf(
"All",
"Action",
"Adult Cast",
"Adventure",
"Anthropomorphic",
"Avant Garde",
"Award Winning",
"Childcare",
"Combat Sports",
"Comedy",
"Crossdressing",
"Cute Girls Doing Cute Things",
"Delinquents",
"Detective",
"Drama",
"Ecchi",
"Educational",
"Fantasy",
"Gag Humor",
"Gore",
"Gourmet",
"Harem",
"High Stakes Game",
"Historical",
"Horror",
"Idols (Female)",
"Idols (Male)",
"Isekai",
"Iyashikei",
"Love Polygon",
"Josei",
"Kids",
"Magical Sex Shift",
"Mahou Shoujo",
"Martial Arts",
"Mecha",
"Medical",
"Military",
"Music",
"Mystery",
"Mythology",
"Organized Crime",
"Otaku Culture",
"Parody",
"Performing Arts",
"Pets",
"Psychological",
"Racing",
"Reincarnation",
"Reverse Harem",
"Romance",
"Romantic Subtext",
"Samurai",
"School",
"Sci-Fi",
"Seinen",
"Shoujo",
"Shoujo Ai",
"Shounen",
"Shounen Ai",
"Showbiz",
"Slice of Life",
"Space", "Sports",
"Strategy Game",
"Super Power",
"Supernatural",
"Survival",
"Team Sports",
"Suspense",
"Time Travel",
"Vampire",
"Video Game",
"Visual Arts",
"Workplace",
)
private val GENRE_VALUES = listOf(
"",
"action",
"adult-cast",
"adventure",
"anthropomorphic",
"avant-garde",
"award-winning",
"childcare",
"combat-sports",
"comedy",
"crossdressing",
"cgdct",
"delinquents",
"detective",
"drama",
"ecchi",
"educational",
"fantasy",
"gag-humor",
"gore",
"gourmet",
"harem",
"high-stakes-game",
"historical",
"horror",
"idols-female",
"idols-male",
"isekai",
"iyashikei",
"love-polygon",
"josei",
"kids",
"magical-sex-shift",
"mahou-shoujo",
"martial-arts",
"mecha",
"medical",
"military",
"music",
"mystery",
"mythology",
"organized-crime",
"otaku-culture",
"parody",
"performing-arts",
"pets",
"psychological",
"racing",
"reincarnation",
"reverse-harem",
"romance",
"romantic-subtext",
"samurai",
"school",
"sci-fi",
"seinen",
"shoujo",
"shoujo-ai",
"shounen",
"shounen-ai",
"showbiz",
"slice-of-life",
"space",
"sports",
"strategy-game",
"super-power",
"supernatural",
"survival",
"team-sports",
"suspense",
"time-travel",
"vampire",
"video-game",
"visual-arts",
"workplace",
)
}
}
val FILTER_LIST get() = AnimeFilterList(
GenreFilter(),
)
}

View File

@@ -12,9 +12,7 @@ class SearchInterceptor(client: OkHttpClient, baseUrl: String, searchUrl: String
private val token: String by lazy {
runCatching {
val document = client.newCall(
GET(
baseUrl,
),
GET(baseUrl),
).execute().useAsJsoup()
document.selectFirst("meta[name=ao-search-token]")?.attr("content") ?: ""

View File

@@ -19,6 +19,9 @@ data class AnimeListItem(
val content_id: String,
val content_title: String? = null,
val content_title_en: String? = null,
val content_title_jp: String? = null,
val content_image: String? = null,
val thumbnail: String? = null,
)
@Serializable
@@ -29,6 +32,8 @@ data class AnimeDetails(
val content_id: String,
val content_title: String?,
val content_title_en: String?,
val mal_id: Int? = null,
val subtitle_support: Boolean? = null,
@Serializable(with = MalSerializer::class)
val mal_data: MalData?,
)
@@ -36,7 +41,9 @@ data class AnimeDetails(
@Serializable
data class EpisodeDto(
@SerialName("contentTitle_episode_en")
val name: String,
val nameEn: String? = null,
@SerialName("contentTitle_episode_jp")
val nameJp: String? = null,
)
@Serializable
@@ -45,6 +52,8 @@ data class MalData(
val status: String?,
val studios: List<Studio>?,
val synopsis: String?,
val mean_score: Double? = null,
val rating: String? = null,
)
@Serializable
@@ -69,10 +78,16 @@ data class StreamData(
)
@Serializable
data class SearchResponse(
data class MeilisearchResponse(
val hits: List<AnimeListItem>,
)
@Serializable
data class SearchResponse(
val status: Int,
val result: List<AnimeListItem>,
)
object MalSerializer : JsonTransformingSerializer<MalData>(MalData.serializer()) {
override fun transformDeserialize(element: JsonElement): JsonElement = when (element) {
is JsonPrimitive -> JsonObject(emptyMap())