fix(eta): tame reading-time estimates (idle + cumulative history)

- Cap per-chapter samples used for averages at 45m (stats only).
- Prefer session ms/page extrapolation for current chapter so cumulative
  DB read time over revisits no longer blows up duration/progress.
- Floor uncertain page rates using conservative ms/page until enough samples.
- Shared defaults in ReadingEtaDefaults for reader + library badge.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
littlecodedragon 2026-05-11 02:28:28 +02:00
parent 9ce3f0ba29
commit 07019175d1
3 changed files with 62 additions and 6 deletions

View file

@ -106,6 +106,7 @@ import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.library.model.LibrarySort
import tachiyomi.domain.library.model.sort
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.reading.ReadingEtaDefaults
import tachiyomi.domain.manga.interactor.GetIdsOfFavoriteMangaWithMetadata
import tachiyomi.domain.manga.interactor.GetLibraryManga
import tachiyomi.domain.manga.interactor.GetMergedMangaById
@ -483,6 +484,7 @@ class LibraryScreenModel(
): Long? {
val sampledDurations = chapterDurations
.filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS }
.map { it.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) }
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
if (sampledDurations.isEmpty()) return null

View file

@ -97,6 +97,7 @@ 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.ReadingEtaDefaults
import tachiyomi.domain.manga.interactor.GetFlatMetadataById
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.GetMergedMangaById
@ -1001,10 +1002,13 @@ class ReaderViewModel @JvmOverloads constructor(
currentPage = state.value.currentPage,
totalPages = state.value.totalPages,
)
val projectedCurrentChapterDuration = calculateProjectedChapterDurationMs(
chapterDuration = chapterDurations[currentChapterId] ?: 0L,
chapterProgress = currentChapterProgress,
)
val projectedCurrentChapterDuration =
estimateFullChapterMsFromActiveSession()
?.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS)
?: calculateProjectedChapterDurationMs(
chapterDuration = chapterDurations[currentChapterId] ?: 0L,
chapterProgress = currentChapterProgress,
)
val averageChapterDuration = calculateAverageChapterDurationMs(
chapterDurations = chapterDurations,
currentChapterId = currentChapterId,
@ -1131,7 +1135,10 @@ class ReaderViewModel @JvmOverloads constructor(
}
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
if (pageRates.isEmpty()) return null
val avgMsPerPage = pageRates.average().toLong().coerceAtLeast(1L)
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)
@ -1176,6 +1183,7 @@ class ReaderViewModel @JvmOverloads constructor(
normalizedDuration
}
.filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS }
.map { it.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) }
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
if (sampledDurations.isEmpty()) return null
@ -1208,7 +1216,30 @@ class ReaderViewModel @JvmOverloads constructor(
?.coerceIn(0.0, 1.0)
?: return null
if (progress == 0.0) return null
return (chapterDuration.toDouble() / progress).toLong().coerceAtLeast(0L)
return (chapterDuration.toDouble() / progress).toLong()
.coerceAtLeast(0L)
.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS)
}
/**
* Estimates full-chapter duration from the current session's ms/page rate.
* Prefer over scaling cumulative DB read time by absolute chapter progress history times are
* summed across revisits, which inflates `duration / progress` for rereads.
*/
private fun estimateFullChapterMsFromActiveSession(): Long? {
val totalPages = state.value.totalPages
val startPageIndex = chapterReadStartPageIndex ?: return null
val startTime = chapterReadStartTime ?: return null
if (totalPages <= 1) return null
val sessionMs = (SystemClock.elapsedRealtime() - startTime).sanitizeTrackedDuration()
if (sessionMs < MIN_TRACKED_CHAPTER_DURATION_MS) return null
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
val pagesThisSession = (currentPageIndex - startPageIndex + 1).coerceAtLeast(1)
val msPerPage = sessionMs.toDouble() / pagesThisSession.toDouble()
val estimated = (msPerPage * totalPages.toDouble()).toLong()
return estimated.takeIf { it > 0L }
}
private fun calculateRemainingChapterTimeMs(

View file

@ -0,0 +1,23 @@
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.
*/
object ReadingEtaDefaults {
/**
* When building per-chapter averages, treat each sample as at most this long.
* Stops a single tab-left-open session from blowing up series-wide ETAs.
*/
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
const val MIN_TRUSTED_PAGE_RATE_SAMPLES: Int = 3
}