feat(reading-eta): unify library and reader limits; make caps tunable
All checks were successful
Forgejo Release Builder / release (push) Successful in 5m17s

Introduce ReadingEtaPreferences and ReadingEtaLimits so library badges, reader series ETA, cross-manga history mining, and page-rate estimation share one configuration. Add reader settings sliders (EN/DE strings). Register preferences in PreferenceModule and wire GetCrossMangaPageRateHistorySamples.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
littlecodedragon 2026-05-14 01:36:28 +02:00
parent e0f4262407
commit f178a79195
12 changed files with 273 additions and 58 deletions

View file

@ -177,7 +177,7 @@ class DomainModule : InjektModule {
addFactory { RemoveHistory(get()) }
addFactory { GetTotalReadDuration(get()) }
addFactory { GetReadDurationEntriesByMangaIds(get()) }
addFactory { GetCrossMangaPageRateHistorySamples(get()) }
addFactory { GetCrossMangaPageRateHistorySamples(get(), get()) }
addFactory { DeleteDownload(get(), get()) }

View file

@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.util.system.hasDisplayCutout
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import tachiyomi.domain.reading.service.ReadingEtaPreferences
import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR
import tachiyomi.i18n.sy.SYMR
@ -101,6 +102,7 @@ object SettingsReaderScreen : SearchableSettings {
),
SY <-- */
getDisplayGroup(readerPreferences = readerPref),
getReadingEtaTuningGroup(),
getEInkGroup(readerPreferences = readerPref),
getReadingGroup(readerPreferences = readerPref),
getPagedGroup(readerPreferences = readerPref),
@ -177,6 +179,56 @@ object SettingsReaderScreen : SearchableSettings {
)
}
@Composable
private fun getReadingEtaTuningGroup(): Preference.PreferenceGroup {
val readingEtaPrefs = remember { Injekt.get<ReadingEtaPreferences>() }
val etaMaxStatsPref = readingEtaPrefs.maxStatsDurationMinutes()
val etaMaxTrustedPref = readingEtaPrefs.maxTrustedSessionMinutes()
val etaSamplePref = readingEtaPrefs.sampleChapterLimit()
val etaMaxPagesPref = readingEtaPrefs.maxPagesReadForPageRate()
val etaMaxStats by etaMaxStatsPref.collectAsState()
val etaMaxTrusted by etaMaxTrustedPref.collectAsState()
val etaSample by etaSamplePref.collectAsState()
val etaMaxPages by etaMaxPagesPref.collectAsState()
return Preference.PreferenceGroup(
title = stringResource(KMR.strings.pref_reading_eta_group_title),
preferenceItems = persistentListOf(
Preference.PreferenceItem.SliderPreference(
value = etaMaxStats,
valueRange = 5..180,
title = stringResource(KMR.strings.pref_reading_eta_max_stats_min),
subtitle = stringResource(KMR.strings.pref_reading_eta_max_stats_min_summary),
valueString = stringResource(KMR.strings.pref_reading_eta_max_stats_min_value, etaMaxStats),
onValueChanged = { etaMaxStatsPref.set(it) },
),
Preference.PreferenceItem.SliderPreference(
value = etaMaxTrusted,
valueRange = 60..1440,
title = stringResource(KMR.strings.pref_reading_eta_max_trusted_min),
subtitle = stringResource(KMR.strings.pref_reading_eta_max_trusted_min_summary),
valueString = stringResource(KMR.strings.pref_reading_eta_max_trusted_min_value, etaMaxTrusted),
onValueChanged = { etaMaxTrustedPref.set(it) },
),
Preference.PreferenceItem.SliderPreference(
value = etaSample,
valueRange = 5..100,
title = stringResource(KMR.strings.pref_reading_eta_sample_chapters),
subtitle = stringResource(KMR.strings.pref_reading_eta_sample_chapters_summary),
valueString = stringResource(KMR.strings.pref_reading_eta_sample_chapters_value, etaSample),
onValueChanged = { etaSamplePref.set(it) },
),
Preference.PreferenceItem.SliderPreference(
value = (etaMaxPages / 100).coerceIn(10, 500),
valueRange = 10..500,
title = stringResource(KMR.strings.pref_reading_eta_max_pages_sample),
subtitle = stringResource(KMR.strings.pref_reading_eta_max_pages_sample_summary),
valueString = stringResource(KMR.strings.pref_reading_eta_max_pages_sample_value, etaMaxPages),
onValueChanged = { etaMaxPagesPref.set(it * 100) },
),
),
)
}
@Composable
private fun getEInkGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
val flashPageState by readerPreferences.flashOnPageChange().collectAsState()

View file

@ -19,6 +19,7 @@ import tachiyomi.domain.backup.service.BackupPreferences
import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.history.service.HistoryPreferences
import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.reading.service.ReadingEtaPreferences
import tachiyomi.domain.storage.service.StoragePreferences
import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.api.InjektModule
@ -61,6 +62,9 @@ class PreferenceModule(val app: Application) : InjektModule {
addSingletonFactory {
ReaderPreferences(get())
}
addSingletonFactory {
ReadingEtaPreferences(get())
}
addSingletonFactory {
TrackPreferences(get())
}

View file

@ -106,7 +106,8 @@ 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.reading.ReadingEtaLimits
import tachiyomi.domain.reading.service.ReadingEtaPreferences
import tachiyomi.domain.manga.interactor.GetIdsOfFavoriteMangaWithMetadata
import tachiyomi.domain.manga.interactor.GetLibraryManga
import tachiyomi.domain.manga.interactor.GetMergedMangaById
@ -164,6 +165,7 @@ class LibraryScreenModel(
// SY <--
// KMK -->
private val smartSearchMerge: SmartSearchMerge = Injekt.get(),
private val readingEtaPreferences: ReadingEtaPreferences = Injekt.get(),
// KMK <--
) : StateScreenModel<LibraryScreenModel.State>(State()) {
@ -195,8 +197,9 @@ class LibraryScreenModel(
),
// KMK <--
getLibraryItemPreferencesFlow(),
) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences ->
val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites)
readingEtaPreferences.limitsFlow(),
) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences, etaLimits ->
val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites, etaLimits)
val favoritesWithEstimatedTimeLeft = favorites.map { item ->
item.copy(
estimatedTimeLeftMillis = if (itemPreferences.estimatedTimeLeftBadge) {
@ -440,6 +443,7 @@ class LibraryScreenModel(
private suspend fun computeEstimatedTimeLeftByMangaId(
favorites: List<LibraryItem>,
limits: ReadingEtaLimits,
): Map<Long, Long?> {
if (favorites.isEmpty()) return emptyMap()
@ -448,6 +452,7 @@ class LibraryScreenModel(
}
val globalAverageChapterDurationMs = calculateAverageChapterDurationMs(
chapterDurationsByMangaId.values.flatten(),
limits,
)
return favorites.associate { item ->
@ -455,6 +460,7 @@ class LibraryScreenModel(
item = item,
chapterDurations = chapterDurationsByMangaId[item.id].orEmpty(),
globalAverageChapterDurationMs = globalAverageChapterDurationMs,
limits = limits,
)
}
}
@ -463,11 +469,12 @@ class LibraryScreenModel(
item: LibraryItem,
chapterDurations: List<Long>,
globalAverageChapterDurationMs: Long?,
limits: ReadingEtaLimits,
): Long? {
val unreadCount = item.libraryManga.unreadCount.coerceAtLeast(0L)
if (unreadCount == 0L) return null
val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations)
val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations, limits)
?: globalAverageChapterDurationMs
?: return null
@ -481,11 +488,12 @@ class LibraryScreenModel(
private fun calculateAverageChapterDurationMs(
chapterDurations: List<Long>,
limits: ReadingEtaLimits,
): 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)
.filter { it in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs }
.map { it.coerceAtMost(limits.maxChapterDurationMsForStats) }
.takeLast(limits.etaSampleChapterLimit)
if (sampledDurations.isEmpty()) return null
val sortedDurations = sampledDurations.sorted()
@ -1878,10 +1886,6 @@ class LibraryScreenModel(
// KMK -->
companion object {
private const val MIN_TRACKED_CHAPTER_DURATION_MS = 2_000L
private const val MAX_TRACKED_CHAPTER_DURATION_MS = 3_600_000L
private const val ETA_SAMPLE_CHAPTER_LIMIT = 60
/** List of MangaDex UUIDs subject to DMCA takedowns */
@Volatile
private var mangaDexDmcaUuids = hashSetOf<String>()

View file

@ -101,6 +101,8 @@ 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.ReadingEtaLimits
import tachiyomi.domain.reading.service.ReadingEtaPreferences
import tachiyomi.domain.manga.interactor.GetFlatMetadataById
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.GetMergedMangaById
@ -137,6 +139,7 @@ class ReaderViewModel @JvmOverloads constructor(
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
private val getIncognitoState: GetIncognitoState = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(),
private val readingEtaPreferences: ReadingEtaPreferences = Injekt.get(),
// SY -->
private val syncPreferences: SyncPreferences = Injekt.get(),
private val uiPreferences: UiPreferences = Injekt.get(),
@ -935,16 +938,17 @@ class ReaderViewModel @JvmOverloads constructor(
getCurrentChapter()?.let { readerChapter ->
if (incognitoMode) return@let
val limits = readingEtaPreferences.currentLimits()
val chapterId = readerChapter.chapter.id!!
val endTime = Date()
val sessionReadDuration = chapterReadStartTime
?.let { SystemClock.elapsedRealtime() - it }
?.sanitizeTrackedDuration()
?.sanitizeTrackedDuration(limits)
?: 0
val sessionReadPages = chapterReadStartPageIndex
?.let { startPageIndex ->
val endPageIndex = readerChapter.chapter.last_page_read
(endPageIndex - startPageIndex + 1L).sanitizeTrackedPages()
(endPageIndex - startPageIndex + 1L).sanitizeTrackedPages(limits)
}
?: 0L
@ -1007,18 +1011,20 @@ class ReaderViewModel @JvmOverloads constructor(
val remainingChapters = (
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
).coerceAtLeast(0)
val chapterDurations = getEtaChapterDurations(currentChapterId)
val chapterPages = getEtaChapterPages(currentChapterId)
val limits = readingEtaPreferences.currentLimits()
val chapterDurations = getEtaChapterDurations(currentChapterId, limits)
val chapterPages = getEtaChapterPages(currentChapterId, limits)
val currentChapterProgress = calculateChapterProgress(
currentPage = state.value.currentPage,
totalPages = state.value.totalPages,
)
val projectedCurrentChapterDuration =
estimateFullChapterMsFromActiveSession()
?.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS)
estimateFullChapterMsFromActiveSession(limits)
?.coerceAtMost(limits.maxChapterDurationMsForStats)
?: calculateProjectedChapterDurationMs(
chapterDuration = chapterDurations[currentChapterId] ?: 0L,
chapterProgress = currentChapterProgress,
limits = limits,
)
val averageChapterDuration = calculateAverageChapterDurationMs(
chapterDurations = chapterDurations,
@ -1026,6 +1032,7 @@ class ReaderViewModel @JvmOverloads constructor(
currentChapterIndex = currentChapterIndex,
isCurrentChapterRead = isCurrentChapterRead,
currentChapterProgress = currentChapterProgress,
limits = limits,
)
val pageBasedEta = calculatePageBasedEtaMillis(
chapterDurations = chapterDurations,
@ -1037,6 +1044,7 @@ class ReaderViewModel @JvmOverloads constructor(
totalPages = state.value.totalPages,
currentPage = state.value.currentPage,
remainingChapters = remainingChapters,
limits = limits,
)
val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration
val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters
@ -1089,7 +1097,7 @@ class ReaderViewModel @JvmOverloads constructor(
}
}
private fun getEtaChapterDurations(currentChapterId: Long): Map<Long, Long> {
private fun getEtaChapterDurations(currentChapterId: Long, limits: ReadingEtaLimits): Map<Long, Long> {
val trackedDurations = if (readerPreferences.persistentSeriesEta().get()) {
chapterReadDurationsPersistent.toMutableMap()
} else {
@ -1098,7 +1106,7 @@ class ReaderViewModel @JvmOverloads constructor(
val inProgressDuration = chapterReadStartTime
?.let { SystemClock.elapsedRealtime() - it }
?.sanitizeTrackedDuration()
?.sanitizeTrackedDuration(limits)
?: 0L
if (inProgressDuration > 0L) {
trackedDurations.merge(currentChapterId, inProgressDuration, Long::plus)
@ -1107,7 +1115,7 @@ class ReaderViewModel @JvmOverloads constructor(
return trackedDurations
}
private fun getEtaChapterPages(currentChapterId: Long): Map<Long, Long> {
private fun getEtaChapterPages(currentChapterId: Long, limits: ReadingEtaLimits): Map<Long, Long> {
val trackedPages = if (readerPreferences.persistentSeriesEta().get()) {
chapterReadPagesPersistent.toMutableMap()
} else {
@ -1116,7 +1124,7 @@ class ReaderViewModel @JvmOverloads constructor(
val inProgressPages = chapterReadStartPageIndex
?.let { startPageIndex ->
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
(currentPageIndex - startPageIndex + 1L).sanitizeTrackedPages()
(currentPageIndex - startPageIndex + 1L).sanitizeTrackedPages(limits)
}
?: 0L
if (inProgressPages > 0L) {
@ -1135,21 +1143,22 @@ class ReaderViewModel @JvmOverloads constructor(
totalPages: Int,
currentPage: Int,
remainingChapters: Int,
limits: ReadingEtaLimits,
): Long? {
val seriesPageRatesMsPerPage = orderedSeriesChapterIds
.take(currentChapterIndex + 1)
.mapNotNull { chapterId ->
val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration() ?: return@mapNotNull null
val pages = chapterPages[chapterId]?.sanitizeTrackedPages() ?: return@mapNotNull null
val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration(limits) ?: return@mapNotNull null
val pages = chapterPages[chapterId]?.sanitizeTrackedPages(limits) ?: return@mapNotNull null
if (duration <= 0L || pages <= 0L) return@mapNotNull null
duration.toDouble() / pages.toDouble()
}
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
.takeLast(limits.etaSampleChapterLimit)
val sampledPages = orderedSeriesChapterIds
.take(currentChapterIndex + 1)
.mapNotNull { chapterPages[it]?.sanitizeTrackedPages() }
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
.mapNotNull { chapterPages[it]?.sanitizeTrackedPages(limits) }
.takeLast(limits.etaSampleChapterLimit)
if (sampledPages.isEmpty()) return null
val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L)
@ -1165,6 +1174,7 @@ class ReaderViewModel @JvmOverloads constructor(
targetChapterPages = targetChapterPages,
seriesPageRatesMsPerPage = seriesPageRatesMsPerPage,
crossMangaSamples = crossMangaPageRateSamples,
limits = limits,
) ?: return null
val currentRemainingPages = if (isCurrentChapterRead) {
@ -1185,6 +1195,7 @@ class ReaderViewModel @JvmOverloads constructor(
currentChapterIndex: Int,
isCurrentChapterRead: Boolean,
currentChapterProgress: Double?,
limits: ReadingEtaLimits,
): Long? {
if (currentChapterIndex < 0) return null
val sampledDurations = orderedSeriesChapterIds
@ -1196,15 +1207,16 @@ class ReaderViewModel @JvmOverloads constructor(
calculateProjectedChapterDurationMs(
chapterDuration = chapterDuration,
chapterProgress = currentChapterProgress,
limits = limits,
)
}
else -> chapterDuration
} ?: return@mapNotNull null
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)
.filter { it in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs }
.map { it.coerceAtMost(limits.maxChapterDurationMsForStats) }
.takeLast(limits.etaSampleChapterLimit)
if (sampledDurations.isEmpty()) return null
val sortedDurations = sampledDurations.sorted()
@ -1229,6 +1241,7 @@ class ReaderViewModel @JvmOverloads constructor(
private fun calculateProjectedChapterDurationMs(
chapterDuration: Long,
chapterProgress: Double?,
limits: ReadingEtaLimits,
): Long? {
if (chapterDuration <= 0L) return null
val progress = chapterProgress
@ -1238,7 +1251,7 @@ class ReaderViewModel @JvmOverloads constructor(
if (progress == 0.0) return null
return (chapterDuration.toDouble() / progress).toLong()
.coerceAtLeast(0L)
.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS)
.coerceAtMost(limits.maxChapterDurationMsForStats)
}
/**
@ -1246,14 +1259,14 @@ class ReaderViewModel @JvmOverloads constructor(
* 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? {
private fun estimateFullChapterMsFromActiveSession(limits: ReadingEtaLimits): 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 sessionMs = (SystemClock.elapsedRealtime() - startTime).sanitizeTrackedDuration(limits)
if (sessionMs < limits.minTrackedChapterDurationMs) return null
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
val pagesThisSession = (currentPageIndex - startPageIndex + 1).coerceAtLeast(1)
@ -1271,12 +1284,12 @@ class ReaderViewModel @JvmOverloads constructor(
return (duration.toDouble() * remainingProgress).toLong().coerceAtLeast(0L)
}
private fun Long.sanitizeTrackedDuration(): Long {
return if (this in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS) this else 0L
private fun Long.sanitizeTrackedDuration(limits: ReadingEtaLimits): Long {
return if (this in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs) this else 0L
}
private fun Long.sanitizeTrackedPages(): Long {
return if (this in MIN_TRACKED_PAGES..MAX_TRACKED_PAGES) this else 0L
private fun Long.sanitizeTrackedPages(limits: ReadingEtaLimits): Long {
return if (this in ReadingEtaDefaults.MIN_PAGES_READ_FOR_PAGE_RATE..limits.maxPagesReadForPageRate) this else 0L
}
/**
@ -1830,12 +1843,7 @@ class ReaderViewModel @JvmOverloads constructor(
}
private companion object {
const val MIN_TRACKED_CHAPTER_DURATION_MS = 2_000L
const val MAX_TRACKED_CHAPTER_DURATION_MS = 4 * 60 * 60 * 1_000L
const val ETA_SAMPLE_CHAPTER_LIMIT = 20
const val MIN_PROGRESS_FOR_DURATION_PROJECTION = 0.05
const val MIN_TRACKED_PAGES = 1L
const val MAX_TRACKED_PAGES = 2_000L
}
@Immutable

View file

@ -3,16 +3,19 @@ package tachiyomi.domain.history.interactor
import tachiyomi.domain.history.repository.HistoryRepository
import tachiyomi.domain.reading.ReadingEtaDefaults
import tachiyomi.domain.reading.ReadingEtaHistorySample
import tachiyomi.domain.reading.service.ReadingEtaPreferences
class GetCrossMangaPageRateHistorySamples(
private val repository: HistoryRepository,
private val readingEtaPreferences: ReadingEtaPreferences,
) {
suspend fun await(excludeMangaId: Long): List<ReadingEtaHistorySample> {
val limits = readingEtaPreferences.currentLimits()
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,
minDurationMs = limits.minChapterDurationMsForPageRate,
maxDurationMs = limits.maxChapterDurationMsForPageRate,
maxPagesRead = limits.maxPagesReadForPageRate,
limit = ReadingEtaDefaults.CROSS_MANGA_PAGE_RATE_SAMPLE_LIMIT,
)
}

View file

@ -14,18 +14,35 @@ 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.
* Default for [tachiyomi.domain.reading.service.ReadingEtaPreferences.maxStatsDurationMinutes].
*/
const val MAX_CHAPTER_DURATION_MS_FOR_STATS: Long = 45 * 60 * 1000L
/** Same lower bound as reader tracking: ignore ultra-short noise. */
const val MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = 2_000L
/** Ignore ultra-short noise when attributing time to pages (session + history mining). */
const val MIN_TRACKED_CHAPTER_DURATION_MS: Long = 2_000L
/** Ignore pathological totals when mining history for page-rate priors. */
const val MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = 4L * 60 * 60 * 1000L
/**
* Upper bound on a single sessions credited duration for ETA speed stats.
* Default for [tachiyomi.domain.reading.service.ReadingEtaPreferences.maxTrustedSessionMinutes].
* Must be [MAX_CHAPTER_DURATION_MS_FOR_STATS] for sensible filtering.
*/
const val MAX_TRACKED_CHAPTER_DURATION_MS: Long = 4L * 60 * 60 * 1000L
/**
* How many recent chapters feed duration / page-rate averages (reader + library).
* Default for [tachiyomi.domain.reading.service.ReadingEtaPreferences.sampleChapterLimit].
*/
const val ETA_SAMPLE_CHAPTER_LIMIT: Int = 60
/** Same lower bound as reader tracking: ignore ultra-short noise. */
const val MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = MIN_TRACKED_CHAPTER_DURATION_MS
/** Default upper bound for mining history for page-rate priors (overridden by trusted session pref). */
const val MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = MAX_TRACKED_CHAPTER_DURATION_MS
const val MIN_PAGES_READ_FOR_PAGE_RATE: Long = 1L
/** Match reader sanitization so cross-manga rows stay comparable. */
/** Default cap on pages per sample for page-rate stats (user-tunable). */
const val MAX_PAGES_READ_FOR_PAGE_RATE: Long = 2_000L
/** Recent history rows scanned for cross-series priors (newest first). */

View file

@ -0,0 +1,45 @@
package tachiyomi.domain.reading
/**
* Effective caps for reading-time estimates, usually built from [tachiyomi.domain.reading.service.ReadingEtaPreferences].
* Library badge ETA and reader series ETA use the same limits.
*/
data class ReadingEtaLimits(
val maxChapterDurationMsForStats: Long,
val minTrackedChapterDurationMs: Long,
val maxTrackedChapterDurationMs: Long,
val etaSampleChapterLimit: Int,
val minChapterDurationMsForPageRate: Long,
val maxChapterDurationMsForPageRate: Long,
val maxPagesReadForPageRate: Long,
) {
companion object {
val DEFAULT: ReadingEtaLimits = fromRaw(
statsMinutes = (ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS / 60_000L).toInt(),
trustedSessionMinutes = (ReadingEtaDefaults.MAX_TRACKED_CHAPTER_DURATION_MS / 60_000L).toInt(),
sampleChapterLimit = ReadingEtaDefaults.ETA_SAMPLE_CHAPTER_LIMIT,
maxPagesSample = ReadingEtaDefaults.MAX_PAGES_READ_FOR_PAGE_RATE.toInt(),
)
fun fromRaw(
statsMinutes: Int,
trustedSessionMinutes: Int,
sampleChapterLimit: Int,
maxPagesSample: Int,
): ReadingEtaLimits {
val statsMin = statsMinutes.coerceIn(5, 24 * 60)
val trustedMin = trustedSessionMinutes.coerceIn(10, 24 * 60)
val statsMs = statsMin * 60_000L
val trustedMs = maxOf(trustedMin * 60_000L, statsMs)
return ReadingEtaLimits(
maxChapterDurationMsForStats = statsMs,
minTrackedChapterDurationMs = ReadingEtaDefaults.MIN_TRACKED_CHAPTER_DURATION_MS,
maxTrackedChapterDurationMs = trustedMs,
etaSampleChapterLimit = sampleChapterLimit.coerceIn(5, 200),
minChapterDurationMsForPageRate = ReadingEtaDefaults.MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE,
maxChapterDurationMsForPageRate = trustedMs,
maxPagesReadForPageRate = maxPagesSample.coerceIn(100, 50_000).toLong(),
)
}
}
}

View file

@ -15,10 +15,11 @@ object ReadingEtaPageRateEstimator {
targetChapterPages: Int,
seriesPageRatesMsPerPage: List<Double>,
crossMangaSamples: List<ReadingEtaHistorySample>,
limits: ReadingEtaLimits = ReadingEtaLimits.DEFAULT,
): Long? {
val n = seriesPageRatesMsPerPage.size
val seriesAvg = if (n > 0) seriesPageRatesMsPerPage.average() else null
val prior = estimatePriorMsPerPageFromCrossManga(targetChapterPages, crossMangaSamples)
val prior = estimatePriorMsPerPageFromCrossManga(targetChapterPages, crossMangaSamples, limits)
return when {
n >= 3 && seriesAvg != null -> seriesAvg.toLong().coerceAtLeast(1L)
@ -35,9 +36,10 @@ object ReadingEtaPageRateEstimator {
private fun estimatePriorMsPerPageFromCrossManga(
targetChapterPages: Int,
samples: List<ReadingEtaHistorySample>,
limits: ReadingEtaLimits,
): Long? {
if (targetChapterPages <= 0) return null
val ratesWithSize = samples.mapNotNull { toRateWithChapterPages(it) }
val ratesWithSize = samples.mapNotNull { toRateWithChapterPages(it, limits) }
if (ratesWithSize.isEmpty()) return null
val inner = ratesWithinPageBand(ratesWithSize, targetChapterPages, ReadingEtaDefaults.SIMILAR_CHAPTER_PAGE_COUNT_RATIO)
@ -49,16 +51,16 @@ object ReadingEtaPageRateEstimator {
return medianMsPerPage(ratesWithSize.map { it.first })
}
private fun toRateWithChapterPages(sample: ReadingEtaHistorySample): Pair<Double, Int>? {
private fun toRateWithChapterPages(sample: ReadingEtaHistorySample, limits: ReadingEtaLimits): 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 > limits.maxPagesReadForPageRate) return null
if (sample.readDurationMs < limits.minChapterDurationMsForPageRate) return null
if (sample.readDurationMs > limits.maxChapterDurationMsForPageRate) return null
if (sample.pagesRead > chapterPages * 2L) return null
val capped = sample.readDurationMs.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS)
val capped = sample.readDurationMs.coerceAtMost(limits.maxChapterDurationMsForStats)
val msPerPage = capped.toDouble() / sample.pagesRead.toDouble()
if (!msPerPage.isFinite() || msPerPage <= 0.0) return null
return msPerPage to chapterPages

View file

@ -0,0 +1,52 @@
package tachiyomi.domain.reading.service
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.domain.reading.ReadingEtaDefaults
import tachiyomi.domain.reading.ReadingEtaLimits
/**
* User-tunable bounds shared by library time-left badges and reader series ETA.
*/
class ReadingEtaPreferences(
private val preferenceStore: PreferenceStore,
) {
fun maxStatsDurationMinutes() = preferenceStore.getInt(
"pref_reading_eta_max_stats_min",
(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS / 60_000L).toInt(),
)
fun maxTrustedSessionMinutes() = preferenceStore.getInt(
"pref_reading_eta_max_trusted_session_min",
(ReadingEtaDefaults.MAX_TRACKED_CHAPTER_DURATION_MS / 60_000L).toInt(),
)
fun sampleChapterLimit() = preferenceStore.getInt(
"pref_reading_eta_sample_chapter_limit",
ReadingEtaDefaults.ETA_SAMPLE_CHAPTER_LIMIT,
)
fun maxPagesReadForPageRate() = preferenceStore.getInt(
"pref_reading_eta_max_pages_sample",
ReadingEtaDefaults.MAX_PAGES_READ_FOR_PAGE_RATE.toInt(),
)
fun currentLimits(): ReadingEtaLimits = ReadingEtaLimits.fromRaw(
statsMinutes = maxStatsDurationMinutes().get(),
trustedSessionMinutes = maxTrustedSessionMinutes().get(),
sampleChapterLimit = sampleChapterLimit().get(),
maxPagesSample = maxPagesReadForPageRate().get(),
)
fun limitsFlow(): Flow<ReadingEtaLimits> = combine(
maxStatsDurationMinutes().changes(),
maxTrustedSessionMinutes().changes(),
sampleChapterLimit().changes(),
maxPagesReadForPageRate().changes(),
) { statsMin, trustedMin, samples, maxPages ->
ReadingEtaLimits.fromRaw(statsMin, trustedMin, samples, maxPages)
}.distinctUntilChanged()
}

View file

@ -238,4 +238,18 @@
<string name="pref_discord_show_download_button_summary">Displays a button to download the app</string>
<string name="pref_discord_show_discord_button">Show Discord Button</string>
<string name="pref_discord_show_discord_button_summary">Displays a button to join the Discord server</string>
<!-- Reading ETA (library + reader) -->
<string name="pref_reading_eta_group_title">Time estimate tuning</string>
<string name="pref_reading_eta_max_stats_min">Max minutes per chapter sample</string>
<string name="pref_reading_eta_max_stats_min_summary">When averaging read times, each chapter session counts at most this many minutes. Prevents one long idle tab from skewing estimates.</string>
<string name="pref_reading_eta_max_stats_min_value">%d min</string>
<string name="pref_reading_eta_max_trusted_min">Max trusted session length</string>
<string name="pref_reading_eta_max_trusted_min_summary">Sessions longer than this are ignored for speed stats and when loading cross-series page-rate hints. Must be at least the value above.</string>
<string name="pref_reading_eta_max_trusted_min_value">%d min</string>
<string name="pref_reading_eta_sample_chapters">Chapters sampled for averages</string>
<string name="pref_reading_eta_sample_chapters_summary">How many recent chapters feed library time-left badges and reader series ETA averages.</string>
<string name="pref_reading_eta_sample_chapters_value">%d chapters</string>
<string name="pref_reading_eta_max_pages_sample">Max pages per history sample</string>
<string name="pref_reading_eta_max_pages_sample_summary">History rows with more pages read than this are skipped for page-rate estimates.</string>
<string name="pref_reading_eta_max_pages_sample_value">%d pages</string>
</resources>

View file

@ -192,4 +192,18 @@
<string name="pref_discord_online">🟢 Online</string>
<string name="discord_status_description">%1$s %2$s</string>
<string name="pref_discord_rpc">Discord Rich Presence</string>
<!-- Reading ETA -->
<string name="pref_reading_eta_group_title">Zeitschätzungen (Bibliothek &amp; Reader)</string>
<string name="pref_reading_eta_max_stats_min">Höchstwert Minuten pro Kapitel (Mittelwerte)</string>
<string name="pref_reading_eta_max_stats_min_summary">Bei Durchschnittszeiten zählt jede Lesesession pro Kapitel höchstens so viele Minuten. Verhindert, dass ein offenes Tab die Schätzung verzerrt.</string>
<string name="pref_reading_eta_max_stats_min_value">%d Min.</string>
<string name="pref_reading_eta_max_trusted_min">Max. vertrauenswürdige Sitzungslänge</string>
<string name="pref_reading_eta_max_trusted_min_summary">Längere Sessions fließen nicht in Geschwindigkeitsstatistik und seitenbasierte Fremdserien-Hinweise ein. Muss mindestens dem Wert darüber entsprechen.</string>
<string name="pref_reading_eta_max_trusted_min_value">%d Min.</string>
<string name="pref_reading_eta_sample_chapters">Kapitel für Mittelwerte</string>
<string name="pref_reading_eta_sample_chapters_summary">So viele der zuletzt gelesenen Kapitel fließen in Bibliotheks-Badges und Reader-Restzeit ein.</string>
<string name="pref_reading_eta_sample_chapters_value">%d Kapitel</string>
<string name="pref_reading_eta_max_pages_sample">Max. Seiten pro Verlauf-Stichprobe</string>
<string name="pref_reading_eta_max_pages_sample_summary">Verlaufseinträge mit mehr gelesenen Seiten werden für Seitenraten-Schätzungen übersprungen.</string>
<string name="pref_reading_eta_max_pages_sample_value">%d Seiten</string>
</resources>