feat(eta): cross-manga page-rate priors for reading time left
All checks were successful
Forgejo Release Builder / release (push) Successful in 5m53s
All checks were successful
Forgejo Release Builder / release (push) Successful in 5m53s
Blend series-local ms/page with samples from completed chapters in other titles (similar page counts via ±15%/±30% bands). Removes fixed ms/page floors; adds SQL query + estimator + DI wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
07019175d1
commit
c15d280cb4
10 changed files with 235 additions and 14 deletions
|
|
@ -65,6 +65,7 @@ import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags
|
|||
import tachiyomi.domain.chapter.interactor.ShouldUpdateDbChapter
|
||||
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.GetNextChapters
|
||||
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
|
||||
|
|
@ -176,6 +177,7 @@ class DomainModule : InjektModule {
|
|||
addFactory { RemoveHistory(get()) }
|
||||
addFactory { GetTotalReadDuration(get()) }
|
||||
addFactory { GetReadDurationEntriesByMangaIds(get()) }
|
||||
addFactory { GetCrossMangaPageRateHistorySamples(get()) }
|
||||
|
||||
addFactory { DeleteDownload(get(), get()) }
|
||||
|
||||
|
|
|
|||
|
|
@ -92,11 +92,14 @@ import tachiyomi.domain.chapter.model.Chapter
|
|||
import tachiyomi.domain.chapter.model.ChapterUpdate
|
||||
import tachiyomi.domain.chapter.service.getChapterSort
|
||||
import tachiyomi.domain.download.service.DownloadPreferences
|
||||
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
|
||||
import tachiyomi.domain.history.interactor.GetHistory
|
||||
import tachiyomi.domain.history.interactor.GetNextChapters
|
||||
import tachiyomi.domain.history.interactor.UpsertHistory
|
||||
import tachiyomi.domain.history.model.HistoryUpdate
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
||||
import tachiyomi.domain.reading.ReadingEtaPageRateEstimator
|
||||
import tachiyomi.domain.reading.ReadingEtaDefaults
|
||||
import tachiyomi.domain.manga.interactor.GetFlatMetadataById
|
||||
import tachiyomi.domain.manga.interactor.GetManga
|
||||
|
|
@ -128,6 +131,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
|
||||
private val getNextChapters: GetNextChapters = Injekt.get(),
|
||||
private val getHistory: GetHistory = Injekt.get(),
|
||||
private val getCrossMangaPageRateHistorySamples: GetCrossMangaPageRateHistorySamples = Injekt.get(),
|
||||
private val upsertHistory: UpsertHistory = Injekt.get(),
|
||||
private val updateChapter: UpdateChapter = Injekt.get(),
|
||||
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
|
||||
|
|
@ -255,6 +259,12 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
private val chapterReadPagesSession = mutableMapOf<Long, Long>()
|
||||
private val chapterReadPagesPersistent = mutableMapOf<Long, Long>()
|
||||
|
||||
/**
|
||||
* Recent completed-chapter rows from manga other than the open title, used to infer ms/page
|
||||
* when the current series has few local samples ([ReadingEtaPageRateEstimator]).
|
||||
*/
|
||||
private var crossMangaPageRateSamples: List<ReadingEtaHistorySample> = emptyList()
|
||||
|
||||
private var orderedSeriesChapterIds = emptyList<Long>()
|
||||
private var seriesChapterIndexById = emptyMap<Long, Int>()
|
||||
|
||||
|
|
@ -974,6 +984,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
getHistory.await(manga.id)
|
||||
.associate { history -> history.chapterId to history.readPages },
|
||||
)
|
||||
crossMangaPageRateSamples = getCrossMangaPageRateHistorySamples.await(manga.id)
|
||||
updateSeriesProgressState()
|
||||
}
|
||||
|
||||
|
|
@ -1125,7 +1136,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
currentPage: Int,
|
||||
remainingChapters: Int,
|
||||
): Long? {
|
||||
val pageRates = orderedSeriesChapterIds
|
||||
val seriesPageRatesMsPerPage = orderedSeriesChapterIds
|
||||
.take(currentChapterIndex + 1)
|
||||
.mapNotNull { chapterId ->
|
||||
val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration() ?: return@mapNotNull null
|
||||
|
|
@ -1134,11 +1145,6 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
duration.toDouble() / pages.toDouble()
|
||||
}
|
||||
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
|
||||
if (pageRates.isEmpty()) return null
|
||||
var avgMsPerPage = pageRates.average().toLong().coerceAtLeast(1L)
|
||||
if (pageRates.size < ReadingEtaDefaults.MIN_TRUSTED_PAGE_RATE_SAMPLES) {
|
||||
avgMsPerPage = maxOf(avgMsPerPage, ReadingEtaDefaults.FALLBACK_MS_PER_PAGE)
|
||||
}
|
||||
|
||||
val sampledPages = orderedSeriesChapterIds
|
||||
.take(currentChapterIndex + 1)
|
||||
|
|
@ -1147,6 +1153,20 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
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,
|
||||
) ?: return null
|
||||
|
||||
val currentRemainingPages = if (isCurrentChapterRead) {
|
||||
0L
|
||||
} else if (totalPages > 0 && currentPage > 0) {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,22 @@ package tachiyomi.data.history
|
|||
import tachiyomi.domain.history.model.History
|
||||
import tachiyomi.domain.history.model.HistoryWithRelations
|
||||
import tachiyomi.domain.manga.model.MangaCover
|
||||
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
||||
import java.util.Date
|
||||
|
||||
object HistoryMapper {
|
||||
fun mapReadingEtaHistorySample(
|
||||
readDuration: Long,
|
||||
readPages: Long,
|
||||
lastPageRead: Long,
|
||||
chapterRead: Boolean,
|
||||
): ReadingEtaHistorySample = ReadingEtaHistorySample(
|
||||
readDurationMs = readDuration,
|
||||
pagesRead = readPages,
|
||||
chapterFullyRead = chapterRead,
|
||||
lastPageReadIndex = lastPageRead,
|
||||
)
|
||||
|
||||
fun mapHistory(
|
||||
id: Long,
|
||||
chapterId: Long,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import tachiyomi.domain.history.model.HistoryUpdate
|
|||
import tachiyomi.domain.history.model.HistoryWithRelations
|
||||
import tachiyomi.domain.history.repository.HistoryRepository
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
||||
|
||||
class HistoryRepositoryImpl(
|
||||
private val handler: DatabaseHandler,
|
||||
|
|
@ -56,6 +57,25 @@ class HistoryRepositoryImpl(
|
|||
return handler.awaitList { historyQueries.getHistoryByMangaId(mangaId, HistoryMapper::mapHistory) }
|
||||
}
|
||||
|
||||
override suspend fun getCrossMangaPageRateHistorySamples(
|
||||
excludeMangaId: Long,
|
||||
minDurationMs: Long,
|
||||
maxDurationMs: Long,
|
||||
maxPagesRead: Long,
|
||||
limit: Long,
|
||||
): List<ReadingEtaHistorySample> {
|
||||
return handler.awaitList {
|
||||
historyQueries.getCrossMangaPageRateHistorySamples(
|
||||
excludeMangaId,
|
||||
minDurationMs,
|
||||
maxDurationMs,
|
||||
maxPagesRead,
|
||||
limit,
|
||||
HistoryMapper::mapReadingEtaHistorySample,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// KMK -->
|
||||
override suspend fun resetHistory(historyIds: List<Long>) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -82,3 +82,20 @@ WHERE chapter_id = :chapterId;
|
|||
getReadDuration:
|
||||
SELECT coalesce(sum(time_read), 0)
|
||||
FROM history;
|
||||
|
||||
-- Recent completed-chapter history from other series, for data-driven ms/page ETA priors.
|
||||
getCrossMangaPageRateHistorySamples:
|
||||
SELECT
|
||||
H.time_read,
|
||||
H.pages_read,
|
||||
C.last_page_read,
|
||||
C.read
|
||||
FROM history H
|
||||
JOIN chapters C ON H.chapter_id = C._id
|
||||
WHERE C.manga_id != :excludeMangaId
|
||||
AND H.time_read >= :minDurationMs
|
||||
AND H.time_read <= :maxDurationMs
|
||||
AND H.pages_read >= 1
|
||||
AND H.pages_read <= :maxPagesRead
|
||||
ORDER BY H.last_read DESC
|
||||
LIMIT :limit;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package tachiyomi.domain.history.interactor
|
||||
|
||||
import tachiyomi.domain.history.repository.HistoryRepository
|
||||
import tachiyomi.domain.reading.ReadingEtaDefaults
|
||||
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
||||
|
||||
class GetCrossMangaPageRateHistorySamples(
|
||||
private val repository: HistoryRepository,
|
||||
) {
|
||||
suspend fun await(excludeMangaId: Long): List<ReadingEtaHistorySample> {
|
||||
return repository.getCrossMangaPageRateHistorySamples(
|
||||
excludeMangaId = excludeMangaId,
|
||||
minDurationMs = ReadingEtaDefaults.MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE,
|
||||
maxDurationMs = ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE,
|
||||
maxPagesRead = ReadingEtaDefaults.MAX_PAGES_READ_FOR_PAGE_RATE,
|
||||
limit = ReadingEtaDefaults.CROSS_MANGA_PAGE_RATE_SAMPLE_LIMIT,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow
|
|||
import tachiyomi.domain.history.model.History
|
||||
import tachiyomi.domain.history.model.HistoryUpdate
|
||||
import tachiyomi.domain.history.model.HistoryWithRelations
|
||||
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
||||
|
||||
interface HistoryRepository {
|
||||
|
||||
|
|
@ -22,6 +23,14 @@ interface HistoryRepository {
|
|||
|
||||
suspend fun getHistoryByMangaId(mangaId: Long): List<History>
|
||||
|
||||
suspend fun getCrossMangaPageRateHistorySamples(
|
||||
excludeMangaId: Long,
|
||||
minDurationMs: Long,
|
||||
maxDurationMs: Long,
|
||||
maxPagesRead: Long,
|
||||
limit: Long,
|
||||
): List<ReadingEtaHistorySample>
|
||||
|
||||
// KMK -->
|
||||
suspend fun resetHistory(historyIds: List<Long>)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@ package tachiyomi.domain.reading
|
|||
|
||||
/**
|
||||
* Shared defaults for "time left" / reading-speed estimates in the library and reader.
|
||||
* These cap idle-inflated history and avoid ultra-optimistic page rates from skimming
|
||||
* the first few pages.
|
||||
*
|
||||
* **Page-rate path (reader series ETA):** ms/page blends recent chapters in the **current**
|
||||
* series with a **prior** from **other** manga: completed chapters whose page count
|
||||
* (last page index + 1) falls in a band around the target length. That replaces a fixed
|
||||
* minimum ms/page when local samples are thin. Session-based projection and the stats cap
|
||||
* below still apply.
|
||||
*/
|
||||
object ReadingEtaDefaults {
|
||||
|
||||
|
|
@ -13,11 +17,23 @@ object ReadingEtaDefaults {
|
|||
*/
|
||||
const val MAX_CHAPTER_DURATION_MS_FOR_STATS: Long = 45 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* If we have few per-chapter page-rate samples, do not go below this ms/page
|
||||
* (avoids "2 minutes for the whole series" after a quick flip through ch.1).
|
||||
*/
|
||||
const val FALLBACK_MS_PER_PAGE: Long = 12_000L
|
||||
/** Same lower bound as reader tracking: ignore ultra-short noise. */
|
||||
const val MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = 2_000L
|
||||
|
||||
const val MIN_TRUSTED_PAGE_RATE_SAMPLES: Int = 3
|
||||
/** Ignore pathological totals when mining history for page-rate priors. */
|
||||
const val MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = 4L * 60 * 60 * 1000L
|
||||
|
||||
const val MIN_PAGES_READ_FOR_PAGE_RATE: Long = 1L
|
||||
|
||||
/** Match reader sanitization so cross-manga rows stay comparable. */
|
||||
const val MAX_PAGES_READ_FOR_PAGE_RATE: Long = 2_000L
|
||||
|
||||
/** Recent history rows scanned for cross-series priors (newest first). */
|
||||
const val CROSS_MANGA_PAGE_RATE_SAMPLE_LIMIT: Long = 500L
|
||||
|
||||
/** “Similar chapter” length: ± this fraction of target page count (inner band). */
|
||||
const val SIMILAR_CHAPTER_PAGE_COUNT_RATIO: Double = 0.15
|
||||
|
||||
/** Widen the page-count band if the inner band has no usable samples. */
|
||||
const val SIMILAR_CHAPTER_PAGE_COUNT_RATIO_WIDENED: Double = 0.30
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
package tachiyomi.domain.reading
|
||||
|
||||
/**
|
||||
* One row of reading history joined with chapter progress, used to infer ms/page from
|
||||
* [readDurationMs] / [pagesRead] when chapter length is approximated from [lastPageReadIndex].
|
||||
*/
|
||||
data class ReadingEtaHistorySample(
|
||||
val readDurationMs: Long,
|
||||
val pagesRead: Long,
|
||||
val chapterFullyRead: Boolean,
|
||||
val lastPageReadIndex: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
package tachiyomi.domain.reading
|
||||
|
||||
/**
|
||||
* Blends per-series page rates with a **prior** from other manga: completed chapters whose
|
||||
* length (last page index + 1) is close to the target page count. Replaces a fixed slow
|
||||
* “minimum ms/page” floor when the current series has little local data.
|
||||
*/
|
||||
object ReadingEtaPageRateEstimator {
|
||||
|
||||
/**
|
||||
* @param targetChapterPages current chapter length, or a recent average when unknown
|
||||
* @param seriesPageRatesMsPerPage ms/page from already-read chapters in this series (same order as today)
|
||||
*/
|
||||
fun estimateBlendedMsPerPage(
|
||||
targetChapterPages: Int,
|
||||
seriesPageRatesMsPerPage: List<Double>,
|
||||
crossMangaSamples: List<ReadingEtaHistorySample>,
|
||||
): Long? {
|
||||
val n = seriesPageRatesMsPerPage.size
|
||||
val seriesAvg = if (n > 0) seriesPageRatesMsPerPage.average() else null
|
||||
val prior = estimatePriorMsPerPageFromCrossManga(targetChapterPages, crossMangaSamples)
|
||||
|
||||
return when {
|
||||
n >= 3 && seriesAvg != null -> seriesAvg.toLong().coerceAtLeast(1L)
|
||||
n == 0 -> prior?.coerceAtLeast(1L)
|
||||
seriesAvg == null -> prior?.coerceAtLeast(1L)
|
||||
prior == null -> seriesAvg.toLong().coerceAtLeast(1L)
|
||||
else -> {
|
||||
val blended = (seriesAvg * n + prior * (3 - n)) / 3.0
|
||||
blended.toLong().coerceAtLeast(1L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun estimatePriorMsPerPageFromCrossManga(
|
||||
targetChapterPages: Int,
|
||||
samples: List<ReadingEtaHistorySample>,
|
||||
): Long? {
|
||||
if (targetChapterPages <= 0) return null
|
||||
val ratesWithSize = samples.mapNotNull { toRateWithChapterPages(it) }
|
||||
if (ratesWithSize.isEmpty()) return null
|
||||
|
||||
val inner = ratesWithinPageBand(ratesWithSize, targetChapterPages, ReadingEtaDefaults.SIMILAR_CHAPTER_PAGE_COUNT_RATIO)
|
||||
medianMsPerPage(inner)?.let { return it }
|
||||
|
||||
val wide = ratesWithinPageBand(ratesWithSize, targetChapterPages, ReadingEtaDefaults.SIMILAR_CHAPTER_PAGE_COUNT_RATIO_WIDENED)
|
||||
medianMsPerPage(wide)?.let { return it }
|
||||
|
||||
return medianMsPerPage(ratesWithSize.map { it.first })
|
||||
}
|
||||
|
||||
private fun toRateWithChapterPages(sample: ReadingEtaHistorySample): Pair<Double, Int>? {
|
||||
if (!sample.chapterFullyRead) return null
|
||||
val chapterPages = (sample.lastPageReadIndex + 1L).toInt().coerceAtLeast(1)
|
||||
if (sample.pagesRead < ReadingEtaDefaults.MIN_PAGES_READ_FOR_PAGE_RATE) return null
|
||||
if (sample.pagesRead > ReadingEtaDefaults.MAX_PAGES_READ_FOR_PAGE_RATE) return null
|
||||
if (sample.readDurationMs < ReadingEtaDefaults.MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE) return null
|
||||
if (sample.readDurationMs > ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_PAGE_RATE) return null
|
||||
if (sample.pagesRead > chapterPages * 2L) return null
|
||||
|
||||
val capped = sample.readDurationMs.coerceAtMost(ReadingEtaDefaults.MAX_CHAPTER_DURATION_MS_FOR_STATS)
|
||||
val msPerPage = capped.toDouble() / sample.pagesRead.toDouble()
|
||||
if (!msPerPage.isFinite() || msPerPage <= 0.0) return null
|
||||
return msPerPage to chapterPages
|
||||
}
|
||||
|
||||
private fun ratesWithinPageBand(
|
||||
ratesWithSize: List<Pair<Double, Int>>,
|
||||
targetChapterPages: Int,
|
||||
bandRatio: Double,
|
||||
): List<Double> {
|
||||
val target = targetChapterPages.toDouble().coerceAtLeast(1.0)
|
||||
val low = target * (1.0 - bandRatio)
|
||||
val high = target * (1.0 + bandRatio)
|
||||
return ratesWithSize.mapNotNull { (rate, pages) ->
|
||||
val p = pages.toDouble()
|
||||
if (p in low..high) rate else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun medianMsPerPage(values: List<Double>): Long? {
|
||||
if (values.isEmpty()) return null
|
||||
val sorted = values.sorted()
|
||||
val mid = sorted.size / 2
|
||||
val med = if (sorted.size % 2 == 1) {
|
||||
sorted[mid]
|
||||
} else {
|
||||
(sorted[mid - 1] + sorted[mid]) / 2.0
|
||||
}
|
||||
if (!med.isFinite() || med <= 0.0) return null
|
||||
return med.toLong().coerceAtLeast(1L)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue