From c15d280cb45fb750e8539a6367be7ae02c2a5f8b Mon Sep 17 00:00:00 2001 From: littlecodedragon <253116839+littlecodedragon@users.noreply.github.com> Date: Mon, 11 May 2026 02:59:22 +0200 Subject: [PATCH] feat(eta): cross-manga page-rate priors for reading time left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blend series-local ms/page with samples from completed chapters in other titles (similar page counts via ±15%/±30% bands). Removes fixed ms/page floors; adds SQL query + estimator + DI wiring. Co-authored-by: Cursor --- .../java/eu/kanade/domain/DomainModule.kt | 2 + .../tachiyomi/ui/reader/ReaderViewModel.kt | 32 +++++-- .../tachiyomi/data/history/HistoryMapper.kt | 13 +++ .../data/history/HistoryRepositoryImpl.kt | 20 ++++ .../main/sqldelight/tachiyomi/data/history.sq | 17 ++++ .../GetCrossMangaPageRateHistorySamples.kt | 19 ++++ .../history/repository/HistoryRepository.kt | 9 ++ .../domain/reading/ReadingEtaDefaults.kt | 32 +++++-- .../domain/reading/ReadingEtaHistorySample.kt | 12 +++ .../reading/ReadingEtaPageRateEstimator.kt | 93 +++++++++++++++++++ 10 files changed, 235 insertions(+), 14 deletions(-) create mode 100644 domain/src/main/java/tachiyomi/domain/history/interactor/GetCrossMangaPageRateHistorySamples.kt create mode 100644 domain/src/main/java/tachiyomi/domain/reading/ReadingEtaHistorySample.kt create mode 100644 domain/src/main/java/tachiyomi/domain/reading/ReadingEtaPageRateEstimator.kt diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 6e0581230..9b07f98d9 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -65,6 +65,7 @@ import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags import tachiyomi.domain.chapter.interactor.ShouldUpdateDbChapter import tachiyomi.domain.chapter.interactor.UpdateChapter import tachiyomi.domain.chapter.repository.ChapterRepository +import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples import tachiyomi.domain.history.interactor.GetHistory import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds @@ -176,6 +177,7 @@ class DomainModule : InjektModule { addFactory { RemoveHistory(get()) } addFactory { GetTotalReadDuration(get()) } addFactory { GetReadDurationEntriesByMangaIds(get()) } + addFactory { GetCrossMangaPageRateHistorySamples(get()) } addFactory { DeleteDownload(get(), get()) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 23684c3e2..02a0cfc75 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -92,11 +92,14 @@ import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.ChapterUpdate import tachiyomi.domain.chapter.service.getChapterSort import tachiyomi.domain.download.service.DownloadPreferences +import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples import tachiyomi.domain.history.interactor.GetHistory import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.UpsertHistory import tachiyomi.domain.history.model.HistoryUpdate import tachiyomi.domain.library.service.LibraryPreferences +import tachiyomi.domain.reading.ReadingEtaHistorySample +import tachiyomi.domain.reading.ReadingEtaPageRateEstimator import tachiyomi.domain.reading.ReadingEtaDefaults import tachiyomi.domain.manga.interactor.GetFlatMetadataById import tachiyomi.domain.manga.interactor.GetManga @@ -128,6 +131,7 @@ class ReaderViewModel @JvmOverloads constructor( private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(), private val getNextChapters: GetNextChapters = Injekt.get(), private val getHistory: GetHistory = Injekt.get(), + private val getCrossMangaPageRateHistorySamples: GetCrossMangaPageRateHistorySamples = Injekt.get(), private val upsertHistory: UpsertHistory = Injekt.get(), private val updateChapter: UpdateChapter = Injekt.get(), private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(), @@ -255,6 +259,12 @@ class ReaderViewModel @JvmOverloads constructor( private val chapterReadPagesSession = mutableMapOf() private val chapterReadPagesPersistent = mutableMapOf() + /** + * Recent completed-chapter rows from manga other than the open title, used to infer ms/page + * when the current series has few local samples ([ReadingEtaPageRateEstimator]). + */ + private var crossMangaPageRateSamples: List = emptyList() + private var orderedSeriesChapterIds = emptyList() private var seriesChapterIndexById = emptyMap() @@ -974,6 +984,7 @@ class ReaderViewModel @JvmOverloads constructor( getHistory.await(manga.id) .associate { history -> history.chapterId to history.readPages }, ) + crossMangaPageRateSamples = getCrossMangaPageRateHistorySamples.await(manga.id) updateSeriesProgressState() } @@ -1125,7 +1136,7 @@ class ReaderViewModel @JvmOverloads constructor( currentPage: Int, remainingChapters: Int, ): Long? { - val pageRates = orderedSeriesChapterIds + val seriesPageRatesMsPerPage = orderedSeriesChapterIds .take(currentChapterIndex + 1) .mapNotNull { chapterId -> val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration() ?: return@mapNotNull null @@ -1134,11 +1145,6 @@ class ReaderViewModel @JvmOverloads constructor( duration.toDouble() / pages.toDouble() } .takeLast(ETA_SAMPLE_CHAPTER_LIMIT) - if (pageRates.isEmpty()) return null - var avgMsPerPage = pageRates.average().toLong().coerceAtLeast(1L) - if (pageRates.size < ReadingEtaDefaults.MIN_TRUSTED_PAGE_RATE_SAMPLES) { - avgMsPerPage = maxOf(avgMsPerPage, ReadingEtaDefaults.FALLBACK_MS_PER_PAGE) - } val sampledPages = orderedSeriesChapterIds .take(currentChapterIndex + 1) @@ -1147,6 +1153,20 @@ class ReaderViewModel @JvmOverloads constructor( if (sampledPages.isEmpty()) return null val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L) + if (seriesPageRatesMsPerPage.isEmpty() && crossMangaPageRateSamples.isEmpty()) return null + + val targetChapterPages = if (totalPages > 0) { + totalPages + } else { + avgPagesPerChapter.toInt().coerceAtLeast(1) + } + + val avgMsPerPage = ReadingEtaPageRateEstimator.estimateBlendedMsPerPage( + targetChapterPages = targetChapterPages, + seriesPageRatesMsPerPage = seriesPageRatesMsPerPage, + crossMangaSamples = crossMangaPageRateSamples, + ) ?: return null + val currentRemainingPages = if (isCurrentChapterRead) { 0L } else if (totalPages > 0 && currentPage > 0) { diff --git a/data/src/main/java/tachiyomi/data/history/HistoryMapper.kt b/data/src/main/java/tachiyomi/data/history/HistoryMapper.kt index cd95b31e2..06376daed 100644 --- a/data/src/main/java/tachiyomi/data/history/HistoryMapper.kt +++ b/data/src/main/java/tachiyomi/data/history/HistoryMapper.kt @@ -3,9 +3,22 @@ package tachiyomi.data.history import tachiyomi.domain.history.model.History import tachiyomi.domain.history.model.HistoryWithRelations import tachiyomi.domain.manga.model.MangaCover +import tachiyomi.domain.reading.ReadingEtaHistorySample import java.util.Date object HistoryMapper { + fun mapReadingEtaHistorySample( + readDuration: Long, + readPages: Long, + lastPageRead: Long, + chapterRead: Boolean, + ): ReadingEtaHistorySample = ReadingEtaHistorySample( + readDurationMs = readDuration, + pagesRead = readPages, + chapterFullyRead = chapterRead, + lastPageReadIndex = lastPageRead, + ) + fun mapHistory( id: Long, chapterId: Long, diff --git a/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt index e055a0100..e99e4219e 100644 --- a/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt @@ -10,6 +10,7 @@ import tachiyomi.domain.history.model.HistoryUpdate import tachiyomi.domain.history.model.HistoryWithRelations import tachiyomi.domain.history.repository.HistoryRepository import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.reading.ReadingEtaHistorySample class HistoryRepositoryImpl( private val handler: DatabaseHandler, @@ -56,6 +57,25 @@ class HistoryRepositoryImpl( return handler.awaitList { historyQueries.getHistoryByMangaId(mangaId, HistoryMapper::mapHistory) } } + override suspend fun getCrossMangaPageRateHistorySamples( + excludeMangaId: Long, + minDurationMs: Long, + maxDurationMs: Long, + maxPagesRead: Long, + limit: Long, + ): List { + return handler.awaitList { + historyQueries.getCrossMangaPageRateHistorySamples( + excludeMangaId, + minDurationMs, + maxDurationMs, + maxPagesRead, + limit, + HistoryMapper::mapReadingEtaHistorySample, + ) + } + } + // KMK --> override suspend fun resetHistory(historyIds: List) { try { diff --git a/data/src/main/sqldelight/tachiyomi/data/history.sq b/data/src/main/sqldelight/tachiyomi/data/history.sq index f50ff2d05..8482eece7 100644 --- a/data/src/main/sqldelight/tachiyomi/data/history.sq +++ b/data/src/main/sqldelight/tachiyomi/data/history.sq @@ -82,3 +82,20 @@ WHERE chapter_id = :chapterId; getReadDuration: SELECT coalesce(sum(time_read), 0) FROM history; + +-- Recent completed-chapter history from other series, for data-driven ms/page ETA priors. +getCrossMangaPageRateHistorySamples: +SELECT + H.time_read, + H.pages_read, + C.last_page_read, + C.read +FROM history H +JOIN chapters C ON H.chapter_id = C._id +WHERE C.manga_id != :excludeMangaId + AND H.time_read >= :minDurationMs + AND H.time_read <= :maxDurationMs + AND H.pages_read >= 1 + AND H.pages_read <= :maxPagesRead +ORDER BY H.last_read DESC +LIMIT :limit; diff --git a/domain/src/main/java/tachiyomi/domain/history/interactor/GetCrossMangaPageRateHistorySamples.kt b/domain/src/main/java/tachiyomi/domain/history/interactor/GetCrossMangaPageRateHistorySamples.kt new file mode 100644 index 000000000..8d3967cfc --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/history/interactor/GetCrossMangaPageRateHistorySamples.kt @@ -0,0 +1,19 @@ +package tachiyomi.domain.history.interactor + +import tachiyomi.domain.history.repository.HistoryRepository +import tachiyomi.domain.reading.ReadingEtaDefaults +import tachiyomi.domain.reading.ReadingEtaHistorySample + +class GetCrossMangaPageRateHistorySamples( + private val repository: HistoryRepository, +) { + suspend fun await(excludeMangaId: Long): List { + return repository.getCrossMangaPageRateHistorySamples( + excludeMangaId = excludeMangaId, + minDurationMs = ReadingEtaDefaults.MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE, + maxDurationMs = ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE, + maxPagesRead = ReadingEtaDefaults.MAX_PAGES_READ_FOR_PAGE_RATE, + limit = ReadingEtaDefaults.CROSS_MANGA_PAGE_RATE_SAMPLE_LIMIT, + ) + } +} diff --git a/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt b/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt index cdf82be30..3d1522dc2 100644 --- a/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt +++ b/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow import tachiyomi.domain.history.model.History import tachiyomi.domain.history.model.HistoryUpdate import tachiyomi.domain.history.model.HistoryWithRelations +import tachiyomi.domain.reading.ReadingEtaHistorySample interface HistoryRepository { @@ -22,6 +23,14 @@ interface HistoryRepository { suspend fun getHistoryByMangaId(mangaId: Long): List + suspend fun getCrossMangaPageRateHistorySamples( + excludeMangaId: Long, + minDurationMs: Long, + maxDurationMs: Long, + maxPagesRead: Long, + limit: Long, + ): List + // KMK --> suspend fun resetHistory(historyIds: List) diff --git a/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaDefaults.kt b/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaDefaults.kt index 8e0bf7c47..f3a5617be 100644 --- a/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaDefaults.kt +++ b/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaDefaults.kt @@ -2,8 +2,12 @@ package tachiyomi.domain.reading /** * Shared defaults for "time left" / reading-speed estimates in the library and reader. - * These cap idle-inflated history and avoid ultra-optimistic page rates from skimming - * the first few pages. + * + * **Page-rate path (reader series ETA):** ms/page blends recent chapters in the **current** + * series with a **prior** from **other** manga: completed chapters whose page count + * (last page index + 1) falls in a band around the target length. That replaces a fixed + * minimum ms/page when local samples are thin. Session-based projection and the stats cap + * below still apply. */ object ReadingEtaDefaults { @@ -13,11 +17,23 @@ object ReadingEtaDefaults { */ const val MAX_CHAPTER_DURATION_MS_FOR_STATS: Long = 45 * 60 * 1000L - /** - * If we have few per-chapter page-rate samples, do not go below this ms/page - * (avoids "2 minutes for the whole series" after a quick flip through ch.1). - */ - const val FALLBACK_MS_PER_PAGE: Long = 12_000L + /** Same lower bound as reader tracking: ignore ultra-short noise. */ + const val MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = 2_000L - const val MIN_TRUSTED_PAGE_RATE_SAMPLES: Int = 3 + /** Ignore pathological totals when mining history for page-rate priors. */ + const val MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = 4L * 60 * 60 * 1000L + + const val MIN_PAGES_READ_FOR_PAGE_RATE: Long = 1L + + /** Match reader sanitization so cross-manga rows stay comparable. */ + const val MAX_PAGES_READ_FOR_PAGE_RATE: Long = 2_000L + + /** Recent history rows scanned for cross-series priors (newest first). */ + const val CROSS_MANGA_PAGE_RATE_SAMPLE_LIMIT: Long = 500L + + /** “Similar chapter” length: ± this fraction of target page count (inner band). */ + const val SIMILAR_CHAPTER_PAGE_COUNT_RATIO: Double = 0.15 + + /** Widen the page-count band if the inner band has no usable samples. */ + const val SIMILAR_CHAPTER_PAGE_COUNT_RATIO_WIDENED: Double = 0.30 } diff --git a/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaHistorySample.kt b/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaHistorySample.kt new file mode 100644 index 000000000..5fe8c7061 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaHistorySample.kt @@ -0,0 +1,12 @@ +package tachiyomi.domain.reading + +/** + * One row of reading history joined with chapter progress, used to infer ms/page from + * [readDurationMs] / [pagesRead] when chapter length is approximated from [lastPageReadIndex]. + */ +data class ReadingEtaHistorySample( + val readDurationMs: Long, + val pagesRead: Long, + val chapterFullyRead: Boolean, + val lastPageReadIndex: Long, +) diff --git a/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaPageRateEstimator.kt b/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaPageRateEstimator.kt new file mode 100644 index 000000000..ba1abda21 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/reading/ReadingEtaPageRateEstimator.kt @@ -0,0 +1,93 @@ +package tachiyomi.domain.reading + +/** + * Blends per-series page rates with a **prior** from other manga: completed chapters whose + * length (last page index + 1) is close to the target page count. Replaces a fixed slow + * “minimum ms/page” floor when the current series has little local data. + */ +object ReadingEtaPageRateEstimator { + + /** + * @param targetChapterPages current chapter length, or a recent average when unknown + * @param seriesPageRatesMsPerPage ms/page from already-read chapters in this series (same order as today) + */ + fun estimateBlendedMsPerPage( + targetChapterPages: Int, + seriesPageRatesMsPerPage: List, + crossMangaSamples: List, + ): Long? { + val n = seriesPageRatesMsPerPage.size + val seriesAvg = if (n > 0) seriesPageRatesMsPerPage.average() else null + val prior = estimatePriorMsPerPageFromCrossManga(targetChapterPages, crossMangaSamples) + + return when { + n >= 3 && seriesAvg != null -> seriesAvg.toLong().coerceAtLeast(1L) + n == 0 -> prior?.coerceAtLeast(1L) + seriesAvg == null -> prior?.coerceAtLeast(1L) + prior == null -> seriesAvg.toLong().coerceAtLeast(1L) + else -> { + val blended = (seriesAvg * n + prior * (3 - n)) / 3.0 + blended.toLong().coerceAtLeast(1L) + } + } + } + + private fun estimatePriorMsPerPageFromCrossManga( + targetChapterPages: Int, + samples: List, + ): Long? { + if (targetChapterPages <= 0) return null + val ratesWithSize = samples.mapNotNull { toRateWithChapterPages(it) } + if (ratesWithSize.isEmpty()) return null + + val inner = ratesWithinPageBand(ratesWithSize, targetChapterPages, ReadingEtaDefaults.SIMILAR_CHAPTER_PAGE_COUNT_RATIO) + medianMsPerPage(inner)?.let { return it } + + val wide = ratesWithinPageBand(ratesWithSize, targetChapterPages, ReadingEtaDefaults.SIMILAR_CHAPTER_PAGE_COUNT_RATIO_WIDENED) + medianMsPerPage(wide)?.let { return it } + + return medianMsPerPage(ratesWithSize.map { it.first }) + } + + private fun toRateWithChapterPages(sample: ReadingEtaHistorySample): Pair? { + if (!sample.chapterFullyRead) return null + val chapterPages = (sample.lastPageReadIndex + 1L).toInt().coerceAtLeast(1) + if (sample.pagesRead < ReadingEtaDefaults.MIN_PAGES_READ_FOR_PAGE_RATE) return null + if (sample.pagesRead > ReadingEtaDefaults.MAX_PAGES_READ_FOR_PAGE_RATE) return null + if (sample.readDurationMs < ReadingEtaDefaults.MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE) return null + if (sample.readDurationMs > ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE) return null + if (sample.pagesRead > chapterPages * 2L) return null + + val capped = sample.readDurationMs.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) + val msPerPage = capped.toDouble() / sample.pagesRead.toDouble() + if (!msPerPage.isFinite() || msPerPage <= 0.0) return null + return msPerPage to chapterPages + } + + private fun ratesWithinPageBand( + ratesWithSize: List>, + targetChapterPages: Int, + bandRatio: Double, + ): List { + val target = targetChapterPages.toDouble().coerceAtLeast(1.0) + val low = target * (1.0 - bandRatio) + val high = target * (1.0 + bandRatio) + return ratesWithSize.mapNotNull { (rate, pages) -> + val p = pages.toDouble() + if (p in low..high) rate else null + } + } + + private fun medianMsPerPage(values: List): Long? { + if (values.isEmpty()) return null + val sorted = values.sorted() + val mid = sorted.size / 2 + val med = if (sorted.size % 2 == 1) { + sorted[mid] + } else { + (sorted[mid - 1] + sorted[mid]) / 2.0 + } + if (!med.isFinite() || med <= 0.0) return null + return med.toLong().coerceAtLeast(1L) + } +}