feat(eta): cross-manga page-rate priors for reading time left
All checks were successful
Forgejo Release Builder / release (push) Successful in 5m53s

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 <cursoragent@cursor.com>
This commit is contained in:
littlecodedragon 2026-05-11 02:59:22 +02:00
parent 07019175d1
commit c15d280cb4
10 changed files with 235 additions and 14 deletions

View file

@ -65,6 +65,7 @@ import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags
import tachiyomi.domain.chapter.interactor.ShouldUpdateDbChapter import tachiyomi.domain.chapter.interactor.ShouldUpdateDbChapter
import tachiyomi.domain.chapter.interactor.UpdateChapter import tachiyomi.domain.chapter.interactor.UpdateChapter
import tachiyomi.domain.chapter.repository.ChapterRepository import tachiyomi.domain.chapter.repository.ChapterRepository
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
import tachiyomi.domain.history.interactor.GetHistory import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.GetNextChapters
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
@ -176,6 +177,7 @@ class DomainModule : InjektModule {
addFactory { RemoveHistory(get()) } addFactory { RemoveHistory(get()) }
addFactory { GetTotalReadDuration(get()) } addFactory { GetTotalReadDuration(get()) }
addFactory { GetReadDurationEntriesByMangaIds(get()) } addFactory { GetReadDurationEntriesByMangaIds(get()) }
addFactory { GetCrossMangaPageRateHistorySamples(get()) }
addFactory { DeleteDownload(get(), get()) } addFactory { DeleteDownload(get(), get()) }

View file

@ -92,11 +92,14 @@ import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.chapter.model.ChapterUpdate import tachiyomi.domain.chapter.model.ChapterUpdate
import tachiyomi.domain.chapter.service.getChapterSort import tachiyomi.domain.chapter.service.getChapterSort
import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
import tachiyomi.domain.history.interactor.GetHistory import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.GetNextChapters
import tachiyomi.domain.history.interactor.UpsertHistory import tachiyomi.domain.history.interactor.UpsertHistory
import tachiyomi.domain.history.model.HistoryUpdate import tachiyomi.domain.history.model.HistoryUpdate
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.reading.ReadingEtaHistorySample
import tachiyomi.domain.reading.ReadingEtaPageRateEstimator
import tachiyomi.domain.reading.ReadingEtaDefaults import tachiyomi.domain.reading.ReadingEtaDefaults
import tachiyomi.domain.manga.interactor.GetFlatMetadataById import tachiyomi.domain.manga.interactor.GetFlatMetadataById
import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.GetManga
@ -128,6 +131,7 @@ class ReaderViewModel @JvmOverloads constructor(
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(), private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
private val getNextChapters: GetNextChapters = Injekt.get(), private val getNextChapters: GetNextChapters = Injekt.get(),
private val getHistory: GetHistory = Injekt.get(), private val getHistory: GetHistory = Injekt.get(),
private val getCrossMangaPageRateHistorySamples: GetCrossMangaPageRateHistorySamples = Injekt.get(),
private val upsertHistory: UpsertHistory = Injekt.get(), private val upsertHistory: UpsertHistory = Injekt.get(),
private val updateChapter: UpdateChapter = Injekt.get(), private val updateChapter: UpdateChapter = Injekt.get(),
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(), private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
@ -255,6 +259,12 @@ class ReaderViewModel @JvmOverloads constructor(
private val chapterReadPagesSession = mutableMapOf<Long, Long>() private val chapterReadPagesSession = mutableMapOf<Long, Long>()
private val chapterReadPagesPersistent = mutableMapOf<Long, Long>() private val chapterReadPagesPersistent = mutableMapOf<Long, Long>()
/**
* 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<ReadingEtaHistorySample> = emptyList()
private var orderedSeriesChapterIds = emptyList<Long>() private var orderedSeriesChapterIds = emptyList<Long>()
private var seriesChapterIndexById = emptyMap<Long, Int>() private var seriesChapterIndexById = emptyMap<Long, Int>()
@ -974,6 +984,7 @@ class ReaderViewModel @JvmOverloads constructor(
getHistory.await(manga.id) getHistory.await(manga.id)
.associate { history -> history.chapterId to history.readPages }, .associate { history -> history.chapterId to history.readPages },
) )
crossMangaPageRateSamples = getCrossMangaPageRateHistorySamples.await(manga.id)
updateSeriesProgressState() updateSeriesProgressState()
} }
@ -1125,7 +1136,7 @@ class ReaderViewModel @JvmOverloads constructor(
currentPage: Int, currentPage: Int,
remainingChapters: Int, remainingChapters: Int,
): Long? { ): Long? {
val pageRates = orderedSeriesChapterIds val seriesPageRatesMsPerPage = orderedSeriesChapterIds
.take(currentChapterIndex + 1) .take(currentChapterIndex + 1)
.mapNotNull { chapterId -> .mapNotNull { chapterId ->
val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration() ?: return@mapNotNull null val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration() ?: return@mapNotNull null
@ -1134,11 +1145,6 @@ class ReaderViewModel @JvmOverloads constructor(
duration.toDouble() / pages.toDouble() duration.toDouble() / pages.toDouble()
} }
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT) .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 val sampledPages = orderedSeriesChapterIds
.take(currentChapterIndex + 1) .take(currentChapterIndex + 1)
@ -1147,6 +1153,20 @@ class ReaderViewModel @JvmOverloads constructor(
if (sampledPages.isEmpty()) return null if (sampledPages.isEmpty()) return null
val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L) 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) { val currentRemainingPages = if (isCurrentChapterRead) {
0L 0L
} else if (totalPages > 0 && currentPage > 0) { } else if (totalPages > 0 && currentPage > 0) {

View file

@ -3,9 +3,22 @@ package tachiyomi.data.history
import tachiyomi.domain.history.model.History import tachiyomi.domain.history.model.History
import tachiyomi.domain.history.model.HistoryWithRelations import tachiyomi.domain.history.model.HistoryWithRelations
import tachiyomi.domain.manga.model.MangaCover import tachiyomi.domain.manga.model.MangaCover
import tachiyomi.domain.reading.ReadingEtaHistorySample
import java.util.Date import java.util.Date
object HistoryMapper { object HistoryMapper {
fun mapReadingEtaHistorySample(
readDuration: Long,
readPages: Long,
lastPageRead: Long,
chapterRead: Boolean,
): ReadingEtaHistorySample = ReadingEtaHistorySample(
readDurationMs = readDuration,
pagesRead = readPages,
chapterFullyRead = chapterRead,
lastPageReadIndex = lastPageRead,
)
fun mapHistory( fun mapHistory(
id: Long, id: Long,
chapterId: Long, chapterId: Long,

View file

@ -10,6 +10,7 @@ import tachiyomi.domain.history.model.HistoryUpdate
import tachiyomi.domain.history.model.HistoryWithRelations import tachiyomi.domain.history.model.HistoryWithRelations
import tachiyomi.domain.history.repository.HistoryRepository import tachiyomi.domain.history.repository.HistoryRepository
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.reading.ReadingEtaHistorySample
class HistoryRepositoryImpl( class HistoryRepositoryImpl(
private val handler: DatabaseHandler, private val handler: DatabaseHandler,
@ -56,6 +57,25 @@ class HistoryRepositoryImpl(
return handler.awaitList { historyQueries.getHistoryByMangaId(mangaId, HistoryMapper::mapHistory) } return handler.awaitList { historyQueries.getHistoryByMangaId(mangaId, HistoryMapper::mapHistory) }
} }
override suspend fun getCrossMangaPageRateHistorySamples(
excludeMangaId: Long,
minDurationMs: Long,
maxDurationMs: Long,
maxPagesRead: Long,
limit: Long,
): List<ReadingEtaHistorySample> {
return handler.awaitList {
historyQueries.getCrossMangaPageRateHistorySamples(
excludeMangaId,
minDurationMs,
maxDurationMs,
maxPagesRead,
limit,
HistoryMapper::mapReadingEtaHistorySample,
)
}
}
// KMK --> // KMK -->
override suspend fun resetHistory(historyIds: List<Long>) { override suspend fun resetHistory(historyIds: List<Long>) {
try { try {

View file

@ -82,3 +82,20 @@ WHERE chapter_id = :chapterId;
getReadDuration: getReadDuration:
SELECT coalesce(sum(time_read), 0) SELECT coalesce(sum(time_read), 0)
FROM history; 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;

View file

@ -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<ReadingEtaHistorySample> {
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,
)
}
}

View file

@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow
import tachiyomi.domain.history.model.History import tachiyomi.domain.history.model.History
import tachiyomi.domain.history.model.HistoryUpdate import tachiyomi.domain.history.model.HistoryUpdate
import tachiyomi.domain.history.model.HistoryWithRelations import tachiyomi.domain.history.model.HistoryWithRelations
import tachiyomi.domain.reading.ReadingEtaHistorySample
interface HistoryRepository { interface HistoryRepository {
@ -22,6 +23,14 @@ interface HistoryRepository {
suspend fun getHistoryByMangaId(mangaId: Long): List<History> suspend fun getHistoryByMangaId(mangaId: Long): List<History>
suspend fun getCrossMangaPageRateHistorySamples(
excludeMangaId: Long,
minDurationMs: Long,
maxDurationMs: Long,
maxPagesRead: Long,
limit: Long,
): List<ReadingEtaHistorySample>
// KMK --> // KMK -->
suspend fun resetHistory(historyIds: List<Long>) suspend fun resetHistory(historyIds: List<Long>)

View file

@ -2,8 +2,12 @@ package tachiyomi.domain.reading
/** /**
* Shared defaults for "time left" / reading-speed estimates in the library and reader. * 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 { object ReadingEtaDefaults {
@ -13,11 +17,23 @@ object ReadingEtaDefaults {
*/ */
const val MAX_CHAPTER_DURATION_MS_FOR_STATS: Long = 45 * 60 * 1000L const val MAX_CHAPTER_DURATION_MS_FOR_STATS: Long = 45 * 60 * 1000L
/** /** Same lower bound as reader tracking: ignore ultra-short noise. */
* If we have few per-chapter page-rate samples, do not go below this ms/page const val MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = 2_000L
* (avoids "2 minutes for the whole series" after a quick flip through ch.1).
*/
const val FALLBACK_MS_PER_PAGE: Long = 12_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
} }

View file

@ -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,
)

View file

@ -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<Double>,
crossMangaSamples: List<ReadingEtaHistorySample>,
): 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<ReadingEtaHistorySample>,
): 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<Double, Int>? {
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<Pair<Double, Int>>,
targetChapterPages: Int,
bandRatio: Double,
): List<Double> {
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<Double>): 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)
}
}