feat(reading-eta): share SeriesReadingEtaCalculator across reader and library

Extract a single ETA estimator, refresh library badges when history changes,
and expose a history revision stream for reactive updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
littlecodedragon 2026-07-10 10:12:02 +02:00
parent b299a9ce81
commit 4fce1fa940
8 changed files with 417 additions and 288 deletions

View file

@ -67,6 +67,7 @@ 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.GetCrossMangaPageRateHistorySamples
import tachiyomi.domain.history.interactor.GetHistory import tachiyomi.domain.history.interactor.GetHistory
import tachiyomi.domain.history.interactor.GetHistoryRevision
import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.GetNextChapters
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
import tachiyomi.domain.history.interactor.GetTotalReadDuration import tachiyomi.domain.history.interactor.GetTotalReadDuration
@ -173,6 +174,7 @@ class DomainModule : InjektModule {
addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) } addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
addFactory { GetHistory(get()) } addFactory { GetHistory(get()) }
addFactory { GetHistoryRevision(get()) }
addFactory { UpsertHistory(get()) } addFactory { UpsertHistory(get()) }
addFactory { RemoveHistory(get()) } addFactory { RemoveHistory(get()) }
addFactory { GetTotalReadDuration(get()) } addFactory { GetTotalReadDuration(get()) }

View file

@ -98,6 +98,9 @@ import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.GetNextChapters
import tachiyomi.domain.chapter.service.getChapterSort
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
import tachiyomi.domain.history.interactor.GetHistoryRevision
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
import tachiyomi.domain.history.interactor.GetTotalReadDuration import tachiyomi.domain.history.interactor.GetTotalReadDuration
import tachiyomi.domain.library.model.LibraryDisplayMode import tachiyomi.domain.library.model.LibraryDisplayMode
@ -107,6 +110,7 @@ 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.ReadingEtaLimits import tachiyomi.domain.reading.ReadingEtaLimits
import tachiyomi.domain.reading.SeriesReadingEtaCalculator
import tachiyomi.domain.reading.service.ReadingEtaPreferences 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
@ -153,6 +157,8 @@ class LibraryScreenModel(
private val sourcePreferences: SourcePreferences = Injekt.get(), private val sourcePreferences: SourcePreferences = Injekt.get(),
private val getMergedMangaById: GetMergedMangaById = Injekt.get(), private val getMergedMangaById: GetMergedMangaById = Injekt.get(),
private val getReadDurationEntriesByMangaIds: GetReadDurationEntriesByMangaIds = Injekt.get(), private val getReadDurationEntriesByMangaIds: GetReadDurationEntriesByMangaIds = Injekt.get(),
private val getCrossMangaPageRateHistorySamples: GetCrossMangaPageRateHistorySamples = Injekt.get(),
private val getHistoryRevision: GetHistoryRevision = Injekt.get(),
private val getTotalReadDuration: GetTotalReadDuration = Injekt.get(), private val getTotalReadDuration: GetTotalReadDuration = Injekt.get(),
private val getTracks: GetTracks = Injekt.get(), private val getTracks: GetTracks = Injekt.get(),
private val getIdsOfFavoriteMangaWithMetadata: GetIdsOfFavoriteMangaWithMetadata = Injekt.get(), private val getIdsOfFavoriteMangaWithMetadata: GetIdsOfFavoriteMangaWithMetadata = Injekt.get(),
@ -447,64 +453,57 @@ class LibraryScreenModel(
): Map<Long, Long?> { ): Map<Long, Long?> {
if (favorites.isEmpty()) return emptyMap() if (favorites.isEmpty()) return emptyMap()
val chapterDurationsByMangaId = favorites.associate { item ->
item.id to getReadDurationEntriesByMangaIds.await(item.id).map { it.readDuration }
}
val globalAverageChapterDurationMs = calculateAverageChapterDurationMs(
chapterDurationsByMangaId.values.flatten(),
limits,
)
return favorites.associate { item -> return favorites.associate { item ->
item.id to calculateEstimatedTimeLeftMs( item.id to computeEstimatedTimeLeftMs(item, limits)
item = item,
chapterDurations = chapterDurationsByMangaId[item.id].orEmpty(),
globalAverageChapterDurationMs = globalAverageChapterDurationMs,
limits = limits,
)
} }
} }
private fun calculateEstimatedTimeLeftMs( private suspend fun computeEstimatedTimeLeftMs(
item: LibraryItem, item: LibraryItem,
chapterDurations: List<Long>,
globalAverageChapterDurationMs: Long?,
limits: ReadingEtaLimits, limits: ReadingEtaLimits,
): Long? { ): Long? {
val unreadCount = item.libraryManga.unreadCount.coerceAtLeast(0L) if (item.libraryManga.unreadCount <= 0L) return null
if (unreadCount == 0L) return null
val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations, limits) val manga = item.libraryManga.manga
?: globalAverageChapterDurationMs val mergedManga = if (manga.source == MERGED_SOURCE_ID) {
?: return null getMergedMangaById.await(manga.id).associateBy { it.id }
val remainingChapterUnits = if (item.libraryManga.totalChapters == 1L) {
1L
} else { } else {
unreadCount emptyMap()
} }
return (averageChapterDurationMs * remainingChapterUnits).coerceAtLeast(0L) val rawChapters = if (manga.source == MERGED_SOURCE_ID) {
} getMergedChaptersByMangaId.await(manga.id, applyFilter = true)
private fun calculateAverageChapterDurationMs(
chapterDurations: List<Long>,
limits: ReadingEtaLimits,
): Long? {
val sampledDurations = chapterDurations
.filter { it in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs }
.map { it.coerceAtMost(limits.maxChapterDurationMsForStats) }
.takeLast(limits.etaSampleChapterLimit)
if (sampledDurations.isEmpty()) return null
val sortedDurations = sampledDurations.sorted()
val trimCount = (sortedDurations.size / 10)
.coerceAtMost((sortedDurations.size - 1) / 2)
val trimmedDurations = if (trimCount > 0) {
sortedDurations.subList(trimCount, sortedDurations.size - trimCount)
} else { } else {
sortedDurations getChaptersByMangaId.await(manga.id, applyFilter = true)
} }
return trimmedDurations.sum() / trimmedDurations.size val orderedChapters = if (manga.source == MERGED_SOURCE_ID) {
getMergedChaptersByMangaId.await(manga.id, applyFilter = false)
} else {
getChaptersByMangaId.await(manga.id, applyFilter = false)
}.sortedWith(getChapterSort(manga, sortDescending = false))
if (orderedChapters.isEmpty()) return null
val nextUnread = rawChapters.getNextUnread(manga, downloadManager, mergedManga) ?: return null
val histories = getReadDurationEntriesByMangaIds.await(manga.id)
val crossMangaSamples = getCrossMangaPageRateHistorySamples.await(manga.id)
val currentPage = if (nextUnread.lastPageRead > 0L) {
(nextUnread.lastPageRead + 1L).toInt()
} else {
1
}
return SeriesReadingEtaCalculator.estimate(
SeriesReadingEtaCalculator.Input(
orderedChapterIds = orderedChapters.map { it.id },
chapterReadById = orderedChapters.associate { it.id to it.read },
currentChapterId = nextUnread.id,
currentPage = currentPage,
totalPages = -1,
chapterDurations = histories.associate { it.chapterId to it.readDuration },
chapterPages = histories.associate { it.chapterId to it.readPages },
crossMangaPageRateSamples = crossMangaSamples,
limits = limits,
),
)?.etaMillis
} }
private fun compareEstimatedTimeLeft( private fun compareEstimatedTimeLeft(
@ -906,7 +905,8 @@ class LibraryScreenModel(
getLibraryManga.subscribe(), getLibraryManga.subscribe(),
getLibraryItemPreferencesFlow(), getLibraryItemPreferencesFlow(),
downloadCache.changes, downloadCache.changes,
) { libraryManga, preferences, _ -> getHistoryRevision.subscribe(),
) { libraryManga, preferences, _, _ ->
libraryManga.map { manga -> libraryManga.map { manga ->
// Display mode based on user preference: take it from global library setting or category // Display mode based on user preference: take it from global library setting or category
// KMK --> // KMK -->

View file

@ -99,9 +99,9 @@ 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.ReadingEtaHistorySample
import tachiyomi.domain.reading.ReadingEtaPageRateEstimator
import tachiyomi.domain.reading.ReadingEtaDefaults import tachiyomi.domain.reading.ReadingEtaDefaults
import tachiyomi.domain.reading.ReadingEtaLimits import tachiyomi.domain.reading.ReadingEtaLimits
import tachiyomi.domain.reading.SeriesReadingEtaCalculator
import tachiyomi.domain.reading.service.ReadingEtaPreferences 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
@ -269,7 +269,6 @@ class ReaderViewModel @JvmOverloads constructor(
private var crossMangaPageRateSamples: List<ReadingEtaHistorySample> = emptyList() private var crossMangaPageRateSamples: List<ReadingEtaHistorySample> = emptyList()
private var orderedSeriesChapterIds = emptyList<Long>() private var orderedSeriesChapterIds = emptyList<Long>()
private var seriesChapterIndexById = emptyMap<Long, Int>()
private var chapterToDownload: Download? = null private var chapterToDownload: Download? = null
@ -972,9 +971,6 @@ class ReaderViewModel @JvmOverloads constructor(
orderedSeriesChapterIds = unfilteredChapterList orderedSeriesChapterIds = unfilteredChapterList
.sortedWith(getChapterSort(manga, sortDescending = false)) .sortedWith(getChapterSort(manga, sortDescending = false))
.mapNotNull { it.id } .mapNotNull { it.id }
seriesChapterIndexById = orderedSeriesChapterIds
.withIndex()
.associate { (index, chapterId) -> chapterId to index }
chapterReadDurationsSession.clear() chapterReadDurationsSession.clear()
chapterReadDurationsPersistent.clear() chapterReadDurationsPersistent.clear()
@ -1000,94 +996,49 @@ class ReaderViewModel @JvmOverloads constructor(
return return
} }
val currentChapterIndex = seriesChapterIndexById[currentChapterId] val limits = readingEtaPreferences.currentLimits()
if (currentChapterIndex == null) { val chapterDurations = getEtaChapterDurations(currentChapterId, limits)
val chapterPages = getEtaChapterPages(currentChapterId, limits)
val activeSession = chapterReadStartTime?.let { startTime ->
chapterReadStartPageIndex?.let { startPageIndex ->
val totalPages = state.value.totalPages
if (totalPages <= 1) {
null
} else {
SeriesReadingEtaCalculator.ActiveSession(
sessionDurationMs = SystemClock.elapsedRealtime() - startTime,
startPageIndex = startPageIndex,
currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex),
totalPages = totalPages,
)
}
}
}
val result = SeriesReadingEtaCalculator.estimate(
SeriesReadingEtaCalculator.Input(
orderedChapterIds = orderedSeriesChapterIds,
chapterReadById = unfilteredChapterList.associate { it.id to it.read },
currentChapterId = currentChapterId,
currentPage = state.value.currentPage,
totalPages = state.value.totalPages,
chapterDurations = chapterDurations,
chapterPages = chapterPages,
crossMangaPageRateSamples = crossMangaPageRateSamples,
limits = limits,
activeSession = activeSession,
),
) ?: run {
mutableState.update { it.copy(seriesReadingProgress = null) } mutableState.update { it.copy(seriesReadingProgress = null) }
return return
} }
val totalChapters = orderedSeriesChapterIds.size
val isCurrentChapterRead = state.value.currentChapter?.chapter?.read == true
val remainingChapters = (
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
).coerceAtLeast(0)
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(limits)
?.coerceAtMost(limits.maxChapterDurationMsForStats)
?: calculateProjectedChapterDurationMs(
chapterDuration = chapterDurations[currentChapterId] ?: 0L,
chapterProgress = currentChapterProgress,
limits = limits,
)
val averageChapterDuration = calculateAverageChapterDurationMs(
chapterDurations = chapterDurations,
currentChapterId = currentChapterId,
currentChapterIndex = currentChapterIndex,
isCurrentChapterRead = isCurrentChapterRead,
currentChapterProgress = currentChapterProgress,
limits = limits,
)
val pageBasedEta = calculatePageBasedEtaMillis(
chapterDurations = chapterDurations,
chapterPages = chapterPages,
currentChapterId = currentChapterId,
currentChapterIndex = currentChapterIndex,
isCurrentChapterRead = isCurrentChapterRead,
currentChapterProgress = currentChapterProgress,
totalPages = state.value.totalPages,
currentPage = state.value.currentPage,
remainingChapters = remainingChapters,
limits = limits,
)
val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration
val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters
val chapterBasedEta = when {
totalChapters == 1 -> {
if (isCurrentChapterRead) {
0L
} else {
calculateRemainingChapterTimeMs(
chapterDuration = chapterDurationForCurrentChapter,
chapterProgress = currentChapterProgress,
) ?: chapterDurationForCurrentChapter
}
}
else -> {
when {
remainingChapters == 0 -> 0L
else -> chapterDurationForWholeChapters?.let { chapterDurationMs ->
val currentChapterRemainingMs = if (isCurrentChapterRead) {
0L
} else {
calculateRemainingChapterTimeMs(
chapterDuration = chapterDurationForCurrentChapter,
chapterProgress = currentChapterProgress,
) ?: chapterDurationForCurrentChapter ?: 0L
}
val remainingWholeChapters = (
remainingChapters - if (isCurrentChapterRead) 0 else 1
).coerceAtLeast(0)
currentChapterRemainingMs + (chapterDurationMs * remainingWholeChapters)
}
}
}
}
val etaMillis = (pageBasedEta ?: chapterBasedEta)?.coerceAtLeast(0L)
mutableState.update { mutableState.update {
it.copy( it.copy(
seriesReadingProgress = SeriesReadingProgress( seriesReadingProgress = SeriesReadingProgress(
currentChapter = currentChapterIndex + 1, currentChapter = result.currentChapter,
totalChapters = totalChapters, totalChapters = result.totalChapters,
remainingChapters = remainingChapters, remainingChapters = result.remainingChapters,
etaMillis = etaMillis, etaMillis = result.etaMillis,
), ),
) )
} }
@ -1133,157 +1084,6 @@ class ReaderViewModel @JvmOverloads constructor(
return trackedPages return trackedPages
} }
private fun calculatePageBasedEtaMillis(
chapterDurations: Map<Long, Long>,
chapterPages: Map<Long, Long>,
currentChapterId: Long,
currentChapterIndex: Int,
isCurrentChapterRead: Boolean,
currentChapterProgress: Double?,
totalPages: Int,
currentPage: Int,
remainingChapters: Int,
limits: ReadingEtaLimits,
): Long? {
val seriesPageRatesMsPerPage = orderedSeriesChapterIds
.take(currentChapterIndex + 1)
.mapNotNull { chapterId ->
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(limits.etaSampleChapterLimit)
val sampledPages = orderedSeriesChapterIds
.take(currentChapterIndex + 1)
.mapNotNull { chapterPages[it]?.sanitizeTrackedPages(limits) }
.takeLast(limits.etaSampleChapterLimit)
if (sampledPages.isEmpty()) return null
val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L)
if (seriesPageRatesMsPerPage.isEmpty() && crossMangaPageRateSamples.isEmpty()) return null
val targetChapterPages = if (totalPages > 0) {
totalPages
} else {
avgPagesPerChapter.toInt().coerceAtLeast(1)
}
val avgMsPerPage = ReadingEtaPageRateEstimator.estimateBlendedMsPerPage(
targetChapterPages = targetChapterPages,
seriesPageRatesMsPerPage = seriesPageRatesMsPerPage,
crossMangaSamples = crossMangaPageRateSamples,
limits = limits,
) ?: return null
val currentRemainingPages = if (isCurrentChapterRead) {
0L
} else if (totalPages > 0 && currentPage > 0) {
(totalPages - currentPage).toLong().coerceAtLeast(0L)
} else {
val progress = (currentChapterProgress ?: 0.0).coerceIn(0.0, 1.0)
((1.0 - progress) * avgPagesPerChapter.toDouble()).toLong().coerceAtLeast(0L)
}
val remainingWholeChapters = (remainingChapters - if (isCurrentChapterRead) 0 else 1).coerceAtLeast(0)
return currentRemainingPages * avgMsPerPage + remainingWholeChapters * avgPagesPerChapter * avgMsPerPage
}
private fun calculateAverageChapterDurationMs(
chapterDurations: Map<Long, Long>,
currentChapterId: Long,
currentChapterIndex: Int,
isCurrentChapterRead: Boolean,
currentChapterProgress: Double?,
limits: ReadingEtaLimits,
): Long? {
if (currentChapterIndex < 0) return null
val sampledDurations = orderedSeriesChapterIds
.take(currentChapterIndex + 1)
.mapNotNull { chapterId ->
val chapterDuration = chapterDurations[chapterId] ?: return@mapNotNull null
val normalizedDuration = when {
chapterId == currentChapterId && !isCurrentChapterRead -> {
calculateProjectedChapterDurationMs(
chapterDuration = chapterDuration,
chapterProgress = currentChapterProgress,
limits = limits,
)
}
else -> chapterDuration
} ?: return@mapNotNull null
normalizedDuration
}
.filter { it in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs }
.map { it.coerceAtMost(limits.maxChapterDurationMsForStats) }
.takeLast(limits.etaSampleChapterLimit)
if (sampledDurations.isEmpty()) return null
val sortedDurations = sampledDurations.sorted()
val trimCount = (sortedDurations.size / 10)
.coerceAtMost((sortedDurations.size - 1) / 2)
val trimmedDurations = if (trimCount > 0) {
sortedDurations.subList(trimCount, sortedDurations.size - trimCount)
} else {
sortedDurations
}
return trimmedDurations.sum() / trimmedDurations.size
}
private fun calculateChapterProgress(
currentPage: Int,
totalPages: Int,
): Double? {
if (currentPage <= 0 || totalPages <= 1) return null
return (currentPage.toDouble() / totalPages.toDouble()).coerceIn(0.0, 1.0)
}
private fun calculateProjectedChapterDurationMs(
chapterDuration: Long,
chapterProgress: Double?,
limits: ReadingEtaLimits,
): Long? {
if (chapterDuration <= 0L) return null
val progress = chapterProgress
?.takeIf { it >= MIN_PROGRESS_FOR_DURATION_PROJECTION }
?.coerceIn(0.0, 1.0)
?: return null
if (progress == 0.0) return null
return (chapterDuration.toDouble() / progress).toLong()
.coerceAtLeast(0L)
.coerceAtMost(limits.maxChapterDurationMsForStats)
}
/**
* 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(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(limits)
if (sessionMs < limits.minTrackedChapterDurationMs) 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(
chapterDuration: Long?,
chapterProgress: Double?,
): Long? {
val duration = chapterDuration?.takeIf { it > 0L } ?: return null
val remainingProgress = 1.0 - (chapterProgress ?: 0.0).coerceIn(0.0, 1.0)
return (duration.toDouble() * remainingProgress).toLong().coerceAtLeast(0L)
}
private fun Long.sanitizeTrackedDuration(limits: ReadingEtaLimits): Long { private fun Long.sanitizeTrackedDuration(limits: ReadingEtaLimits): Long {
return if (this in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs) this else 0L return if (this in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs) this else 0L
} }
@ -1842,10 +1642,6 @@ class ReaderViewModel @JvmOverloads constructor(
} }
} }
private companion object {
const val MIN_PROGRESS_FOR_DURATION_PROJECTION = 0.05
}
@Immutable @Immutable
data class State( data class State(
val manga: Manga? = null, val manga: Manga? = null,

View file

@ -57,6 +57,10 @@ class HistoryRepositoryImpl(
return handler.awaitList { historyQueries.getHistoryByMangaId(mangaId, HistoryMapper::mapHistory) } return handler.awaitList { historyQueries.getHistoryByMangaId(mangaId, HistoryMapper::mapHistory) }
} }
override fun subscribeToRevision(): Flow<Long> {
return handler.subscribeToOne { historyQueries.historyRevision() }
}
override suspend fun getCrossMangaPageRateHistorySamples( override suspend fun getCrossMangaPageRateHistorySamples(
excludeMangaId: Long, excludeMangaId: Long,
minDurationMs: Long, minDurationMs: Long,

View file

@ -83,6 +83,10 @@ getReadDuration:
SELECT coalesce(sum(time_read), 0) SELECT coalesce(sum(time_read), 0)
FROM history; FROM history;
historyRevision:
SELECT coalesce(sum(time_read), 0) + coalesce(sum(pages_read), 0) + count(*)
FROM history;
-- Recent completed-chapter history from other series, for data-driven ms/page ETA priors. -- Recent completed-chapter history from other series, for data-driven ms/page ETA priors.
getCrossMangaPageRateHistorySamples: getCrossMangaPageRateHistorySamples:
SELECT SELECT

View file

@ -0,0 +1,10 @@
package tachiyomi.domain.history.interactor
import kotlinx.coroutines.flow.Flow
import tachiyomi.domain.history.repository.HistoryRepository
class GetHistoryRevision(
private val repository: HistoryRepository,
) {
fun subscribe(): Flow<Long> = repository.subscribeToRevision()
}

View file

@ -23,6 +23,8 @@ interface HistoryRepository {
suspend fun getHistoryByMangaId(mangaId: Long): List<History> suspend fun getHistoryByMangaId(mangaId: Long): List<History>
fun subscribeToRevision(): Flow<Long>
suspend fun getCrossMangaPageRateHistorySamples( suspend fun getCrossMangaPageRateHistorySamples(
excludeMangaId: Long, excludeMangaId: Long,
minDurationMs: Long, minDurationMs: Long,

View file

@ -0,0 +1,311 @@
package tachiyomi.domain.reading
/**
* Shared series reading-time estimate used by the reader overlay and library badges.
*/
object SeriesReadingEtaCalculator {
const val MIN_PROGRESS_FOR_DURATION_PROJECTION = 0.05
data class ActiveSession(
val sessionDurationMs: Long,
val startPageIndex: Int,
val currentPageIndex: Int,
val totalPages: Int,
)
data class Input(
val orderedChapterIds: List<Long>,
val chapterReadById: Map<Long, Boolean>,
val currentChapterId: Long,
val currentPage: Int,
val totalPages: Int,
val chapterDurations: Map<Long, Long>,
val chapterPages: Map<Long, Long>,
val crossMangaPageRateSamples: List<ReadingEtaHistorySample>,
val limits: ReadingEtaLimits,
val activeSession: ActiveSession? = null,
)
data class Result(
val currentChapter: Int,
val totalChapters: Int,
val remainingChapters: Int,
val etaMillis: Long?,
)
fun estimate(input: Input): Result? {
if (input.orderedChapterIds.isEmpty()) return null
val chapterIndexById = input.orderedChapterIds
.withIndex()
.associate { (index, chapterId) -> chapterId to index }
val currentChapterIndex = chapterIndexById[input.currentChapterId] ?: return null
val totalChapters = input.orderedChapterIds.size
val isCurrentChapterRead = input.chapterReadById[input.currentChapterId] == true
val remainingChapters = (
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
).coerceAtLeast(0)
val chapterDurations = buildChapterDurations(input)
val chapterPages = buildChapterPages(input)
val currentChapterProgress = calculateChapterProgress(
currentPage = input.currentPage,
totalPages = input.totalPages,
)
val projectedCurrentChapterDuration =
estimateFullChapterMsFromActiveSession(input)
?.coerceAtMost(input.limits.maxChapterDurationMsForStats)
?: calculateProjectedChapterDurationMs(
chapterDuration = chapterDurations[input.currentChapterId] ?: 0L,
chapterProgress = currentChapterProgress,
limits = input.limits,
)
val averageChapterDuration = calculateAverageChapterDurationMs(
orderedChapterIds = input.orderedChapterIds,
chapterDurations = chapterDurations,
currentChapterId = input.currentChapterId,
currentChapterIndex = currentChapterIndex,
isCurrentChapterRead = isCurrentChapterRead,
currentChapterProgress = currentChapterProgress,
limits = input.limits,
)
val pageBasedEta = calculatePageBasedEtaMillis(
orderedChapterIds = input.orderedChapterIds,
chapterDurations = chapterDurations,
chapterPages = chapterPages,
currentChapterId = input.currentChapterId,
currentChapterIndex = currentChapterIndex,
isCurrentChapterRead = isCurrentChapterRead,
currentChapterProgress = currentChapterProgress,
totalPages = input.totalPages,
currentPage = input.currentPage,
remainingChapters = remainingChapters,
crossMangaPageRateSamples = input.crossMangaPageRateSamples,
limits = input.limits,
)
val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration
val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters
val chapterBasedEta = when {
totalChapters == 1 -> {
if (isCurrentChapterRead) {
0L
} else {
calculateRemainingChapterTimeMs(
chapterDuration = chapterDurationForCurrentChapter,
chapterProgress = currentChapterProgress,
) ?: chapterDurationForCurrentChapter
}
}
else -> {
when {
remainingChapters == 0 -> 0L
else -> chapterDurationForWholeChapters?.let { chapterDurationMs ->
val currentChapterRemainingMs = if (isCurrentChapterRead) {
0L
} else {
calculateRemainingChapterTimeMs(
chapterDuration = chapterDurationForCurrentChapter,
chapterProgress = currentChapterProgress,
) ?: chapterDurationForCurrentChapter ?: 0L
}
val remainingWholeChapters = (
remainingChapters - if (isCurrentChapterRead) 0 else 1
).coerceAtLeast(0)
currentChapterRemainingMs + (chapterDurationMs * remainingWholeChapters)
}
}
}
}
val etaMillis = (pageBasedEta ?: chapterBasedEta)?.coerceAtLeast(0L)
return Result(
currentChapter = currentChapterIndex + 1,
totalChapters = totalChapters,
remainingChapters = remainingChapters,
etaMillis = etaMillis,
)
}
private fun buildChapterDurations(input: Input): Map<Long, Long> {
val trackedDurations = input.chapterDurations.toMutableMap()
val activeSession = input.activeSession ?: return trackedDurations
val inProgressDuration = activeSession.sessionDurationMs
.sanitizeTrackedDuration(input.limits)
if (inProgressDuration > 0L) {
trackedDurations.merge(input.currentChapterId, inProgressDuration, Long::plus)
}
return trackedDurations
}
private fun buildChapterPages(input: Input): Map<Long, Long> {
val trackedPages = input.chapterPages.toMutableMap()
val activeSession = input.activeSession ?: return trackedPages
val inProgressPages = (
activeSession.currentPageIndex - activeSession.startPageIndex + 1L
).sanitizeTrackedPages(input.limits)
if (inProgressPages > 0L) {
trackedPages.merge(input.currentChapterId, inProgressPages, Long::plus)
}
return trackedPages
}
private fun estimateFullChapterMsFromActiveSession(input: Input): Long? {
val activeSession = input.activeSession ?: return null
if (activeSession.totalPages <= 1) return null
val sessionMs = activeSession.sessionDurationMs.sanitizeTrackedDuration(input.limits)
if (sessionMs < input.limits.minTrackedChapterDurationMs) return null
val pagesThisSession = (
activeSession.currentPageIndex - activeSession.startPageIndex + 1
).coerceAtLeast(1)
val msPerPage = sessionMs.toDouble() / pagesThisSession.toDouble()
val estimated = (msPerPage * activeSession.totalPages.toDouble()).toLong()
return estimated.takeIf { it > 0L }
}
private fun calculatePageBasedEtaMillis(
orderedChapterIds: List<Long>,
chapterDurations: Map<Long, Long>,
chapterPages: Map<Long, Long>,
currentChapterId: Long,
currentChapterIndex: Int,
isCurrentChapterRead: Boolean,
currentChapterProgress: Double?,
totalPages: Int,
currentPage: Int,
remainingChapters: Int,
crossMangaPageRateSamples: List<ReadingEtaHistorySample>,
limits: ReadingEtaLimits,
): Long? {
val seriesPageRatesMsPerPage = orderedChapterIds
.take(currentChapterIndex + 1)
.mapNotNull { chapterId ->
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(limits.etaSampleChapterLimit)
val sampledPages = orderedChapterIds
.take(currentChapterIndex + 1)
.mapNotNull { chapterPages[it]?.sanitizeTrackedPages(limits) }
.takeLast(limits.etaSampleChapterLimit)
if (sampledPages.isEmpty()) return null
val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L)
if (seriesPageRatesMsPerPage.isEmpty() && crossMangaPageRateSamples.isEmpty()) return null
val targetChapterPages = if (totalPages > 0) {
totalPages
} else {
avgPagesPerChapter.toInt().coerceAtLeast(1)
}
val avgMsPerPage = ReadingEtaPageRateEstimator.estimateBlendedMsPerPage(
targetChapterPages = targetChapterPages,
seriesPageRatesMsPerPage = seriesPageRatesMsPerPage,
crossMangaSamples = crossMangaPageRateSamples,
limits = limits,
) ?: return null
val currentRemainingPages = if (isCurrentChapterRead) {
0L
} else if (totalPages > 0 && currentPage > 0) {
(totalPages - currentPage).toLong().coerceAtLeast(0L)
} else {
val progress = (currentChapterProgress ?: 0.0).coerceIn(0.0, 1.0)
((1.0 - progress) * avgPagesPerChapter.toDouble()).toLong().coerceAtLeast(0L)
}
val remainingWholeChapters = (remainingChapters - if (isCurrentChapterRead) 0 else 1).coerceAtLeast(0)
return currentRemainingPages * avgMsPerPage + remainingWholeChapters * avgPagesPerChapter * avgMsPerPage
}
private fun calculateAverageChapterDurationMs(
orderedChapterIds: List<Long>,
chapterDurations: Map<Long, Long>,
currentChapterId: Long,
currentChapterIndex: Int,
isCurrentChapterRead: Boolean,
currentChapterProgress: Double?,
limits: ReadingEtaLimits,
): Long? {
if (currentChapterIndex < 0) return null
val sampledDurations = orderedChapterIds
.take(currentChapterIndex + 1)
.mapNotNull { chapterId ->
val chapterDuration = chapterDurations[chapterId] ?: return@mapNotNull null
val normalizedDuration = when {
chapterId == currentChapterId && !isCurrentChapterRead -> {
calculateProjectedChapterDurationMs(
chapterDuration = chapterDuration,
chapterProgress = currentChapterProgress,
limits = limits,
)
}
else -> chapterDuration
} ?: return@mapNotNull null
normalizedDuration
}
.filter { it in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs }
.map { it.coerceAtMost(limits.maxChapterDurationMsForStats) }
.takeLast(limits.etaSampleChapterLimit)
if (sampledDurations.isEmpty()) return null
val sortedDurations = sampledDurations.sorted()
val trimCount = (sortedDurations.size / 10)
.coerceAtMost((sortedDurations.size - 1) / 2)
val trimmedDurations = if (trimCount > 0) {
sortedDurations.subList(trimCount, sortedDurations.size - trimCount)
} else {
sortedDurations
}
return trimmedDurations.sum() / trimmedDurations.size
}
private fun calculateChapterProgress(
currentPage: Int,
totalPages: Int,
): Double? {
if (currentPage <= 0 || totalPages <= 1) return null
return (currentPage.toDouble() / totalPages.toDouble()).coerceIn(0.0, 1.0)
}
private fun calculateProjectedChapterDurationMs(
chapterDuration: Long,
chapterProgress: Double?,
limits: ReadingEtaLimits,
): Long? {
if (chapterDuration <= 0L) return null
val progress = chapterProgress
?.takeIf { it >= MIN_PROGRESS_FOR_DURATION_PROJECTION }
?.coerceIn(0.0, 1.0)
?: return null
if (progress == 0.0) return null
return (chapterDuration.toDouble() / progress).toLong()
.coerceAtLeast(0L)
.coerceAtMost(limits.maxChapterDurationMsForStats)
}
private fun calculateRemainingChapterTimeMs(
chapterDuration: Long?,
chapterProgress: Double?,
): Long? {
val duration = chapterDuration?.takeIf { it > 0L } ?: return null
val remainingProgress = 1.0 - (chapterProgress ?: 0.0).coerceIn(0.0, 1.0)
return (duration.toDouble() * remainingProgress).toLong().coerceAtLeast(0L)
}
private fun Long.sanitizeTrackedDuration(limits: ReadingEtaLimits): Long {
return if (this in limits.minTrackedChapterDurationMs..limits.maxTrackedChapterDurationMs) 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
}
}