diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 5b2b4aac8..a9dee6637 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -67,6 +67,7 @@ import tachiyomi.domain.chapter.interactor.UpdateChapter import tachiyomi.domain.chapter.repository.ChapterRepository import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples import tachiyomi.domain.history.interactor.GetHistory +import tachiyomi.domain.history.interactor.GetHistoryRevision import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds import tachiyomi.domain.history.interactor.GetTotalReadDuration @@ -173,6 +174,7 @@ class DomainModule : InjektModule { addSingletonFactory { HistoryRepositoryImpl(get()) } addFactory { GetHistory(get()) } + addFactory { GetHistoryRevision(get()) } addFactory { UpsertHistory(get()) } addFactory { RemoveHistory(get()) } addFactory { GetTotalReadDuration(get()) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index e0eac94dd..e40fb734c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -98,6 +98,9 @@ import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId import tachiyomi.domain.chapter.model.Chapter 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.GetTotalReadDuration 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.service.LibraryPreferences import tachiyomi.domain.reading.ReadingEtaLimits +import tachiyomi.domain.reading.SeriesReadingEtaCalculator import tachiyomi.domain.reading.service.ReadingEtaPreferences import tachiyomi.domain.manga.interactor.GetIdsOfFavoriteMangaWithMetadata import tachiyomi.domain.manga.interactor.GetLibraryManga @@ -153,6 +157,8 @@ class LibraryScreenModel( private val sourcePreferences: SourcePreferences = Injekt.get(), private val getMergedMangaById: GetMergedMangaById = 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 getTracks: GetTracks = Injekt.get(), private val getIdsOfFavoriteMangaWithMetadata: GetIdsOfFavoriteMangaWithMetadata = Injekt.get(), @@ -447,64 +453,57 @@ class LibraryScreenModel( ): Map { 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 -> - item.id to calculateEstimatedTimeLeftMs( - item = item, - chapterDurations = chapterDurationsByMangaId[item.id].orEmpty(), - globalAverageChapterDurationMs = globalAverageChapterDurationMs, - limits = limits, - ) + item.id to computeEstimatedTimeLeftMs(item, limits) } } - private fun calculateEstimatedTimeLeftMs( + private suspend fun computeEstimatedTimeLeftMs( item: LibraryItem, - chapterDurations: List, - globalAverageChapterDurationMs: Long?, limits: ReadingEtaLimits, ): Long? { - val unreadCount = item.libraryManga.unreadCount.coerceAtLeast(0L) - if (unreadCount == 0L) return null + if (item.libraryManga.unreadCount <= 0L) return null - val averageChapterDurationMs = calculateAverageChapterDurationMs(chapterDurations, limits) - ?: globalAverageChapterDurationMs - ?: return null - - val remainingChapterUnits = if (item.libraryManga.totalChapters == 1L) { - 1L + val manga = item.libraryManga.manga + val mergedManga = if (manga.source == MERGED_SOURCE_ID) { + getMergedMangaById.await(manga.id).associateBy { it.id } } else { - unreadCount + emptyMap() } - return (averageChapterDurationMs * remainingChapterUnits).coerceAtLeast(0L) - } - - private fun calculateAverageChapterDurationMs( - chapterDurations: List, - 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) + val rawChapters = if (manga.source == MERGED_SOURCE_ID) { + getMergedChaptersByMangaId.await(manga.id, applyFilter = true) } 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( @@ -906,7 +905,8 @@ class LibraryScreenModel( getLibraryManga.subscribe(), getLibraryItemPreferencesFlow(), downloadCache.changes, - ) { libraryManga, preferences, _ -> + getHistoryRevision.subscribe(), + ) { libraryManga, preferences, _, _ -> libraryManga.map { manga -> // Display mode based on user preference: take it from global library setting or category // KMK --> diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index db66137ee..b6c94e0de 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -99,9 +99,9 @@ import tachiyomi.domain.history.interactor.UpsertHistory import tachiyomi.domain.history.model.HistoryUpdate 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.SeriesReadingEtaCalculator import tachiyomi.domain.reading.service.ReadingEtaPreferences import tachiyomi.domain.manga.interactor.GetFlatMetadataById import tachiyomi.domain.manga.interactor.GetManga @@ -269,7 +269,6 @@ class ReaderViewModel @JvmOverloads constructor( private var crossMangaPageRateSamples: List = emptyList() private var orderedSeriesChapterIds = emptyList() - private var seriesChapterIndexById = emptyMap() private var chapterToDownload: Download? = null @@ -972,9 +971,6 @@ class ReaderViewModel @JvmOverloads constructor( orderedSeriesChapterIds = unfilteredChapterList .sortedWith(getChapterSort(manga, sortDescending = false)) .mapNotNull { it.id } - seriesChapterIndexById = orderedSeriesChapterIds - .withIndex() - .associate { (index, chapterId) -> chapterId to index } chapterReadDurationsSession.clear() chapterReadDurationsPersistent.clear() @@ -1000,94 +996,49 @@ class ReaderViewModel @JvmOverloads constructor( return } - val currentChapterIndex = seriesChapterIndexById[currentChapterId] - if (currentChapterIndex == null) { + val limits = readingEtaPreferences.currentLimits() + 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) } 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 { it.copy( seriesReadingProgress = SeriesReadingProgress( - currentChapter = currentChapterIndex + 1, - totalChapters = totalChapters, - remainingChapters = remainingChapters, - etaMillis = etaMillis, + currentChapter = result.currentChapter, + totalChapters = result.totalChapters, + remainingChapters = result.remainingChapters, + etaMillis = result.etaMillis, ), ) } @@ -1133,157 +1084,6 @@ class ReaderViewModel @JvmOverloads constructor( return trackedPages } - private fun calculatePageBasedEtaMillis( - chapterDurations: Map, - chapterPages: Map, - 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, - 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 { 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 data class State( val manga: Manga? = null, diff --git a/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt index e99e4219e..2fa1ad581 100644 --- a/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt @@ -57,6 +57,10 @@ class HistoryRepositoryImpl( return handler.awaitList { historyQueries.getHistoryByMangaId(mangaId, HistoryMapper::mapHistory) } } + override fun subscribeToRevision(): Flow { + return handler.subscribeToOne { historyQueries.historyRevision() } + } + override suspend fun getCrossMangaPageRateHistorySamples( excludeMangaId: Long, minDurationMs: Long, diff --git a/data/src/main/sqldelight/tachiyomi/data/history.sq b/data/src/main/sqldelight/tachiyomi/data/history.sq index 8482eece7..09e8e332d 100644 --- a/data/src/main/sqldelight/tachiyomi/data/history.sq +++ b/data/src/main/sqldelight/tachiyomi/data/history.sq @@ -83,6 +83,10 @@ getReadDuration: SELECT coalesce(sum(time_read), 0) 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. getCrossMangaPageRateHistorySamples: SELECT diff --git a/domain/src/main/java/tachiyomi/domain/history/interactor/GetHistoryRevision.kt b/domain/src/main/java/tachiyomi/domain/history/interactor/GetHistoryRevision.kt new file mode 100644 index 000000000..e4988b38c --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/history/interactor/GetHistoryRevision.kt @@ -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 = repository.subscribeToRevision() +} diff --git a/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt b/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt index 3d1522dc2..930732817 100644 --- a/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt +++ b/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt @@ -23,6 +23,8 @@ interface HistoryRepository { suspend fun getHistoryByMangaId(mangaId: Long): List + fun subscribeToRevision(): Flow + suspend fun getCrossMangaPageRateHistorySamples( excludeMangaId: Long, minDurationMs: Long, diff --git a/domain/src/main/java/tachiyomi/domain/reading/SeriesReadingEtaCalculator.kt b/domain/src/main/java/tachiyomi/domain/reading/SeriesReadingEtaCalculator.kt new file mode 100644 index 000000000..6fc91f487 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/reading/SeriesReadingEtaCalculator.kt @@ -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, + val chapterReadById: Map, + val currentChapterId: Long, + val currentPage: Int, + val totalPages: Int, + val chapterDurations: Map, + val chapterPages: Map, + val crossMangaPageRateSamples: List, + 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 { + 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 { + 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, + chapterDurations: Map, + chapterPages: Map, + currentChapterId: Long, + currentChapterIndex: Int, + isCurrentChapterRead: Boolean, + currentChapterProgress: Double?, + totalPages: Int, + currentPage: Int, + remainingChapters: Int, + crossMangaPageRateSamples: List, + 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, + chapterDurations: Map, + 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 + } +}