From 06b340b025f2680289ea7cccd4c653e9ec8bdf89 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Mon, 6 Jul 2026 11:04:24 -0400 Subject: [PATCH 1/3] Fix(reader): load full Novelight chapter list instead of dropping pages Novelight's chapter-pagination endpoint clamps out-of-range pages to the last page's content instead of returning empty, so the paging loop ran to MAX_CHAPTER_PAGES and flooded the host, which then 429-rate-limited the real page fetches. Those failures were silently swallowed, persisting a chapter list with holes (e.g. jumping from ch. 102 to 403). - Terminate paging when a page adds no new chapters (the clamp) or is short, instead of relying on an empty page or the page cap. - Classify fetch results: retry 429/5xx/timeout with backoff, degrade on 403/redirect (premium), and abort rather than persist a middle-page hole. - Lower chapter-page concurrency and make jsonField null-safe. Adds NovelightSourceTest coverage for retry, clamp termination, gap-abort, and gated-degrade. --- .../data/repository/source/NovelightSource.kt | 187 ++++++++++++++---- .../repository/source/NovelightSourceTest.kt | 124 ++++++++++++ 2 files changed, 277 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/source/NovelightSource.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/source/NovelightSource.kt index 28dbfa3b..55f00087 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/source/NovelightSource.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/source/NovelightSource.kt @@ -3,14 +3,17 @@ package io.aatricks.easyreader.data.repository.source import io.aatricks.easyreader.data.local.PreferencesManager import io.aatricks.easyreader.data.model.ChapterInfo import io.aatricks.easyreader.data.model.ExploreItem +import io.aatricks.easyreader.util.HttpRetry import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import org.json.JSONObject import org.jsoup.Jsoup import org.jsoup.nodes.Document +import java.io.IOException import java.net.URLEncoder import javax.inject.Inject @@ -34,9 +37,13 @@ class NovelightSource @Inject constructor( override val version = "1.0.0" companion object { - private const val CHAPTERS_PER_PAGE = 50.0 + private const val CHAPTERS_PER_PAGE = 50 private const val MAX_CHAPTER_PAGES = 200 - private const val CHAPTER_PAGE_CONCURRENCY = 3 + private const val CHAPTER_PAGE_CONCURRENCY = 2 + private const val MAX_PAGE_RETRIES = 3 + private const val HTTP_FORBIDDEN = 403 + private const val HTTP_REDIRECT_MIN = 300 + private const val HTTP_REDIRECT_MAX = 399 private val BOOK_ID_REGEX = Regex("""BOOK_ID\s*=\s*["'](\d+)["']""") private val CHAPTER_TITLE_REGEX = Regex("""^\s*(\d+(?:\.\d+)?)\s*chapter\b\s*[-:–—]?\s*(.*)$""", RegexOption.IGNORE_CASE) @@ -161,60 +168,172 @@ class NovelightSource @Inject constructor( // --- Chapter list paging ----------------------------------------------------------------- - private suspend fun loadAllChapters(bookId: String): List = coroutineScope { - val firstPage = parseChapterPaginationHtml(fetchChapterPageHtml(bookId, 1)) - if (firstPage.isEmpty()) return@coroutineScope emptyList() + /** + * The pagination endpoint exposes no page count, and out-of-range pages *clamp* to the last + * page's content instead of returning empty. So we estimate the page count from page 1 (the + * newest, highest-numbered chapters, ~[CHAPTERS_PER_PAGE] per page), fetch that range with + * bounded concurrency, then extend until a page adds no new chapters (the clamp) or is short + * (the real last page) — never relying on an empty page or [MAX_CHAPTER_PAGES] to stop. That + * termination is what keeps a large book from firing ~[MAX_CHAPTER_PAGES] redundant requests + * and tripping the site's rate limiter. A page inside the known range that never recovers from a + * transient failure aborts the whole load rather than silently leaving a hole; a gated + * (403/redirect) page degrades. See [fetchChapterPage]. + */ + internal suspend fun loadAllChapters(bookId: String): List = + assembleChapters { page -> fetchChapterPage(bookId, page) } - // The pagination JSON exposes no page count, but page 1 holds the newest (highest-numbered) - // chapters, so the max chapter number estimates the page count. We parallel-fetch up to the - // estimate, then sequentially extend in case numbering is sparse and undercounts. - val maxNumber = firstPage.maxOfOrNull { it.number ?: 0.0 } ?: 0.0 - val estimatedPages = ((maxNumber / CHAPTERS_PER_PAGE).toInt() + 1).coerceIn(1, MAX_CHAPTER_PAGES) + /** Paging orchestration, decoupled from page-fetching so the termination/gap/degrade rules are unit-testable. */ + internal suspend fun assembleChapters(fetchPage: suspend (Int) -> PageOutcome): List { + val acc = ChapterAccumulator() + val firstChapters = (fetchPage(1) as? PageOutcome.Loaded)?.chapters + if (firstChapters.isNullOrEmpty()) return emptyList() + acc.add(firstChapters) - val all = ArrayList(firstPage) - if (estimatedPages > 1) { + val estimatedPages = estimatePageCount(firstChapters) + collectKnownRange(acc, estimatedPages, fetchPage) + extendBeyondEstimate(acc, estimatedPages + 1, fetchPage) + return acc.sortedByNumber() + } + + private fun estimatePageCount(firstChapters: List): Int { + // Page 1 holds the newest, highest-numbered chapters (~CHAPTERS_PER_PAGE per page), so the max + // number estimates the page count. It can undercount slightly (sub-chapters), hence the extend. + val maxNumber = firstChapters.maxOfOrNull { it.number ?: 0.0 } ?: 0.0 + return ((maxNumber / CHAPTERS_PER_PAGE).toInt() + 1).coerceIn(1, MAX_CHAPTER_PAGES) + } + + /** + * Fetch the known-dense range (pages 2..[estimatedPages]) in parallel. Each page retries its own + * transient failures; a gated (premium) page is skipped, but a page that never recovers aborts the + * whole load rather than silently leaving a hole in the middle of the chapter list. + */ + private suspend fun collectKnownRange( + acc: ChapterAccumulator, + estimatedPages: Int, + fetchPage: suspend (Int) -> PageOutcome + ) { + if (estimatedPages <= 1) return + coroutineScope { val semaphore = Semaphore(CHAPTER_PAGE_CONCURRENCY) - val rest = (2..estimatedPages).map { page -> - async { - semaphore.withPermit { - runCatching { parseChapterPaginationHtml(fetchChapterPageHtml(bookId, page)) } - .getOrDefault(emptyList()) - } + val outcomes = (2..estimatedPages).map { page -> + async { semaphore.withPermit { page to fetchPage(page) } } + }.awaitAll().sortedBy { it.first } + for ((page, outcome) in outcomes) { + when (outcome) { + is PageOutcome.Loaded -> acc.add(outcome.chapters) + PageOutcome.Gated -> Unit + PageOutcome.Failed -> + throw IOException("Novelight chapter page $page failed after retries") } - }.awaitAll().flatten() - all.addAll(rest) + } } + } - var page = estimatedPages + 1 - while (page <= MAX_CHAPTER_PAGES) { - val more = runCatching { parseChapterPaginationHtml(fetchChapterPageHtml(bookId, page)) } - .getOrDefault(emptyList()) - if (more.isEmpty()) break - all.addAll(more) + /** + * Extend past the (sometimes-undercounting) estimate, stopping at the real last page — detected by + * a short page or one that only repeats already-seen URLs (the endpoint's out-of-range clamp) — so + * a large book never fires ~[MAX_CHAPTER_PAGES] redundant requests. + */ + private suspend fun extendBeyondEstimate( + acc: ChapterAccumulator, + startPage: Int, + fetchPage: suspend (Int) -> PageOutcome + ) { + var page = startPage + var advancing = true + while (advancing && page <= MAX_CHAPTER_PAGES) { + val chapters = (fetchPage(page) as? PageOutcome.Loaded)?.chapters + val added = chapters?.let { acc.add(it) } ?: 0 + advancing = chapters != null && added > 0 && chapters.size >= CHAPTERS_PER_PAGE page++ } + } + + /** Accumulates chapters across pages, de-duping by URL (the clamp repeats a page's URLs). */ + private class ChapterAccumulator { + private val seen = HashSet() + private val all = ArrayList() + + fun add(chapters: List): Int { + var added = 0 + for (chapter in chapters) if (seen.add(chapter.url)) { + all.add(chapter) + added++ + } + return added + } - all.distinctBy { it.url }.sortedBy { it.number ?: Double.MAX_VALUE } + fun sortedByNumber(): List = all.sortedBy { it.number ?: Double.MAX_VALUE } } - private fun fetchChapterPageHtml(bookId: String, page: Int): String = - jsonField(ajaxGet("$baseUrl/book/ajax/chapter-pagination?book_id=$bookId&page=$page"), "html") + internal sealed interface PageOutcome { + data class Loaded(val chapters: List) : PageOutcome + object Gated : PageOutcome + object Failed : PageOutcome + } + + /** Fetch one chapter-pagination page, retrying transient failures (429/5xx/timeout) with backoff. */ + internal suspend fun fetchChapterPage(bookId: String, page: Int): PageOutcome { + val url = "$baseUrl/book/ajax/chapter-pagination?book_id=$bookId&page=$page" + var attempt = 0 + var outcome: PageOutcome? = null + while (outcome == null) { + outcome = when (val result = ajaxFetch(url)) { + is FetchResult.Body -> PageOutcome.Loaded(parseChapterPaginationHtml(jsonField(result.text, "html"))) + FetchResult.Gated -> PageOutcome.Gated + is FetchResult.Retryable -> + if (attempt >= MAX_PAGE_RETRIES) { + PageOutcome.Failed + } else { + delay(HttpRetry.nextRetryDelayMs(result.retryAfterMs, url, attempt)) + attempt++ + null + } + } + } + return outcome + } // --- HTTP -------------------------------------------------------------------------------- private fun jsonField(body: String, field: String): String = - runCatching { JSONObject(body).optString(field) }.getOrDefault("") + runCatching { JSONObject(body).optString(field) }.getOrNull().orEmpty() - private fun ajaxGet(url: String): String { + private sealed interface FetchResult { + data class Body(val text: String) : FetchResult + object Gated : FetchResult + data class Retryable(val retryAfterMs: Long?, val cause: Exception? = null) : FetchResult + } + + /** + * One raw XHR GET. 2xx → [FetchResult.Body]; 403 or a (non-followed) redirect → [FetchResult.Gated] + * (premium content); 408/429/5xx or a network error → [FetchResult.Retryable]; other 4xx → gated. + */ + private fun ajaxFetch(url: String): FetchResult { val request = okhttp3.Request.Builder() .url(url) .header("User-Agent", userAgent) .header("Referer", baseUrl) .header("X-Requested-With", "XMLHttpRequest") .build() - okHttpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) throw java.io.IOException("Unexpected code $response") - return response.body.string() + return try { + okHttpClient.newCall(request).execute().use { response -> + when { + response.isSuccessful -> FetchResult.Body(response.body.string()) + response.code == HTTP_FORBIDDEN || response.code in HTTP_REDIRECT_MIN..HTTP_REDIRECT_MAX -> + FetchResult.Gated + HttpRetry.shouldRetryResponseCode(response.code) -> + FetchResult.Retryable(HttpRetry.parseRetryAfterMs(response.header("Retry-After"))) + else -> FetchResult.Gated + } + } + } catch (e: IOException) { + FetchResult.Retryable(retryAfterMs = null, cause = e) } } + + private fun ajaxGet(url: String): String = when (val result = ajaxFetch(url)) { + is FetchResult.Body -> result.text + else -> throw IOException("Unexpected response for $url") + } } diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/source/NovelightSourceTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/source/NovelightSourceTest.kt index b6b664d6..2599bd77 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/source/NovelightSourceTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/source/NovelightSourceTest.kt @@ -1,13 +1,27 @@ package io.aatricks.easyreader.data.repository.source import io.aatricks.easyreader.data.local.PreferencesManager +import io.aatricks.easyreader.data.model.ChapterInfo +import kotlinx.coroutines.runBlocking +import okhttp3.Call +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody import org.jsoup.Jsoup import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.IOException class NovelightSourceTest { @@ -108,4 +122,114 @@ class NovelightSourceTest { assertEquals("https://novelight.net", source.baseUrl) assertTrue(source.baseUrl.startsWith("https://")) } + + // --- Chapter paging orchestration (assembleChapters) ------------------------------------- + // + // Regression cover for the "jumps from ch. 102 to 403" bug: novelight's pagination clamps + // out-of-range pages to the last page's content instead of returning empty, and failed pages + // used to be silently dropped, persisting a chapter list with holes. + + private fun loadedPage(numbers: IntRange): NovelightSource.PageOutcome.Loaded = + NovelightSource.PageOutcome.Loaded( + numbers.map { ChapterInfo(title = "Chapter $it", url = "/book/chapter/$it", number = it.toDouble()) } + ) + + // Newest page is 1 (131..180) down to a short last page 4 (1..30); pages beyond 4 clamp to page + // 4's content, exactly like novelight's real endpoint. + private val bookPages = mapOf(1 to 131..180, 2 to 81..130, 3 to 31..80, 4 to 1..30) + private fun bookPageOrClamp(page: Int) = loadedPage(bookPages[page] ?: bookPages.getValue(4)) + + @Test + fun `assembleChapters collects every page with no gap and without flooding`() = runBlocking { + var calls = 0 + val chapters = source.assembleChapters { page -> + calls++ + bookPageOrClamp(page) + } + assertEquals((1..180).toList(), chapters.mapNotNull { it.number?.toInt() }) + assertTrue("should stop at the real last page, not flood; was $calls calls", calls < 10) + } + + @Test + fun `assembleChapters stops at the clamped out-of-range page instead of looping to the cap`() = runBlocking { + // All four pages are full (50 each), so the only stop signal is the clamp repeating page 4. + val fullBook = mapOf(1 to 151..200, 2 to 101..150, 3 to 51..100, 4 to 1..50) + var calls = 0 + val chapters = source.assembleChapters { page -> + calls++ + loadedPage(fullBook[page] ?: fullBook.getValue(4)) + } + assertEquals(200, chapters.size) + assertEquals((1..200).toList(), chapters.mapNotNull { it.number?.toInt() }) + assertTrue("should detect the clamp and stop; was $calls calls", calls < 12) + } + + @Test + fun `assembleChapters aborts rather than persist a hole when a known page never recovers`() { + assertThrows(IOException::class.java) { + runBlocking { + source.assembleChapters { page -> + if (page == 3) NovelightSource.PageOutcome.Failed else bookPageOrClamp(page) + } + } + } + } + + @Test + fun `assembleChapters degrades past a gated page without failing the load`() = runBlocking { + val chapters = source.assembleChapters { page -> + if (page == 2) NovelightSource.PageOutcome.Gated else bookPageOrClamp(page) + } + val numbers = chapters.mapNotNull { it.number?.toInt() } + assertEquals(130, chapters.size) // page 2 (81..130) skipped, the rest still load + assertTrue("gated page's chapters are dropped, not backfilled", numbers.none { it in 81..130 }) + } + + // --- Page fetch retry / status classification (fetchChapterPage) ------------------------- + + private fun httpResponse(code: Int): Response = Response.Builder() + .request(Request.Builder().url("https://novelight.net/book/ajax/chapter-pagination?book_id=1&page=1").build()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("msg") + .body("".toResponseBody("application/json".toMediaType())) + .build() + + private fun sourceWith(client: OkHttpClient) = NovelightSource(mock(), client) + + @Test + fun `fetchChapterPage retries a transient 429 then succeeds`() { + val call = mock() + val client = mock { whenever(it.newCall(any())).thenReturn(call) } + whenever(call.execute()).thenReturn(httpResponse(429), httpResponse(200)) + + val outcome = runBlocking { sourceWith(client).fetchChapterPage("1", 5) } + + assertTrue(outcome is NovelightSource.PageOutcome.Loaded) + verify(call, times(2)).execute() + } + + @Test + fun `fetchChapterPage gives up after exhausting retries on persistent 429`() { + val call = mock() + val client = mock { whenever(it.newCall(any())).thenReturn(call) } + whenever(call.execute()).thenAnswer { httpResponse(429) } + + val outcome = runBlocking { sourceWith(client).fetchChapterPage("1", 5) } + + assertTrue(outcome is NovelightSource.PageOutcome.Failed) + verify(call, times(4)).execute() // 1 initial attempt + 3 retries + } + + @Test + fun `fetchChapterPage treats a 403 as gated without retrying`() { + val call = mock() + val client = mock { whenever(it.newCall(any())).thenReturn(call) } + whenever(call.execute()).thenReturn(httpResponse(403)) + + val outcome = runBlocking { sourceWith(client).fetchChapterPage("1", 5) } + + assertTrue(outcome is NovelightSource.PageOutcome.Gated) + verify(call, times(1)).execute() + } } From b411c6f5ab1f87148c277adfd16b6558675125d3 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Mon, 6 Jul 2026 11:25:58 -0400 Subject: [PATCH 2/3] Fix(reader): refetch cached chapter lists after a parsing-version bump The chapter list is cached per (novel, source) for 6h, so the Novelight paging fix stayed invisible on already-added entries until the cache expired, and nothing (re-add or delete) invalidated it sooner. Add a version field to the cache entry; entries written before the current version are treated as stale, forcing a one-time refetch that heals every existing install. --- .../data/repository/ChapterListCache.kt | 13 +++++++--- .../data/repository/ChapterListCacheTest.kt | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/ChapterListCache.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/ChapterListCache.kt index c7015c8d..7985eec8 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/ChapterListCache.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/ChapterListCache.kt @@ -22,6 +22,11 @@ class ChapterListCache @Inject constructor( ) { companion object { const val FRESH_WINDOW_MS = 6L * 60 * 60 * 1000 // 6 hours + + // Bump when a source's chapter-list parsing changes in a way that should invalidate every + // already-cached list. Entries written before versioning default to 0, so a bump forces a + // one-time refetch on next open (e.g. the Novelight paging fix that recovers dropped pages). + const val CACHE_VERSION = 1 } @Serializable @@ -29,7 +34,8 @@ class ChapterListCache @Inject constructor( val chapters: List, val fetchedAt: Long, val baseNovelUrl: String, - val sourceName: String + val sourceName: String, + val version: Int = 0 ) fun load(baseNovelUrl: String, sourceName: String): Entry? { @@ -50,7 +56,8 @@ class ChapterListCache @Inject constructor( chapters = chapters, fetchedAt = System.currentTimeMillis(), baseNovelUrl = baseNovelUrl, - sourceName = sourceName + sourceName = sourceName, + version = CACHE_VERSION ) val temp = File.createTempFile("${file.name}.", ".tmp", file.parentFile) try { @@ -75,7 +82,7 @@ class ChapterListCache @Inject constructor( } fun isFresh(entry: Entry): Boolean = - (System.currentTimeMillis() - entry.fetchedAt) < FRESH_WINDOW_MS + entry.version == CACHE_VERSION && (System.currentTimeMillis() - entry.fetchedAt) < FRESH_WINDOW_MS private fun fileFor(baseNovelUrl: String, sourceName: String): File = File(cacheDir, "${CacheKeyUtils.keyFor("$sourceName|$baseNovelUrl")}.json") diff --git a/app/src/test/java/io/aatricks/easyreader/data/repository/ChapterListCacheTest.kt b/app/src/test/java/io/aatricks/easyreader/data/repository/ChapterListCacheTest.kt index e4875508..951c11c0 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/repository/ChapterListCacheTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/repository/ChapterListCacheTest.kt @@ -4,7 +4,9 @@ import io.aatricks.easyreader.data.model.ChapterInfo import kotlinx.serialization.json.Json import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.io.File @@ -63,4 +65,26 @@ class ChapterListCacheTest { assertNotNull(loaded2) assertEquals(chapters2, loaded2?.chapters) } + + @Test + fun `entry cached before versioning is not fresh so it refetches once`() { + // Old entries (written before the version field existed) deserialize with version 0. + val legacy = ChapterListCache.Entry( + chapters = listOf(ChapterInfo("Ch 1", "url1")), + fetchedAt = System.currentTimeMillis(), + baseNovelUrl = "novel1", + sourceName = "Source1", + version = 0 + ) + assertFalse("a stale-version entry must not be treated as fresh", cache.isFresh(legacy)) + } + + @Test + fun `freshly saved entry is current version and fresh`() { + cache.save("novel1", "Source1", listOf(ChapterInfo("Ch 1", "url1"))) + val loaded = cache.load("novel1", "Source1") + assertNotNull(loaded) + assertEquals(ChapterListCache.CACHE_VERSION, loaded?.version) + assertTrue(cache.isFresh(loaded!!)) + } } From 04ad6542cba43e1d76526d0c0d5da30068fa8b22 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Mon, 6 Jul 2026 11:25:58 -0400 Subject: [PATCH 3/3] Fix(reader): label chapters from the source list, not the reading URL Novelight reading URLs are /book/chapter/{id} where the id is not the chapter number, so the reader's URL-derived label showed the id (e.g. "Chapter 141313") while the chapter list correctly showed "Chapter 102". Prefer the chapter list's label when it carries a number that differs from the current one, a no-op for sources whose URL already encodes the number. --- .../ui/viewmodel/ReaderViewModel.kt | 19 +++++-- .../easyreader/util/ChapterListNormalizer.kt | 21 ++++++++ .../util/ChapterListNormalizerTest.kt | 49 +++++++++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt index b943162e..3592360a 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt @@ -13,6 +13,7 @@ import io.aatricks.easyreader.data.repository.ImageDimensionCacheRepository import io.aatricks.easyreader.data.repository.LibraryRepository import io.aatricks.easyreader.ui.theme.AccentTheme import io.aatricks.easyreader.util.normalizeChapterList +import io.aatricks.easyreader.util.resolveChapterLabelFromList import io.aatricks.easyreader.util.TextUtils import io.aatricks.easyreader.util.UrlSecurity import io.aatricks.easyreader.ui.viewmodel.ReaderProgressController.Companion.PAGED_POSITION_ITEM_SIZE_PX @@ -603,6 +604,11 @@ class ReaderViewModel @Inject constructor( } } + // Prefer the chapter list's label when the content/URL-derived one carries the wrong number + // (Novelight reading URLs are /book/chapter/{id}, so the id leaks in as the chapter number). + val resolvedChapterTitle = + resolveChapterLabelFromList(content.url, chapterTitle, currentFullList) ?: chapterTitle + updateState { it.copy( content = content, @@ -621,7 +627,7 @@ class ReaderViewModel @Inject constructor( targetScrollPosition = initialPosition.targetScrollPosition, hasReachedQuarterScreen = fromBottom || initialPosition.scrollProgress >= 25, novelName = novelName, - chapterTitle = chapterTitle, + chapterTitle = resolvedChapterTitle, baseTitle = baseTitle, baseNovelUrl = libraryItem?.baseNovelUrl ?: "", sourceName = libraryItem?.sourceName ?: "", @@ -1354,11 +1360,16 @@ class ReaderViewModel @Inject constructor( } private suspend fun applyFullChapterList(normalizedChapters: List) { - updateState { - it.copy( + updateState { state -> + // Now that the authoritative list is loaded, correct the header label if the current + // chapter's URL-derived number was wrong (e.g. Novelight's opaque /book/chapter/{id}). + val resolvedTitle = state.content?.url + ?.let { resolveChapterLabelFromList(it, state.chapterTitle, normalizedChapters) } + state.copy( fullChapterList = normalizedChapters, isChaptersLoading = false, - isFullChapterListLoaded = true + isFullChapterListLoaded = true, + chapterTitle = resolvedTitle ?: state.chapterTitle ) } updateNavigationUrls() diff --git a/app/src/main/java/io/aatricks/easyreader/util/ChapterListNormalizer.kt b/app/src/main/java/io/aatricks/easyreader/util/ChapterListNormalizer.kt index b01c93aa..0d4792d4 100644 --- a/app/src/main/java/io/aatricks/easyreader/util/ChapterListNormalizer.kt +++ b/app/src/main/java/io/aatricks/easyreader/util/ChapterListNormalizer.kt @@ -20,6 +20,27 @@ internal fun normalizeChapterList(chapters: List): List +): String? { + val target = url.trim() + val match = if (target.isBlank()) null else chapters.firstOrNull { it.url == target } + val listNumber = match?.let { it.number ?: TextUtils.extractChapterNumber(it.title) } + val differs = listNumber != null && TextUtils.extractChapterNumber(currentLabel) != listNumber + return match?.title?.trim()?.takeIf { it.isNotBlank() && differs } +} + internal fun normalizeExploreItemDetails(item: ExploreItem): ExploreItem { val normalizedChapters = normalizeChapterList(item.chapters) val normalizedReadingUrl = item.readingUrl?.trim().takeUnless { it.isNullOrBlank() } diff --git a/app/src/test/java/io/aatricks/easyreader/util/ChapterListNormalizerTest.kt b/app/src/test/java/io/aatricks/easyreader/util/ChapterListNormalizerTest.kt index 1415578b..00a84d30 100644 --- a/app/src/test/java/io/aatricks/easyreader/util/ChapterListNormalizerTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/util/ChapterListNormalizerTest.kt @@ -69,4 +69,53 @@ class ChapterListNormalizerTest { assertTrue(normalized.chapters.isEmpty()) assertNull(normalized.readingUrl) } + + // Novelight reading URLs are /book/chapter/{id}; the id is not the chapter number, so a + // URL-derived label ("Chapter 141313") must be corrected to the chapter list's real label. + private val novelightList = listOf( + ChapterInfo(title = "Chapter 102 - The Duel", url = "https://novelight.net/book/chapter/141313", number = 102.0), + ChapterInfo(title = "Chapter 103 - Aftermath", url = "https://novelight.net/book/chapter/141320", number = 103.0) + ) + + @Test + fun `resolveChapterLabelFromList corrects a wrong url-derived number`() { + val resolved = resolveChapterLabelFromList( + url = "https://novelight.net/book/chapter/141313", + currentLabel = "Chapter 141313", + chapters = novelightList + ) + assertEquals("Chapter 102 - The Duel", resolved) + } + + @Test + fun `resolveChapterLabelFromList is a no-op when the label already has the right number`() { + // Sources whose URL encodes the real number already show it — don't override their label. + val resolved = resolveChapterLabelFromList( + url = "https://novelight.net/book/chapter/141313", + currentLabel = "Chapter 102: The Duel (translator note)", + chapters = novelightList + ) + assertNull(resolved) + } + + @Test + fun `resolveChapterLabelFromList returns null when the url is not in the list`() { + val resolved = resolveChapterLabelFromList( + url = "https://novelight.net/book/chapter/999999", + currentLabel = "Chapter 999999", + chapters = novelightList + ) + assertNull(resolved) + } + + @Test + fun `resolveChapterLabelFromList returns null when the list entry has no number`() { + val list = listOf(ChapterInfo(title = "Epilogue", url = "https://x/c/1", number = null)) + val resolved = resolveChapterLabelFromList( + url = "https://x/c/1", + currentLabel = "Chapter 1", + chapters = list + ) + assertNull(resolved) + } }