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 { RemoveHistory(get()) }
addFactory { GetTotalReadDuration(get()) } addFactory { GetTotalReadDuration(get()) }
addFactory { GetReadDurationEntriesByMangaIds(get()) } addFactory { GetReadDurationEntriesByMangaIds(get()) }
addFactory { GetCrossMangaPageRateHistorySamples(get()) } addFactory { GetCrossMangaPageRateHistorySamples(get(), get()) }
addFactory { DeleteDownload(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.persistentListOf
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import tachiyomi.domain.reading.service.ReadingEtaPreferences
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR import tachiyomi.i18n.kmk.KMR
import tachiyomi.i18n.sy.SYMR import tachiyomi.i18n.sy.SYMR
@ -101,6 +102,7 @@ object SettingsReaderScreen : SearchableSettings {
), ),
SY <-- */ SY <-- */
getDisplayGroup(readerPreferences = readerPref), getDisplayGroup(readerPreferences = readerPref),
getReadingEtaTuningGroup(),
getEInkGroup(readerPreferences = readerPref), getEInkGroup(readerPreferences = readerPref),
getReadingGroup(readerPreferences = readerPref), getReadingGroup(readerPreferences = readerPref),
getPagedGroup(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 @Composable
private fun getEInkGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup { private fun getEInkGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
val flashPageState by readerPreferences.flashOnPageChange().collectAsState() 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.download.service.DownloadPreferences
import tachiyomi.domain.history.service.HistoryPreferences import tachiyomi.domain.history.service.HistoryPreferences
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
import tachiyomi.domain.reading.service.ReadingEtaPreferences
import tachiyomi.domain.storage.service.StoragePreferences import tachiyomi.domain.storage.service.StoragePreferences
import tachiyomi.domain.updates.service.UpdatesPreferences import tachiyomi.domain.updates.service.UpdatesPreferences
import uy.kohesive.injekt.api.InjektModule import uy.kohesive.injekt.api.InjektModule
@ -61,6 +62,9 @@ class PreferenceModule(val app: Application) : InjektModule {
addSingletonFactory { addSingletonFactory {
ReaderPreferences(get()) ReaderPreferences(get())
} }
addSingletonFactory {
ReadingEtaPreferences(get())
}
addSingletonFactory { addSingletonFactory {
TrackPreferences(get()) 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.LibrarySort
import tachiyomi.domain.library.model.sort import tachiyomi.domain.library.model.sort
import tachiyomi.domain.library.service.LibraryPreferences 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.GetIdsOfFavoriteMangaWithMetadata
import tachiyomi.domain.manga.interactor.GetLibraryManga import tachiyomi.domain.manga.interactor.GetLibraryManga
import tachiyomi.domain.manga.interactor.GetMergedMangaById import tachiyomi.domain.manga.interactor.GetMergedMangaById
@ -164,6 +165,7 @@ class LibraryScreenModel(
// SY <-- // SY <--
// KMK --> // KMK -->
private val smartSearchMerge: SmartSearchMerge = Injekt.get(), private val smartSearchMerge: SmartSearchMerge = Injekt.get(),
private val readingEtaPreferences: ReadingEtaPreferences = Injekt.get(),
// KMK <-- // KMK <--
) : StateScreenModel<LibraryScreenModel.State>(State()) { ) : StateScreenModel<LibraryScreenModel.State>(State()) {
@ -195,8 +197,9 @@ class LibraryScreenModel(
), ),
// KMK <-- // KMK <--
getLibraryItemPreferencesFlow(), getLibraryItemPreferencesFlow(),
) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences -> readingEtaPreferences.limitsFlow(),
val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites) ) { (searchQuery, categories, favorites), (tracksMap, trackingFilters), (includedCategories, excludedCategories), itemPreferences, etaLimits ->
val estimatedTimeLeftByMangaId = computeEstimatedTimeLeftByMangaId(favorites, etaLimits)
val favoritesWithEstimatedTimeLeft = favorites.map { item -> val favoritesWithEstimatedTimeLeft = favorites.map { item ->
item.copy( item.copy(
estimatedTimeLeftMillis = if (itemPreferences.estimatedTimeLeftBadge) { estimatedTimeLeftMillis = if (itemPreferences.estimatedTimeLeftBadge) {
@ -440,6 +443,7 @@ class LibraryScreenModel(
private suspend fun computeEstimatedTimeLeftByMangaId( private suspend fun computeEstimatedTimeLeftByMangaId(
favorites: List<LibraryItem>, favorites: List<LibraryItem>,
limits: ReadingEtaLimits,
): Map<Long, Long?> { ): Map<Long, Long?> {
if (favorites.isEmpty()) return emptyMap() if (favorites.isEmpty()) return emptyMap()
@ -448,6 +452,7 @@ class LibraryScreenModel(
} }
val globalAverageChapterDurationMs = calculateAverageChapterDurationMs( val globalAverageChapterDurationMs = calculateAverageChapterDurationMs(
chapterDurationsByMangaId.values.flatten(), chapterDurationsByMangaId.values.flatten(),
limits,
) )
return favorites.associate { item -> return favorites.associate { item ->
@ -455,6 +460,7 @@ class LibraryScreenModel(
item = item, item = item,
chapterDurations = chapterDurationsByMangaId[item.id].orEmpty(), chapterDurations = chapterDurationsByMangaId[item.id].orEmpty(),
globalAverageChapterDurationMs = globalAverageChapterDurationMs, globalAverageChapterDurationMs = globalAverageChapterDurationMs,
limits = limits,
) )
} }
} }
@ -463,11 +469,12 @@ class LibraryScreenModel(
item: LibraryItem, item: LibraryItem,
chapterDurations: List<Long>, chapterDurations: List<Long>,
globalAverageChapterDurationMs: Long?, globalAverageChapterDurationMs: Long?,
limits: ReadingEtaLimits,
): Long? { ): Long? {
val unreadCount = item.libraryManga.unreadCount.coerceAtLeast(0L) val unreadCount = item.libraryManga.unreadCount.coerceAtLeast(0L)
if (unreadCount == 0L) return null if (unreadCount == 0L) return null
val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations) val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations, limits)
?: globalAverageChapterDurationMs ?: globalAverageChapterDurationMs
?: return null ?: return null
@ -481,11 +488,12 @@ class LibraryScreenModel(
private fun calculateAverageChapterDurationMs( private fun calculateAverageChapterDurationMs(
chapterDurations: List<Long>, chapterDurations: List<Long>,
limits: ReadingEtaLimits,
): Long? { ): Long? {
val sampledDurations = chapterDurations val sampledDurations = chapterDurations
.filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS } .filter { it in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs }
.map { it.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) } .map { it.coerceAtMost(limits.maxChapterDurationMsForStats) }
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT) .takeLast(limits.etaSampleChapterLimit)
if (sampledDurations.isEmpty()) return null if (sampledDurations.isEmpty()) return null
val sortedDurations = sampledDurations.sorted() val sortedDurations = sampledDurations.sorted()
@ -1878,10 +1886,6 @@ class LibraryScreenModel(
// KMK --> // KMK -->
companion object { 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 */ /** List of MangaDex UUIDs subject to DMCA takedowns */
@Volatile @Volatile
private var mangaDexDmcaUuids = hashSetOf<String>() 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.ReadingEtaHistorySample
import tachiyomi.domain.reading.ReadingEtaPageRateEstimator import tachiyomi.domain.reading.ReadingEtaPageRateEstimator
import tachiyomi.domain.reading.ReadingEtaDefaults 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.GetFlatMetadataById
import tachiyomi.domain.manga.interactor.GetManga import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.manga.interactor.GetMergedMangaById import tachiyomi.domain.manga.interactor.GetMergedMangaById
@ -137,6 +139,7 @@ class ReaderViewModel @JvmOverloads constructor(
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(), private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
private val getIncognitoState: GetIncognitoState = Injekt.get(), private val getIncognitoState: GetIncognitoState = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(), private val libraryPreferences: LibraryPreferences = Injekt.get(),
private val readingEtaPreferences: ReadingEtaPreferences = Injekt.get(),
// SY --> // SY -->
private val syncPreferences: SyncPreferences = Injekt.get(), private val syncPreferences: SyncPreferences = Injekt.get(),
private val uiPreferences: UiPreferences = Injekt.get(), private val uiPreferences: UiPreferences = Injekt.get(),
@ -935,16 +938,17 @@ class ReaderViewModel @JvmOverloads constructor(
getCurrentChapter()?.let { readerChapter -> getCurrentChapter()?.let { readerChapter ->
if (incognitoMode) return@let if (incognitoMode) return@let
val limits = readingEtaPreferences.currentLimits()
val chapterId = readerChapter.chapter.id!! val chapterId = readerChapter.chapter.id!!
val endTime = Date() val endTime = Date()
val sessionReadDuration = chapterReadStartTime val sessionReadDuration = chapterReadStartTime
?.let { SystemClock.elapsedRealtime() - it } ?.let { SystemClock.elapsedRealtime() - it }
?.sanitizeTrackedDuration() ?.sanitizeTrackedDuration(limits)
?: 0 ?: 0
val sessionReadPages = chapterReadStartPageIndex val sessionReadPages = chapterReadStartPageIndex
?.let { startPageIndex -> ?.let { startPageIndex ->
val endPageIndex = readerChapter.chapter.last_page_read val endPageIndex = readerChapter.chapter.last_page_read
(endPageIndex - startPageIndex + 1L).sanitizeTrackedPages() (endPageIndex - startPageIndex + 1L).sanitizeTrackedPages(limits)
} }
?: 0L ?: 0L
@ -1007,18 +1011,20 @@ class ReaderViewModel @JvmOverloads constructor(
val remainingChapters = ( val remainingChapters = (
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0 totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
).coerceAtLeast(0) ).coerceAtLeast(0)
val chapterDurations = getEtaChapterDurations(currentChapterId) val limits = readingEtaPreferences.currentLimits()
val chapterPages = getEtaChapterPages(currentChapterId) val chapterDurations = getEtaChapterDurations(currentChapterId, limits)
val chapterPages = getEtaChapterPages(currentChapterId, limits)
val currentChapterProgress = calculateChapterProgress( val currentChapterProgress = calculateChapterProgress(
currentPage = state.value.currentPage, currentPage = state.value.currentPage,
totalPages = state.value.totalPages, totalPages = state.value.totalPages,
) )
val projectedCurrentChapterDuration = val projectedCurrentChapterDuration =
estimateFullChapterMsFromActiveSession() estimateFullChapterMsFromActiveSession(limits)
?.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) ?.coerceAtMost(limits.maxChapterDurationMsForStats)
?: calculateProjectedChapterDurationMs( ?: calculateProjectedChapterDurationMs(
chapterDuration = chapterDurations[currentChapterId] ?: 0L, chapterDuration = chapterDurations[currentChapterId] ?: 0L,
chapterProgress = currentChapterProgress, chapterProgress = currentChapterProgress,
limits = limits,
) )
val averageChapterDuration = calculateAverageChapterDurationMs( val averageChapterDuration = calculateAverageChapterDurationMs(
chapterDurations = chapterDurations, chapterDurations = chapterDurations,
@ -1026,6 +1032,7 @@ class ReaderViewModel @JvmOverloads constructor(
currentChapterIndex = currentChapterIndex, currentChapterIndex = currentChapterIndex,
isCurrentChapterRead = isCurrentChapterRead, isCurrentChapterRead = isCurrentChapterRead,
currentChapterProgress = currentChapterProgress, currentChapterProgress = currentChapterProgress,
limits = limits,
) )
val pageBasedEta = calculatePageBasedEtaMillis( val pageBasedEta = calculatePageBasedEtaMillis(
chapterDurations = chapterDurations, chapterDurations = chapterDurations,
@ -1037,6 +1044,7 @@ class ReaderViewModel @JvmOverloads constructor(
totalPages = state.value.totalPages, totalPages = state.value.totalPages,
currentPage = state.value.currentPage, currentPage = state.value.currentPage,
remainingChapters = remainingChapters, remainingChapters = remainingChapters,
limits = limits,
) )
val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration
val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters 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()) { val trackedDurations = if (readerPreferences.persistentSeriesEta().get()) {
chapterReadDurationsPersistent.toMutableMap() chapterReadDurationsPersistent.toMutableMap()
} else { } else {
@ -1098,7 +1106,7 @@ class ReaderViewModel @JvmOverloads constructor(
val inProgressDuration = chapterReadStartTime val inProgressDuration = chapterReadStartTime
?.let { SystemClock.elapsedRealtime() - it } ?.let { SystemClock.elapsedRealtime() - it }
?.sanitizeTrackedDuration() ?.sanitizeTrackedDuration(limits)
?: 0L ?: 0L
if (inProgressDuration > 0L) { if (inProgressDuration > 0L) {
trackedDurations.merge(currentChapterId, inProgressDuration, Long::plus) trackedDurations.merge(currentChapterId, inProgressDuration, Long::plus)
@ -1107,7 +1115,7 @@ class ReaderViewModel @JvmOverloads constructor(
return trackedDurations 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()) { val trackedPages = if (readerPreferences.persistentSeriesEta().get()) {
chapterReadPagesPersistent.toMutableMap() chapterReadPagesPersistent.toMutableMap()
} else { } else {
@ -1116,7 +1124,7 @@ class ReaderViewModel @JvmOverloads constructor(
val inProgressPages = chapterReadStartPageIndex val inProgressPages = chapterReadStartPageIndex
?.let { startPageIndex -> ?.let { startPageIndex ->
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex) val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
(currentPageIndex - startPageIndex + 1L).sanitizeTrackedPages() (currentPageIndex - startPageIndex + 1L).sanitizeTrackedPages(limits)
} }
?: 0L ?: 0L
if (inProgressPages > 0L) { if (inProgressPages > 0L) {
@ -1135,21 +1143,22 @@ class ReaderViewModel @JvmOverloads constructor(
totalPages: Int, totalPages: Int,
currentPage: Int, currentPage: Int,
remainingChapters: Int, remainingChapters: Int,
limits: ReadingEtaLimits,
): Long? { ): Long? {
val seriesPageRatesMsPerPage = 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(limits) ?: return@mapNotNull null
val pages = chapterPages[chapterId]?.sanitizeTrackedPages() ?: return@mapNotNull null val pages = chapterPages[chapterId]?.sanitizeTrackedPages(limits) ?: return@mapNotNull null
if (duration <= 0L || pages <= 0L) return@mapNotNull null if (duration <= 0L || pages <= 0L) return@mapNotNull null
duration.toDouble() / pages.toDouble() duration.toDouble() / pages.toDouble()
} }
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT) .takeLast(limits.etaSampleChapterLimit)
val sampledPages = orderedSeriesChapterIds val sampledPages = orderedSeriesChapterIds
.take(currentChapterIndex + 1) .take(currentChapterIndex + 1)
.mapNotNull { chapterPages[it]?.sanitizeTrackedPages() } .mapNotNull { chapterPages[it]?.sanitizeTrackedPages(limits) }
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT) .takeLast(limits.etaSampleChapterLimit)
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)
@ -1165,6 +1174,7 @@ class ReaderViewModel @JvmOverloads constructor(
targetChapterPages = targetChapterPages, targetChapterPages = targetChapterPages,
seriesPageRatesMsPerPage = seriesPageRatesMsPerPage, seriesPageRatesMsPerPage = seriesPageRatesMsPerPage,
crossMangaSamples = crossMangaPageRateSamples, crossMangaSamples = crossMangaPageRateSamples,
limits = limits,
) ?: return null ) ?: return null
val currentRemainingPages = if (isCurrentChapterRead) { val currentRemainingPages = if (isCurrentChapterRead) {
@ -1185,6 +1195,7 @@ class ReaderViewModel @JvmOverloads constructor(
currentChapterIndex: Int, currentChapterIndex: Int,
isCurrentChapterRead: Boolean, isCurrentChapterRead: Boolean,
currentChapterProgress: Double?, currentChapterProgress: Double?,
limits: ReadingEtaLimits,
): Long? { ): Long? {
if (currentChapterIndex < 0) return null if (currentChapterIndex < 0) return null
val sampledDurations = orderedSeriesChapterIds val sampledDurations = orderedSeriesChapterIds
@ -1196,15 +1207,16 @@ class ReaderViewModel @JvmOverloads constructor(
calculateProjectedChapterDurationMs( calculateProjectedChapterDurationMs(
chapterDuration = chapterDuration, chapterDuration = chapterDuration,
chapterProgress = currentChapterProgress, chapterProgress = currentChapterProgress,
limits = limits,
) )
} }
else -> chapterDuration else -> chapterDuration
} ?: return@mapNotNull null } ?: return@mapNotNull null
normalizedDuration normalizedDuration
} }
.filter { it in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS } .filter { it in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs }
.map { it.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS) } .map { it.coerceAtMost(limits.maxChapterDurationMsForStats) }
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT) .takeLast(limits.etaSampleChapterLimit)
if (sampledDurations.isEmpty()) return null if (sampledDurations.isEmpty()) return null
val sortedDurations = sampledDurations.sorted() val sortedDurations = sampledDurations.sorted()
@ -1229,6 +1241,7 @@ class ReaderViewModel @JvmOverloads constructor(
private fun calculateProjectedChapterDurationMs( private fun calculateProjectedChapterDurationMs(
chapterDuration: Long, chapterDuration: Long,
chapterProgress: Double?, chapterProgress: Double?,
limits: ReadingEtaLimits,
): Long? { ): Long? {
if (chapterDuration <= 0L) return null if (chapterDuration <= 0L) return null
val progress = chapterProgress val progress = chapterProgress
@ -1238,7 +1251,7 @@ class ReaderViewModel @JvmOverloads constructor(
if (progress == 0.0) return null if (progress == 0.0) return null
return (chapterDuration.toDouble() / progress).toLong() return (chapterDuration.toDouble() / progress).toLong()
.coerceAtLeast(0L) .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 * Prefer over scaling cumulative DB read time by absolute chapter progress history times are
* summed across revisits, which inflates `duration / progress` for rereads. * 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 totalPages = state.value.totalPages
val startPageIndex = chapterReadStartPageIndex ?: return null val startPageIndex = chapterReadStartPageIndex ?: return null
val startTime = chapterReadStartTime ?: return null val startTime = chapterReadStartTime ?: return null
if (totalPages <= 1) return null if (totalPages <= 1) return null
val sessionMs = (SystemClock.elapsedRealtime() - startTime).sanitizeTrackedDuration() val sessionMs = (SystemClock.elapsedRealtime() - startTime).sanitizeTrackedDuration(limits)
if (sessionMs < MIN_TRACKED_CHAPTER_DURATION_MS) return null if (sessionMs < limits.minTrackedChapterDurationMs) return null
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex) val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
val pagesThisSession = (currentPageIndex - startPageIndex + 1).coerceAtLeast(1) val pagesThisSession = (currentPageIndex - startPageIndex + 1).coerceAtLeast(1)
@ -1271,12 +1284,12 @@ class ReaderViewModel @JvmOverloads constructor(
return (duration.toDouble() * remainingProgress).toLong().coerceAtLeast(0L) return (duration.toDouble() * remainingProgress).toLong().coerceAtLeast(0L)
} }
private fun Long.sanitizeTrackedDuration(): Long { private fun Long.sanitizeTrackedDuration(limits: ReadingEtaLimits): Long {
return if (this in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS) this else 0L return if (this in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs) this else 0L
} }
private fun Long.sanitizeTrackedPages(): Long { private fun Long.sanitizeTrackedPages(limits: ReadingEtaLimits): Long {
return if (this in MIN_TRACKED_PAGES..MAX_TRACKED_PAGES) this else 0L 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 { 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_PROGRESS_FOR_DURATION_PROJECTION = 0.05
const val MIN_TRACKED_PAGES = 1L
const val MAX_TRACKED_PAGES = 2_000L
} }
@Immutable @Immutable

View file

@ -3,16 +3,19 @@ package tachiyomi.domain.history.interactor
import tachiyomi.domain.history.repository.HistoryRepository import tachiyomi.domain.history.repository.HistoryRepository
import tachiyomi.domain.reading.ReadingEtaDefaults import tachiyomi.domain.reading.ReadingEtaDefaults
import tachiyomi.domain.reading.ReadingEtaHistorySample import tachiyomi.domain.reading.ReadingEtaHistorySample
import tachiyomi.domain.reading.service.ReadingEtaPreferences
class GetCrossMangaPageRateHistorySamples( class GetCrossMangaPageRateHistorySamples(
private val repository: HistoryRepository, private val repository: HistoryRepository,
private val readingEtaPreferences: ReadingEtaPreferences,
) { ) {
suspend fun await(excludeMangaId: Long): List<ReadingEtaHistorySample> { suspend fun await(excludeMangaId: Long): List<ReadingEtaHistorySample> {
val limits = readingEtaPreferences.currentLimits()
return repository.getCrossMangaPageRateHistorySamples( return repository.getCrossMangaPageRateHistorySamples(
excludeMangaId = excludeMangaId, excludeMangaId = excludeMangaId,
minDurationMs = ReadingEtaDefaults.MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE, minDurationMs = limits.minChapterDurationMsForPageRate,
maxDurationMs = ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE, maxDurationMs = limits.maxChapterDurationMsForPageRate,
maxPagesRead = ReadingEtaDefaults.MAX_PAGES_READ_FOR_PAGE_RATE, maxPagesRead = limits.maxPagesReadForPageRate,
limit = ReadingEtaDefaults.CROSS_MANGA_PAGE_RATE_SAMPLE_LIMIT, 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. * 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. * 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 const val MAX_CHAPTER_DURATION_MS_FOR_STATS: Long = 45 * 60 * 1000L
/** Same lower bound as reader tracking: ignore ultra-short noise. */ /** Ignore ultra-short noise when attributing time to pages (session + history mining). */
const val MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = 2_000L 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 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 const val MAX_PAGES_READ_FOR_PAGE_RATE: Long = 2_000L
/** Recent history rows scanned for cross-series priors (newest first). */ /** 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, targetChapterPages: Int,
seriesPageRatesMsPerPage: List<Double>, seriesPageRatesMsPerPage: List<Double>,
crossMangaSamples: List<ReadingEtaHistorySample>, crossMangaSamples: List<ReadingEtaHistorySample>,
limits: ReadingEtaLimits = ReadingEtaLimits.DEFAULT,
): Long? { ): Long? {
val n = seriesPageRatesMsPerPage.size val n = seriesPageRatesMsPerPage.size
val seriesAvg = if (n > 0) seriesPageRatesMsPerPage.average() else null val seriesAvg = if (n > 0) seriesPageRatesMsPerPage.average() else null
val prior = estimatePriorMsPerPageFromCrossManga(targetChapterPages, crossMangaSamples) val prior = estimatePriorMsPerPageFromCrossManga(targetChapterPages, crossMangaSamples, limits)
return when { return when {
n >= 3 && seriesAvg != null -> seriesAvg.toLong().coerceAtLeast(1L) n >= 3 && seriesAvg != null -> seriesAvg.toLong().coerceAtLeast(1L)
@ -35,9 +36,10 @@ object ReadingEtaPageRateEstimator {
private fun estimatePriorMsPerPageFromCrossManga( private fun estimatePriorMsPerPageFromCrossManga(
targetChapterPages: Int, targetChapterPages: Int,
samples: List<ReadingEtaHistorySample>, samples: List<ReadingEtaHistorySample>,
limits: ReadingEtaLimits,
): Long? { ): Long? {
if (targetChapterPages <= 0) return null if (targetChapterPages <= 0) return null
val ratesWithSize = samples.mapNotNull { toRateWithChapterPages(it) } val ratesWithSize = samples.mapNotNull { toRateWithChapterPages(it, limits) }
if (ratesWithSize.isEmpty()) return null if (ratesWithSize.isEmpty()) return null
val inner = ratesWithinPageBand(ratesWithSize, targetChapterPages, ReadingEtaDefaults.SIMILAR_CHAPTER_PAGE_COUNT_RATIO) val inner = ratesWithinPageBand(ratesWithSize, targetChapterPages, ReadingEtaDefaults.SIMILAR_CHAPTER_PAGE_COUNT_RATIO)
@ -49,16 +51,16 @@ object ReadingEtaPageRateEstimator {
return medianMsPerPage(ratesWithSize.map { it.first }) 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 if (!sample.chapterFullyRead) return null
val chapterPages = (sample.lastPageReadIndex + 1L).toInt().coerceAtLeast(1) val chapterPages = (sample.lastPageReadIndex + 1L).toInt().coerceAtLeast(1)
if (sample.pagesRead < ReadingEtaDefaults.MIN_PAGES_READ_FOR_PAGE_RATE) return null 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.pagesRead > limits.maxPagesReadForPageRate) return null
if (sample.readDurationMs < ReadingEtaDefaults.MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE) return null if (sample.readDurationMs < limits.minChapterDurationMsForPageRate) return null
if (sample.readDurationMs > ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE) return null if (sample.readDurationMs > limits.maxChapterDurationMsForPageRate) return null
if (sample.pagesRead > chapterPages * 2L) 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() val msPerPage = capped.toDouble() / sample.pagesRead.toDouble()
if (!msPerPage.isFinite() || msPerPage <= 0.0) return null if (!msPerPage.isFinite() || msPerPage <= 0.0) return null
return msPerPage to chapterPages 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_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">Show Discord Button</string>
<string name="pref_discord_show_discord_button_summary">Displays a button to join the Discord server</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> </resources>

View file

@ -192,4 +192,18 @@
<string name="pref_discord_online">🟢 Online</string> <string name="pref_discord_online">🟢 Online</string>
<string name="discord_status_description">%1$s %2$s</string> <string name="discord_status_description">%1$s %2$s</string>
<string name="pref_discord_rpc">Discord Rich Presence</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> </resources>