From 4b8c4f420023828cfae12f08ec6f87ff139ef90e Mon Sep 17 00:00:00 2001 From: Alpha-782 Date: Thu, 16 Jul 2026 22:59:01 +0300 Subject: [PATCH 1/7] Refactor OniSaga to lib 1.6 --- src/all/onisaga/build.gradle.kts | 4 +- .../extension/all/onisaga/OniSaga.kt | 398 +++++++++--------- 2 files changed, 212 insertions(+), 190 deletions(-) diff --git a/src/all/onisaga/build.gradle.kts b/src/all/onisaga/build.gradle.kts index 15c6168031a..b30fc9bd9a1 100644 --- a/src/all/onisaga/build.gradle.kts +++ b/src/all/onisaga/build.gradle.kts @@ -6,9 +6,9 @@ plugins { keiyoushi { name = "OniSaga" - versionCode = 3 + versionCode = 4 contentWarning = ContentWarning.MIXED - libVersion = "1.4" + libVersion = "1.6" listOf("all", "en", "fr", "ja", "pt-BR", "pt", "es-419", "es").forEach { source { diff --git a/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt b/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt index 68a7f65c525..dbb369f024a 100644 --- a/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt +++ b/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt @@ -6,7 +6,7 @@ import androidx.preference.MultiSelectListPreference import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreferenceCompat import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList @@ -14,28 +14,35 @@ import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.source.model.SMangaUpdate import eu.kanade.tachiyomi.util.asJsoup import keiyoushi.annotation.Source +import keiyoushi.network.get +import keiyoushi.network.post import keiyoushi.network.rateLimit +import keiyoushi.source.KeiSource import keiyoushi.utils.firstInstanceOrNull -import keiyoushi.utils.getPreferencesLazy +import keiyoushi.utils.getPreferences import keiyoushi.utils.parseAs +import keiyoushi.utils.string import keiyoushi.utils.toJsonRequestBody import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import okhttp3.Headers +import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response import org.jsoup.Jsoup import org.jsoup.nodes.Document -import rx.Observable @Source abstract class OniSaga : - HttpSource(), + KeiSource(), ConfigurableSource { // Returns the uppercase language code required by the Livewire updates payload @@ -50,15 +57,13 @@ abstract class OniSaga : else -> null } - override val supportsLatest = true - private val livewireJson = Json { encodeDefaults = true } - override val client: OkHttpClient = network.client.newBuilder() - .rateLimit(4) - .build() + override fun OkHttpClient.Builder.configureClient() = apply { + rateLimit(4) + } @Volatile private var cachedStateUrl: String? = null @@ -100,11 +105,13 @@ abstract class OniSaga : } } - private val pageClient: OkHttpClient = client.newBuilder() - .addInterceptor(strictApiInterceptor) - .build() + private val pageClient: OkHttpClient by lazy { + client.newBuilder() + .addInterceptor(strictApiInterceptor) + .build() + } - private val preferences: SharedPreferences by getPreferencesLazy() + private val preferences: SharedPreferences = getPreferences() private fun buildLivewireHeaders(referer: String): Headers = headersBuilder() .set("X-Livewire", "") @@ -116,34 +123,28 @@ abstract class OniSaga : // =============================== Popular Manga =============================== - override fun fetchPopularManga(page: Int): Observable = Observable.fromCallable { + override suspend fun getPopularManga(page: Int): MangasPage { val updates = PostFilterUpdatesDto( platform = typePref(), status = statusPref(), sort = "view", ) - fetchMangaLivewirePage("$baseUrl/browse", page, updates) + return fetchMangaLivewirePage("$baseUrl/browse", page, updates) } - override fun popularMangaRequest(page: Int): Request = throw UnsupportedOperationException() - override fun popularMangaParse(response: Response): MangasPage = throw UnsupportedOperationException() - // ================================ Latest Manga ================================ - override fun fetchLatestUpdates(page: Int): Observable = Observable.fromCallable { + override suspend fun getLatestUpdates(page: Int): MangasPage { val updates = PostFilterUpdatesDto( platform = typePref(), status = statusPref(), ) - fetchMangaLivewirePage("$baseUrl/browse", page, updates) + return fetchMangaLivewirePage("$baseUrl/browse", page, updates) } - override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException() - override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException() - // ========================= Livewire Pagination Logic ========================== - private fun fetchMangaLivewirePage( + private suspend fun fetchMangaLivewirePage( url: String, page: Int, updates: PostFilterUpdatesDto? = null, @@ -153,7 +154,7 @@ abstract class OniSaga : val state = if (cachedStateUrl == url && cachedState != null) { cachedState!! } else { - val doc = client.newCall(GET(url, headers)).execute().use { it.asJsoup() } + val doc = client.get(url, headers).use { it.asJsoup() } if (page == 1 && updates == null && prefExcluded.isEmpty()) { return parseMangaList(doc) @@ -189,12 +190,14 @@ abstract class OniSaga : ), ) - val dto = client.newCall( - POST("$baseUrl/livewire/update", buildLivewireHeaders(url.substringBefore("?")), request.toJsonRequestBody(livewireJson)), - ).execute().use { response -> + val dto = client.post( + "$baseUrl/livewire/update", + buildLivewireHeaders(url.substringBefore("?")), + request.toJsonRequestBody(livewireJson), + ).use { response -> if (!response.isSuccessful) { cachedState = null - throw Exception("Livewire error (HTTP ${response.code}): ${response.body.string()}") + throw HttpException(response.code) } response.parseAs() } @@ -209,32 +212,8 @@ abstract class OniSaga : } private fun parseMangaList(doc: Document): MangasPage { - val mangas = mutableListOf() val showNsfw = preferences.getBoolean(PREF_NSFW_KEY, false) - - doc.select("div.relative.group").forEach { card -> - val nsfwSpan = card.selectFirst("span:containsOwn(18+)") - if (nsfwSpan != null && !showNsfw) return@forEach - nsfwSpan?.closest("div.absolute.inset-0.z-20")?.remove() - - val linkEl = card.selectFirst("a[href*=\"/manga/\"]") ?: return@forEach - val href = linkEl.absUrl("href") - if (href.toHttpUrl().pathSegments.firstOrNull()?.equals("manga", true) != true) return@forEach - - val titleEl = card.selectFirst("a[title]") ?: card.selectFirst("h3") ?: linkEl - val currentTitle = titleEl.attr("title").ifEmpty { titleEl.text() } - if (currentTitle.isEmpty()) return@forEach - - val currentThumb = card.selectFirst("img")?.let { resolveImageUrl(it) } - - mangas.add( - SManga.create().apply { - title = currentTitle - thumbnail_url = currentThumb - setUrlWithoutDomain(href) - }, - ) - } + val mangas = doc.select("div.relative.group").mapNotNull { it.toSManga(showNsfw) } val hasNextPage = doc.select("[wire:click*=nextPage]").any { !it.hasAttr("disabled") @@ -245,25 +224,25 @@ abstract class OniSaga : // =================================== Search =================================== - override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable = Observable.fromCallable { + override suspend fun getSearchMangaList(page: Int, query: String, filters: FilterList): MangasPage { if (query.startsWith("http")) { val url = query.toHttpUrl() var mangaUrl = query if (url.pathSegments.firstOrNull() == "read") { - val doc = client.newCall(GET(query, headers)).execute().use { it.asJsoup() } + val doc = client.get(query, headers).use { it.asJsoup() } mangaUrl = doc.selectFirst("a[href*=\"/manga/\"]")?.absUrl("href") ?: throw Exception("Could not find manga link on chapter page") } else if (url.pathSegments.size >= 3 && url.pathSegments[0] == "manga") { mangaUrl = "${url.scheme}://${url.host}/manga/${url.pathSegments[1]}" } - val manga = client.newCall(GET(mangaUrl, headers)).execute().use { response -> - mangaDetailsParse(response).apply { + val manga = client.get(mangaUrl, headers).use { response -> + mangaDetailsParse(response.asJsoup()).apply { setUrlWithoutDomain(mangaUrl) } } - return@fromCallable MangasPage(listOf(manga), false) + return MangasPage(listOf(manga), false) } val url = if (query.isNotBlank()) { baseUrl.toHttpUrl().newBuilder() @@ -275,7 +254,7 @@ abstract class OniSaga : "$baseUrl/browse" } val updates = buildUpdatesFromFilters(filters) - fetchMangaLivewirePage(url, page, updates) + return fetchMangaLivewirePage(url, page, updates) } private fun buildUpdatesFromFilters(filters: FilterList): PostFilterUpdatesDto? { @@ -313,12 +292,9 @@ abstract class OniSaga : } } - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = throw UnsupportedOperationException() - override fun searchMangaParse(response: Response): MangasPage = throw UnsupportedOperationException() - // ================================== Filters =================================== - override fun getFilterList(): FilterList = FilterList( + override fun getFilterList(data: JsonElement?): FilterList = FilterList( TypeFilter(), GenreFilter(genreList.map { GenreTriState(it.first, it.second) }), StatusFilter(), @@ -331,9 +307,26 @@ abstract class OniSaga : // =============================== Manga Details =============================== - override fun mangaDetailsParse(response: Response): SManga { - val doc = response.asJsoup() + override suspend fun fetchMangaUpdate( + manga: SManga, + chapters: List, + fetchDetails: Boolean, + fetchChapters: Boolean, + ): SMangaUpdate { + if (!fetchDetails && !fetchChapters) { + return SMangaUpdate(manga, chapters) + } + + val url = getMangaUrl(manga) + val doc = client.get(url, headers).use { it.asJsoup() } + + val details = if (fetchDetails) mangaDetailsParse(doc) else manga + val chapterList = if (fetchChapters) fetchChapterList(doc) else chapters + + return SMangaUpdate(details, chapterList) + } + private fun mangaDetailsParse(doc: Document): SManga { val nsfwSpan = doc.selectFirst("span:containsOwn(18+)") nsfwSpan?.closest("div.absolute.inset-0.z-20")?.remove() @@ -341,6 +334,8 @@ abstract class OniSaga : var bannerUrl = doc.selectFirst("img.absolute")?.let { resolveImageUrl(it) } + val slug = doc.location().toHttpUrl().pathSegments.lastOrNull { it.isNotBlank() } + return SManga.create().apply { title = doc.selectFirst("h1")?.text() ?: doc.selectFirst("[data-flux-heading]")?.text() @@ -353,6 +348,10 @@ abstract class OniSaga : bannerUrl = null } + memo = buildJsonObject { + slug?.let { put("slug", it) } + } + val infoSection = doc.selectFirst("div.flex.flex-col.md\\:flex-row") author = infoSection?.select("a[href*=\"/author/\"]")?.joinToString { it.text() } @@ -406,7 +405,7 @@ abstract class OniSaga : doc.selectFirst("p[class*=\"text-[13px]\"]")?.text()?.let { altText -> if (altText.isNotEmpty()) { - val altTitles = altText.split("\\s*·\\s*".toRegex()).filter { it.isNotEmpty() } + val altTitles = altText.split(INTERPUNCT_REGEX).filter { it.isNotEmpty() } if (altTitles.isNotEmpty()) { append("\n\n**Alternative Titles:**\n") altTitles.forEach { t -> append("- $t\n") } @@ -447,15 +446,47 @@ abstract class OniSaga : return baseUrl.toHttpUrl().resolve(src)?.toString() } - // =============================== Related Manga =============================== + private fun org.jsoup.nodes.Element.toSManga(showNsfw: Boolean): SManga? { + val nsfwSpan = selectFirst("span:containsOwn(18+)") + if (nsfwSpan != null && !showNsfw) return null + nsfwSpan?.closest("div.absolute.inset-0.z-20")?.remove() + + val linkEl = if (tagName() == "a") this else selectFirst("a[href*=\"/manga/\"]") + if (linkEl == null) return null - override val disableRelatedMangasBySearch = true + val href = linkEl.absUrl("href") + val httpUrl = href.toHttpUrlOrNull() ?: return null + val pathSegments = httpUrl.pathSegments.filter { it.isNotBlank() } + if (pathSegments.firstOrNull()?.equals("manga", true) != true || pathSegments.size < 2) return null - override fun relatedMangaListRequest(manga: SManga): Request = mangaDetailsRequest(manga) + val slug = pathSegments[1] - override fun relatedMangaListParse(response: Response): List { - val doc = response.asJsoup() - val currentUrl = response.request.url.encodedPath + val titleEl = selectFirst("div[data-flux-heading], h3, h4") ?: selectFirst("a[title]") ?: linkEl + val currentTitle = titleEl.attr("title").ifEmpty { titleEl.text() } + if (currentTitle.isEmpty()) return null + + val currentThumb = selectFirst("img[alt]:not([alt=''])")?.let { resolveImageUrl(it) } + ?: selectFirst("img")?.let { resolveImageUrl(it) } + + return SManga.create().apply { + title = currentTitle + thumbnail_url = currentThumb + setUrlWithoutDomain(href) + memo = buildJsonObject { + put("slug", slug) + } + } + } + + // =============================== Related Manga =============================== + + override suspend fun fetchRelatedMangaList(manga: SManga): List { + val doc = client.get(baseUrl + manga.url, headers).use { it.asJsoup() } + return relatedMangaListParse(doc) + } + + private fun relatedMangaListParse(doc: Document): List { + val currentUrl = doc.location().toHttpUrl().encodedPath val showNsfw = preferences.getBoolean(PREF_NSFW_KEY, false) val heading = doc.select("div[data-flux-heading], h3, h2") @@ -467,100 +498,75 @@ abstract class OniSaga : val section = heading.parents().firstOrNull { it.select("div.relative.group, a[href*='/manga/']").size > 1 } ?: return emptyList() - return section.select("div.relative.group, a[href*='/manga/']:has(img)").mapNotNull { element -> - val link = if (element.tagName() == "a") element else element.selectFirst("a[href*='/manga/']") - if (link == null) return@mapNotNull null - - val nsfwSpan = element.selectFirst("span:containsOwn(18+)") - if (nsfwSpan != null && !showNsfw) return@mapNotNull null - nsfwSpan?.closest("div.absolute.inset-0.z-20")?.remove() - - val href = link.absUrl("href") - if (href.toHttpUrl().pathSegments.firstOrNull()?.equals("manga", true) != true || href.contains(currentUrl)) return@mapNotNull null - - val title = element.selectFirst("div[data-flux-heading], h3, h4")?.text() - ?: link.attr("title").ifEmpty { link.text() } - - if (title.isEmpty()) return@mapNotNull null - - val thumbnail = element.selectFirst("img[alt]:not([alt=''])")?.let { resolveImageUrl(it) } - ?: element.selectFirst("img")?.let { resolveImageUrl(it) } - - SManga.create().apply { - this.title = title - thumbnail_url = thumbnail - setUrlWithoutDomain(href) - } - }.distinctBy { it.url } + return section.select("div.relative.group, a[href*='/manga/']:has(img)") + .mapNotNull { it.toSManga(showNsfw) } + .filter { it.url != currentUrl } + .distinctBy { it.url } } // ================================= Chapters ================================== - override fun fetchChapterList(manga: SManga): Observable> { - return Observable.fromCallable { - val doc = client.newCall(mangaDetailsRequest(manga)).execute().use { it.asJsoup() } - - val nsfwSpan = doc.selectFirst("span:containsOwn(18+)") - nsfwSpan?.closest("div.absolute.inset-0.z-20")?.remove() - - val state = doc.extractLivewireState("manga.chapter-list") - ?: return@fromCallable emptyList() - - // If langCode is null (the "all" source), loop through all 7 languages - val langCodes = langCode?.let { listOf(it) } - ?: listOf("EN", "FR", "JA", "PT-BR", "PT", "ES-LA", "ES") - - val allChapters = mutableListOf() + private suspend fun fetchChapterList(doc: Document): List { + val nsfwSpan = doc.selectFirst("span:containsOwn(18+)") + nsfwSpan?.closest("div.absolute.inset-0.z-20")?.remove() - for (code in langCodes) { - var currentSnapshot = state.snapshot - var prevChapterCount = 0 - var langChapters = listOf() + val state = doc.extractLivewireState("manga.chapter-list") + ?: return emptyList() - while (true) { - val request = ChapterLivewireRequest( - token = state.token, - components = listOf( - ChapterLivewireRequest.Component( - snapshot = currentSnapshot, - updates = ChapterUpdatesDto(language = code), - calls = listOf(LivewireCall(method = "loadMoreChapters")), - ), + // If langCode is null (the "all" source), loop through all 7 languages + val langCodes = langCode?.let { listOf(it) } + ?: listOf("EN", "FR", "JA", "PT-BR", "PT", "ES-LA", "ES") + + val allChapters = mutableListOf() + + for (code in langCodes) { + var currentSnapshot = state.snapshot + var prevChapterCount = 0 + var langChapters = listOf() + + while (true) { + val request = ChapterLivewireRequest( + token = state.token, + components = listOf( + ChapterLivewireRequest.Component( + snapshot = currentSnapshot, + updates = ChapterUpdatesDto(language = code), + calls = listOf(LivewireCall(method = "loadMoreChapters")), ), - ) - - val chapterRequest = POST("$baseUrl/livewire/update", buildLivewireHeaders(baseUrl + manga.url), request.toJsonRequestBody(livewireJson)) - - val dto = client.newCall(chapterRequest).execute().use { response -> - if (!response.isSuccessful) { - null - } else { - response.parseAs() - } - } ?: break + ), + ) - val html = dto.components.firstOrNull()?.effects?.html ?: break - val chapterDoc = Jsoup.parseBodyFragment(html, baseUrl) + val dto = client.post( + "$baseUrl/livewire/update", + buildLivewireHeaders(doc.location()), + request.toJsonRequestBody(livewireJson), + ).use { response -> + if (!response.isSuccessful) { + null + } else { + response.parseAs() + } + } ?: break - langChapters = parseChaptersFromDoc(chapterDoc, code, langCode == null) + val html = dto.components.firstOrNull()?.effects?.html ?: break + val chapterDoc = Jsoup.parseBodyFragment(html, baseUrl) - if (langChapters.size <= prevChapterCount) break + langChapters = parseChaptersFromDoc(chapterDoc, code, langCode == null) - prevChapterCount = langChapters.size - currentSnapshot = dto.components.firstOrNull()?.snapshot ?: break - } + if (langChapters.size <= prevChapterCount) break - allChapters.addAll(langChapters) + prevChapterCount = langChapters.size + currentSnapshot = dto.components.firstOrNull()?.snapshot ?: break } - allChapters.distinctBy { it.url } - .sortedByDescending { - CHAPTER_NUMBER_REGEX.find(it.name)?.groupValues?.get(1)?.toFloatOrNull() ?: 0f - } + allChapters.addAll(langChapters) } - } - override fun chapterListParse(response: Response): List = throw UnsupportedOperationException() + return allChapters.distinctBy { it.url } + .sortedByDescending { + CHAPTER_NUMBER_REGEX.find(it.name)?.groupValues?.get(1)?.toFloatOrNull() ?: 0f + } + } private fun parseChaptersFromDoc(doc: Document, langCode: String, isAllSource: Boolean): List { val chapters = mutableListOf() @@ -577,7 +583,7 @@ abstract class OniSaga : if (url.isEmpty() || !url.contains("/read/")) return@forEach val textEl = el.selectFirst("p[data-flux-text]") - val details = textEl?.text()?.replace(" - ", " · ")?.split("\\s*·\\s*".toRegex())?.filter { it.isNotEmpty() } ?: emptyList() + val details = textEl?.text()?.replace(" - ", " · ")?.split(INTERPUNCT_REGEX)?.filter { it.isNotEmpty() } ?: emptyList() val dateStr = details.firstOrNull { val lower = it.lowercase() @@ -587,7 +593,7 @@ abstract class OniSaga : chapters.add( SChapter.create().apply { name = "Chapter $number" - scanlator = if (isAllSource) langDisplay else null + scanlator = if (isAllSource) langCode else null date_upload = parseChapterDate(dateStr) setUrlWithoutDomain(url) }, @@ -602,7 +608,7 @@ abstract class OniSaga : val number = headingText?.ifBlank { null } ?: button.selectFirst("div.w-10")?.text() ?: return@forEach val textEl = button.selectFirst("p[data-flux-text]") - val details = textEl?.text()?.replace(" - ", " · ")?.split("\\s*·\\s*".toRegex())?.filter { it.isNotEmpty() } ?: emptyList() + val details = textEl?.text()?.replace(" - ", " · ")?.split(INTERPUNCT_REGEX)?.filter { it.isNotEmpty() } ?: emptyList() val dateStr = details.firstOrNull { val lower = it.lowercase() lower.contains("ago") || lower.contains("today") || lower.contains("yesterday") @@ -672,15 +678,15 @@ abstract class OniSaga : @Volatile private var currentReaderToken: String = "" - override fun pageListParse(response: Response): List { - val body = response.body.string() + override suspend fun getPageList(chapter: SChapter): List { + val chapterUrl = baseUrl + chapter.url + val body = client.get(chapterUrl, headers).use { it.body.string() } val token = READER_TOKEN_REGEX.find(body)?.groupValues?.get(1) ?: "" if (token.isBlank()) throw Exception("Could not find readerToken in chapter page") currentReaderToken = token - val chapterUrl = response.request.url.toString() val pageCount = PAGE_ORDER_REGEX.findAll(body).count() return (0 until pageCount).map { index -> @@ -688,52 +694,49 @@ abstract class OniSaga : } } - override fun fetchImageUrl(page: Page): Observable { - val chapterUrl = page.url.substringBeforeLast("#") + override suspend fun getImageUrl(page: Page): String { + val chapterUrl = page.url.substringBefore("#") val cid = chapterUrl.toHttpUrl().pathSegments.last() val order = page.index val apiUrl = "$baseUrl/api/chapter/$cid/page/$order" - return Observable.fromCallable { - var token = currentReaderToken - var attempt = 0 - - while (attempt < 3) { - attempt++ + var token = currentReaderToken + var attempt = 0 - val response = pageClient.newCall(GET(apiUrl, apiHeaders(token, chapterUrl))).execute() + while (attempt < 3) { + attempt++ - response.header("x-reader-token-next")?.takeIf { it.isNotBlank() }?.let { - currentReaderToken = it - } + val response = pageClient.get(apiUrl, apiHeaders(token, chapterUrl)) - val dto = response.parseAs() + // Update token globally if the server provides a new one + response.header("x-reader-token-next")?.takeIf { it.isNotBlank() }?.let { + currentReaderToken = it + } - if (dto.url != null) { - return@fromCallable dto.url - } + val dto = response.use { it.parseAs() } - if (!response.isSuccessful || dto.message?.contains("expired", ignoreCase = true) == true) { - val refreshBody = client.newCall(GET(chapterUrl, headers)).execute().use { it.body.string() } - val newToken = READER_TOKEN_REGEX.find(refreshBody)?.groupValues?.get(1) + if (dto.url != null) { + return dto.url + } - if (newToken.isNullOrBlank()) { - throw Exception("Failed to refresh reader token (HTTP ${response.code})") - } + if (!response.isSuccessful || dto.message?.contains("expired", ignoreCase = true) == true) { + val refreshBody = client.get(chapterUrl, headers).use { it.body.string() } + val newToken = READER_TOKEN_REGEX.find(refreshBody)?.groupValues?.get(1) - token = newToken - currentReaderToken = newToken - continue + if (newToken.isNullOrBlank()) { + throw Exception("Failed to refresh reader token (HTTP ${response.code})") } - throw Exception("API Error: ${dto.message}") + token = newToken + currentReaderToken = newToken + continue } - throw Exception("Failed to fetch image after 3 retries.") + throw Exception("API Error: ${dto.message}") } - } - override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() + throw Exception("Failed to fetch image after 3 retries.") + } override fun imageRequest(page: Page): Request { val referer = page.url.substringBeforeLast("#") @@ -766,6 +769,24 @@ abstract class OniSaga : return null } + override fun getMangaUrl(manga: SManga): String { + val slug = manga.memo["slug"]?.string ?: manga.url.substringAfterLast("/") + return "$baseUrl/manga/$slug" + } + + override fun getChapterUrl(chapter: SChapter): String = baseUrl + chapter.url + + override suspend fun getMangaByUrl(url: HttpUrl): SManga? { + if (url.pathSegments.firstOrNull()?.equals("manga", true) == true) { + val mangaUrl = "${url.scheme}://${url.host}/manga/${url.pathSegments[1]}" + val doc = client.get(mangaUrl, headers).use { it.asJsoup() } + return mangaDetailsParse(doc).apply { + setUrlWithoutDomain(mangaUrl) + } + } + return null + } + class LivewireState(val snapshot: String, val token: String) private fun excludedGenresPref(): Set { @@ -871,5 +892,6 @@ abstract class OniSaga : private val ORIGIN_REGEX = Regex("(Japanese|Korean|Chinese|English)", RegexOption.IGNORE_CASE) private val YEAR_REGEX = Regex("^\\d{4}$") private val RATING_REGEX = Regex("(\\d)\\.0(?=[/ ])") + private val INTERPUNCT_REGEX = Regex("\\s*·\\s*") } } From 92a9d29ccae094e3c5770c3268e58444758901b5 Mon Sep 17 00:00:00 2001 From: Alpha-782 Date: Fri, 17 Jul 2026 00:08:10 +0300 Subject: [PATCH 2/7] Small fixes --- .../extension/all/onisaga/OniSaga.kt | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt b/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt index dbb369f024a..72b4811050b 100644 --- a/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt +++ b/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt @@ -154,7 +154,7 @@ abstract class OniSaga : val state = if (cachedStateUrl == url && cachedState != null) { cachedState!! } else { - val doc = client.get(url, headers).use { it.asJsoup() } + val doc = client.get(url, headers).asJsoup() if (page == 1 && updates == null && prefExcluded.isEmpty()) { return parseMangaList(doc) @@ -230,17 +230,15 @@ abstract class OniSaga : var mangaUrl = query if (url.pathSegments.firstOrNull() == "read") { - val doc = client.get(query, headers).use { it.asJsoup() } + val doc = client.get(query, headers).asJsoup() mangaUrl = doc.selectFirst("a[href*=\"/manga/\"]")?.absUrl("href") ?: throw Exception("Could not find manga link on chapter page") } else if (url.pathSegments.size >= 3 && url.pathSegments[0] == "manga") { mangaUrl = "${url.scheme}://${url.host}/manga/${url.pathSegments[1]}" } - val manga = client.get(mangaUrl, headers).use { response -> - mangaDetailsParse(response.asJsoup()).apply { - setUrlWithoutDomain(mangaUrl) - } + val manga = mangaDetailsParse(client.get(mangaUrl, headers).asJsoup()).apply { + setUrlWithoutDomain(mangaUrl) } return MangasPage(listOf(manga), false) } @@ -313,12 +311,8 @@ abstract class OniSaga : fetchDetails: Boolean, fetchChapters: Boolean, ): SMangaUpdate { - if (!fetchDetails && !fetchChapters) { - return SMangaUpdate(manga, chapters) - } - val url = getMangaUrl(manga) - val doc = client.get(url, headers).use { it.asJsoup() } + val doc = client.get(url, headers).asJsoup() val details = if (fetchDetails) mangaDetailsParse(doc) else manga val chapterList = if (fetchChapters) fetchChapterList(doc) else chapters @@ -481,7 +475,7 @@ abstract class OniSaga : // =============================== Related Manga =============================== override suspend fun fetchRelatedMangaList(manga: SManga): List { - val doc = client.get(baseUrl + manga.url, headers).use { it.asJsoup() } + val doc = client.get(baseUrl + manga.url, headers).asJsoup() return relatedMangaListParse(doc) } @@ -779,7 +773,7 @@ abstract class OniSaga : override suspend fun getMangaByUrl(url: HttpUrl): SManga? { if (url.pathSegments.firstOrNull()?.equals("manga", true) == true) { val mangaUrl = "${url.scheme}://${url.host}/manga/${url.pathSegments[1]}" - val doc = client.get(mangaUrl, headers).use { it.asJsoup() } + val doc = client.get(mangaUrl, headers).asJsoup() return mangaDetailsParse(doc).apply { setUrlWithoutDomain(mangaUrl) } From ff6b3de446a18d9fb355f482bb9f8724990e8fb4 Mon Sep 17 00:00:00 2001 From: Alpha-782 Date: Fri, 17 Jul 2026 00:13:42 +0300 Subject: [PATCH 3/7] Small cleanup --- .../tachiyomi/extension/all/onisaga/OniSaga.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt b/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt index 72b4811050b..421cbc80c5d 100644 --- a/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt +++ b/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt @@ -194,8 +194,9 @@ abstract class OniSaga : "$baseUrl/livewire/update", buildLivewireHeaders(url.substringBefore("?")), request.toJsonRequestBody(livewireJson), - ).use { response -> + ).let { response -> if (!response.isSuccessful) { + response.close() cachedState = null throw HttpException(response.code) } @@ -534,8 +535,9 @@ abstract class OniSaga : "$baseUrl/livewire/update", buildLivewireHeaders(doc.location()), request.toJsonRequestBody(livewireJson), - ).use { response -> + ).let { response -> if (!response.isSuccessful) { + response.close() null } else { response.parseAs() @@ -674,7 +676,7 @@ abstract class OniSaga : override suspend fun getPageList(chapter: SChapter): List { val chapterUrl = baseUrl + chapter.url - val body = client.get(chapterUrl, headers).use { it.body.string() } + val body = client.get(chapterUrl, headers).body.string() val token = READER_TOKEN_REGEX.find(body)?.groupValues?.get(1) ?: "" if (token.isBlank()) throw Exception("Could not find readerToken in chapter page") @@ -707,14 +709,14 @@ abstract class OniSaga : currentReaderToken = it } - val dto = response.use { it.parseAs() } + val dto = response.parseAs() if (dto.url != null) { return dto.url } if (!response.isSuccessful || dto.message?.contains("expired", ignoreCase = true) == true) { - val refreshBody = client.get(chapterUrl, headers).use { it.body.string() } + val refreshBody = client.get(chapterUrl, headers).body.string() val newToken = READER_TOKEN_REGEX.find(refreshBody)?.groupValues?.get(1) if (newToken.isNullOrBlank()) { From 22b0423ce40c63e699187f2587703a28686445a9 Mon Sep 17 00:00:00 2001 From: Alpha-782 Date: Fri, 17 Jul 2026 00:19:12 +0300 Subject: [PATCH 4/7] Oops --- .../eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt b/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt index 421cbc80c5d..38d25d80bbf 100644 --- a/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt +++ b/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt @@ -194,9 +194,8 @@ abstract class OniSaga : "$baseUrl/livewire/update", buildLivewireHeaders(url.substringBefore("?")), request.toJsonRequestBody(livewireJson), - ).let { response -> + ).use { response -> if (!response.isSuccessful) { - response.close() cachedState = null throw HttpException(response.code) } @@ -535,9 +534,8 @@ abstract class OniSaga : "$baseUrl/livewire/update", buildLivewireHeaders(doc.location()), request.toJsonRequestBody(livewireJson), - ).let { response -> + ).use { response -> if (!response.isSuccessful) { - response.close() null } else { response.parseAs() From 91935c9a246dfb38101a683d12e936e111eff648 Mon Sep 17 00:00:00 2001 From: Alpha-782 Date: Fri, 17 Jul 2026 14:44:03 +0300 Subject: [PATCH 5/7] Review --- .../extension/all/onisaga/OniSaga.kt | 216 ++++++++---------- 1 file changed, 92 insertions(+), 124 deletions(-) diff --git a/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt b/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt index 38d25d80bbf..9df2e0d4ecc 100644 --- a/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt +++ b/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt @@ -6,7 +6,6 @@ import androidx.preference.MultiSelectListPreference import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreferenceCompat import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList @@ -26,6 +25,11 @@ import keiyoushi.utils.getPreferences import keiyoushi.utils.parseAs import keiyoushi.utils.string import keiyoushi.utils.toJsonRequestBody +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.buildJsonObject @@ -34,11 +38,11 @@ import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import org.jsoup.Jsoup import org.jsoup.nodes.Document +import org.jsoup.nodes.Element @Source abstract class OniSaga : @@ -73,44 +77,6 @@ abstract class OniSaga : @Volatile private var lastRequestTime = 0L - private val strictApiInterceptor = Interceptor { chain -> - synchronized(apiLock) { - val rateLimitDelay = preferences.getString(PREF_RATE_LIMIT_KEY, "2000")?.toLongOrNull() ?: 2000L - - val now = System.currentTimeMillis() - val waitTime = (rateLimitDelay - (now - lastRequestTime)).coerceAtLeast(0L) - - // Using rateLimit from keiyoushi.network.rateLimit was too aggressive, leading to 429 errors when it was fine after the rest of the images loaded - // Note: Error 429 lasts for approximately 15-30 minutes. Had to jump between many VPN servers to reach this conclusion - if (waitTime > 0) { - Thread.sleep(waitTime) - } - - var response = chain.proceed(chain.request()) - - // Handle 429 just in case (still holding the lock) - var attempt = 0 - while (response.code == 429 && attempt < 3) { - val retryAfter = response.header("retry-after")?.toLongOrNull()?.times(1000L) ?: rateLimitDelay - response.close() - - Thread.sleep(retryAfter) - response = chain.proceed(chain.request()) - attempt++ - } - - lastRequestTime = System.currentTimeMillis() - - response - } - } - - private val pageClient: OkHttpClient by lazy { - client.newBuilder() - .addInterceptor(strictApiInterceptor) - .build() - } - private val preferences: SharedPreferences = getPreferences() private fun buildLivewireHeaders(referer: String): Headers = headersBuilder() @@ -154,7 +120,7 @@ abstract class OniSaga : val state = if (cachedStateUrl == url && cachedState != null) { cachedState!! } else { - val doc = client.get(url, headers).asJsoup() + val doc = client.get(url).asJsoup() if (page == 1 && updates == null && prefExcluded.isEmpty()) { return parseMangaList(doc) @@ -194,13 +160,7 @@ abstract class OniSaga : "$baseUrl/livewire/update", buildLivewireHeaders(url.substringBefore("?")), request.toJsonRequestBody(livewireJson), - ).use { response -> - if (!response.isSuccessful) { - cachedState = null - throw HttpException(response.code) - } - response.parseAs() - } + ).parseAs() dto.components.firstOrNull()?.snapshot?.let { newSnapshot -> cachedState = LivewireState(newSnapshot, state.token) @@ -225,23 +185,6 @@ abstract class OniSaga : // =================================== Search =================================== override suspend fun getSearchMangaList(page: Int, query: String, filters: FilterList): MangasPage { - if (query.startsWith("http")) { - val url = query.toHttpUrl() - var mangaUrl = query - - if (url.pathSegments.firstOrNull() == "read") { - val doc = client.get(query, headers).asJsoup() - mangaUrl = doc.selectFirst("a[href*=\"/manga/\"]")?.absUrl("href") - ?: throw Exception("Could not find manga link on chapter page") - } else if (url.pathSegments.size >= 3 && url.pathSegments[0] == "manga") { - mangaUrl = "${url.scheme}://${url.host}/manga/${url.pathSegments[1]}" - } - - val manga = mangaDetailsParse(client.get(mangaUrl, headers).asJsoup()).apply { - setUrlWithoutDomain(mangaUrl) - } - return MangasPage(listOf(manga), false) - } val url = if (query.isNotBlank()) { baseUrl.toHttpUrl().newBuilder() .addPathSegment("search") @@ -312,9 +255,9 @@ abstract class OniSaga : fetchChapters: Boolean, ): SMangaUpdate { val url = getMangaUrl(manga) - val doc = client.get(url, headers).asJsoup() + val doc = client.get(url).asJsoup() - val details = if (fetchDetails) mangaDetailsParse(doc) else manga + val details = mangaDetailsParse(doc) val chapterList = if (fetchChapters) fetchChapterList(doc) else chapters return SMangaUpdate(details, chapterList) @@ -430,7 +373,7 @@ abstract class OniSaga : } } - private fun resolveImageUrl(img: org.jsoup.nodes.Element): String? { + private fun resolveImageUrl(img: Element): String? { val src = img.attr("data-src") .ifEmpty { img.attr("data-lazy-src") } .ifEmpty { img.attr("src") } @@ -440,7 +383,7 @@ abstract class OniSaga : return baseUrl.toHttpUrl().resolve(src)?.toString() } - private fun org.jsoup.nodes.Element.toSManga(showNsfw: Boolean): SManga? { + private fun Element.toSManga(showNsfw: Boolean): SManga? { val nsfwSpan = selectFirst("span:containsOwn(18+)") if (nsfwSpan != null && !showNsfw) return null nsfwSpan?.closest("div.absolute.inset-0.z-20")?.remove() @@ -475,7 +418,7 @@ abstract class OniSaga : // =============================== Related Manga =============================== override suspend fun fetchRelatedMangaList(manga: SManga): List { - val doc = client.get(baseUrl + manga.url, headers).asJsoup() + val doc = client.get(baseUrl + manga.url).asJsoup() return relatedMangaListParse(doc) } @@ -500,63 +443,58 @@ abstract class OniSaga : // ================================= Chapters ================================== - private suspend fun fetchChapterList(doc: Document): List { + private suspend fun fetchChapterList(doc: Document): List = coroutineScope { val nsfwSpan = doc.selectFirst("span:containsOwn(18+)") nsfwSpan?.closest("div.absolute.inset-0.z-20")?.remove() val state = doc.extractLivewireState("manga.chapter-list") - ?: return emptyList() + ?: return@coroutineScope emptyList() - // If langCode is null (the "all" source), loop through all 7 languages + // If langCode is null (the "all" source), loop through all 7 languages in parallel val langCodes = langCode?.let { listOf(it) } ?: listOf("EN", "FR", "JA", "PT-BR", "PT", "ES-LA", "ES") - val allChapters = mutableListOf() - - for (code in langCodes) { - var currentSnapshot = state.snapshot - var prevChapterCount = 0 - var langChapters = listOf() - - while (true) { - val request = ChapterLivewireRequest( - token = state.token, - components = listOf( - ChapterLivewireRequest.Component( - snapshot = currentSnapshot, - updates = ChapterUpdatesDto(language = code), - calls = listOf(LivewireCall(method = "loadMoreChapters")), - ), - ), - ) - - val dto = client.post( - "$baseUrl/livewire/update", - buildLivewireHeaders(doc.location()), - request.toJsonRequestBody(livewireJson), - ).use { response -> - if (!response.isSuccessful) { - null - } else { - response.parseAs() + val deferredChapters = langCodes.map { code -> + async { + runCatching { + var currentSnapshot = state.snapshot + var prevChapterCount = 0 + var langChapters = emptyList() + + while (true) { + val request = ChapterLivewireRequest( + token = state.token, + components = listOf( + ChapterLivewireRequest.Component( + snapshot = currentSnapshot, + updates = ChapterUpdatesDto(language = code), + calls = listOf(LivewireCall(method = "loadMoreChapters")), + ), + ), + ) + + val dto = client.post( + "$baseUrl/livewire/update", + buildLivewireHeaders(doc.location()), + request.toJsonRequestBody(livewireJson), + ).parseAs() + + val html = dto.components.firstOrNull()?.effects?.html ?: break + val chapterDoc = Jsoup.parseBodyFragment(html, baseUrl) + + langChapters = parseChaptersFromDoc(chapterDoc, code, langCode == null) + + if (langChapters.size <= prevChapterCount) break + + prevChapterCount = langChapters.size + currentSnapshot = dto.components.firstOrNull()?.snapshot ?: break } - } ?: break - - val html = dto.components.firstOrNull()?.effects?.html ?: break - val chapterDoc = Jsoup.parseBodyFragment(html, baseUrl) - - langChapters = parseChaptersFromDoc(chapterDoc, code, langCode == null) - - if (langChapters.size <= prevChapterCount) break - - prevChapterCount = langChapters.size - currentSnapshot = dto.components.firstOrNull()?.snapshot ?: break + langChapters + }.getOrDefault(emptyList()) } - - allChapters.addAll(langChapters) } - return allChapters.distinctBy { it.url } + deferredChapters.awaitAll().flatten().distinctBy { it.url } .sortedByDescending { CHAPTER_NUMBER_REGEX.find(it.name)?.groupValues?.get(1)?.toFloatOrNull() ?: 0f } @@ -674,7 +612,7 @@ abstract class OniSaga : override suspend fun getPageList(chapter: SChapter): List { val chapterUrl = baseUrl + chapter.url - val body = client.get(chapterUrl, headers).body.string() + val body = client.get(chapterUrl).body.string() val token = READER_TOKEN_REGEX.find(body)?.groupValues?.get(1) ?: "" if (token.isBlank()) throw Exception("Could not find readerToken in chapter page") @@ -700,7 +638,29 @@ abstract class OniSaga : while (attempt < 3) { attempt++ - val response = pageClient.get(apiUrl, apiHeaders(token, chapterUrl)) + synchronized(apiLock) { + val rateLimitDelay = preferences.getString(PREF_RATE_LIMIT_KEY, "2000")?.toLongOrNull() ?: 2000L + val now = System.currentTimeMillis() + val waitTime = (rateLimitDelay - (now - lastRequestTime)).coerceAtLeast(0L) + + // Using rateLimit from keiyoushi.network.rateLimit was too aggressive, leading to 429 errors when it was fine after the rest of the images loaded + // Note: Error 429 lasts for approximately 15-30 minutes. Had to jump between many VPN servers to reach this conclusion + if (waitTime > 0) Thread.sleep(waitTime) + lastRequestTime = System.currentTimeMillis() + } + + val response = client.get(apiUrl, apiHeaders(token, chapterUrl), ensureSuccess = false) + + // Handle 429 just in case (still holding the lock) + if (response.code == 429) { + val rateLimitDelay = preferences.getString(PREF_RATE_LIMIT_KEY, "2000")?.toLongOrNull() ?: 2000L + val retryAfter = response.header("retry-after")?.toLongOrNull()?.times(1000L) ?: rateLimitDelay + response.close() + withContext(Dispatchers.IO) { + Thread.sleep(retryAfter) + } + continue + } // Update token globally if the server provides a new one response.header("x-reader-token-next")?.takeIf { it.isNotBlank() }?.let { @@ -714,7 +674,7 @@ abstract class OniSaga : } if (!response.isSuccessful || dto.message?.contains("expired", ignoreCase = true) == true) { - val refreshBody = client.get(chapterUrl, headers).body.string() + val refreshBody = client.get(chapterUrl).body.string() val newToken = READER_TOKEN_REGEX.find(refreshBody)?.groupValues?.get(1) if (newToken.isNullOrBlank()) { @@ -771,14 +731,22 @@ abstract class OniSaga : override fun getChapterUrl(chapter: SChapter): String = baseUrl + chapter.url override suspend fun getMangaByUrl(url: HttpUrl): SManga? { - if (url.pathSegments.firstOrNull()?.equals("manga", true) == true) { - val mangaUrl = "${url.scheme}://${url.host}/manga/${url.pathSegments[1]}" - val doc = client.get(mangaUrl, headers).asJsoup() - return mangaDetailsParse(doc).apply { - setUrlWithoutDomain(mangaUrl) + val pathSegments = url.pathSegments.filter { it.isNotBlank() } + val mangaUrl = when { + pathSegments.firstOrNull()?.equals("manga", true) == true && pathSegments.size >= 2 -> { + "${url.scheme}://${url.host}/manga/${pathSegments[1]}" + } + pathSegments.firstOrNull()?.equals("read", true) == true -> { + val doc = client.get(url.toString()).asJsoup() + doc.selectFirst("a[href*=\"/manga/\"]")?.absUrl("href") ?: return null } + else -> return null + } + + val doc = client.get(mangaUrl).asJsoup() + return mangaDetailsParse(doc).apply { + setUrlWithoutDomain(mangaUrl) } - return null } class LivewireState(val snapshot: String, val token: String) From 83c3df13c0a20eb7d572941cfc98d83d19e06b4f Mon Sep 17 00:00:00 2001 From: Alpha-782 Date: Fri, 17 Jul 2026 15:12:24 +0300 Subject: [PATCH 6/7] Cleanup and review --- .../extension/all/onisaga/OniSaga.kt | 75 +++++++++---------- 1 file changed, 36 insertions(+), 39 deletions(-) diff --git a/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt b/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt index 9df2e0d4ecc..5481fe98d58 100644 --- a/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt +++ b/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt @@ -25,11 +25,12 @@ import keiyoushi.utils.getPreferences import keiyoushi.utils.parseAs import keiyoushi.utils.string import keiyoushi.utils.toJsonRequestBody -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.withContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.buildJsonObject @@ -73,7 +74,7 @@ abstract class OniSaga : @Volatile private var cachedState: LivewireState? = null - private val apiLock = Any() + private val apiLock = Mutex() @Volatile private var lastRequestTime = 0L @@ -456,41 +457,39 @@ abstract class OniSaga : val deferredChapters = langCodes.map { code -> async { - runCatching { - var currentSnapshot = state.snapshot - var prevChapterCount = 0 - var langChapters = emptyList() - - while (true) { - val request = ChapterLivewireRequest( - token = state.token, - components = listOf( - ChapterLivewireRequest.Component( - snapshot = currentSnapshot, - updates = ChapterUpdatesDto(language = code), - calls = listOf(LivewireCall(method = "loadMoreChapters")), - ), + var currentSnapshot = state.snapshot + var prevChapterCount = 0 + var langChapters = emptyList() + + while (true) { + val request = ChapterLivewireRequest( + token = state.token, + components = listOf( + ChapterLivewireRequest.Component( + snapshot = currentSnapshot, + updates = ChapterUpdatesDto(language = code), + calls = listOf(LivewireCall(method = "loadMoreChapters")), ), - ) + ), + ) - val dto = client.post( - "$baseUrl/livewire/update", - buildLivewireHeaders(doc.location()), - request.toJsonRequestBody(livewireJson), - ).parseAs() + val dto = client.post( + "$baseUrl/livewire/update", + buildLivewireHeaders(doc.location()), + request.toJsonRequestBody(livewireJson), + ).parseAs() - val html = dto.components.firstOrNull()?.effects?.html ?: break - val chapterDoc = Jsoup.parseBodyFragment(html, baseUrl) + val html = dto.components.firstOrNull()?.effects?.html ?: break + val chapterDoc = Jsoup.parseBodyFragment(html, baseUrl) - langChapters = parseChaptersFromDoc(chapterDoc, code, langCode == null) + langChapters = parseChaptersFromDoc(chapterDoc, code, langCode == null) - if (langChapters.size <= prevChapterCount) break + if (langChapters.size <= prevChapterCount) break - prevChapterCount = langChapters.size - currentSnapshot = dto.components.firstOrNull()?.snapshot ?: break - } - langChapters - }.getOrDefault(emptyList()) + prevChapterCount = langChapters.size + currentSnapshot = dto.components.firstOrNull()?.snapshot ?: break + } + langChapters } } @@ -638,14 +637,11 @@ abstract class OniSaga : while (attempt < 3) { attempt++ - synchronized(apiLock) { + apiLock.withLock { val rateLimitDelay = preferences.getString(PREF_RATE_LIMIT_KEY, "2000")?.toLongOrNull() ?: 2000L val now = System.currentTimeMillis() val waitTime = (rateLimitDelay - (now - lastRequestTime)).coerceAtLeast(0L) - - // Using rateLimit from keiyoushi.network.rateLimit was too aggressive, leading to 429 errors when it was fine after the rest of the images loaded - // Note: Error 429 lasts for approximately 15-30 minutes. Had to jump between many VPN servers to reach this conclusion - if (waitTime > 0) Thread.sleep(waitTime) + if (waitTime > 0) delay(waitTime) lastRequestTime = System.currentTimeMillis() } @@ -656,8 +652,9 @@ abstract class OniSaga : val rateLimitDelay = preferences.getString(PREF_RATE_LIMIT_KEY, "2000")?.toLongOrNull() ?: 2000L val retryAfter = response.header("retry-after")?.toLongOrNull()?.times(1000L) ?: rateLimitDelay response.close() - withContext(Dispatchers.IO) { - Thread.sleep(retryAfter) + apiLock.withLock { + delay(retryAfter) + lastRequestTime = System.currentTimeMillis() } continue } From 947f1109249a10038ad332616dc5ad345570f1b1 Mon Sep 17 00:00:00 2001 From: Alpha-782 Date: Fri, 17 Jul 2026 15:15:08 +0300 Subject: [PATCH 7/7] Note because it's important --- .../src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt b/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt index 5481fe98d58..7c90b55a7e1 100644 --- a/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt +++ b/src/all/onisaga/src/eu/kanade/tachiyomi/extension/all/onisaga/OniSaga.kt @@ -641,6 +641,8 @@ abstract class OniSaga : val rateLimitDelay = preferences.getString(PREF_RATE_LIMIT_KEY, "2000")?.toLongOrNull() ?: 2000L val now = System.currentTimeMillis() val waitTime = (rateLimitDelay - (now - lastRequestTime)).coerceAtLeast(0L) + // Using rateLimit from keiyoushi.network.rateLimit was too aggressive, leading to 429 errors when it was fine after the rest of the images loaded + // Note: Error 429 lasts for approximately 15-30 minutes. Had to jump between many VPN servers to reach this conclusion if (waitTime > 0) delay(waitTime) lastRequestTime = System.currentTimeMillis() }