Reader: track per-page history metrics for ETA and backups
Persist per-session pages read in history, include it in backup/restore, and prefer a page-based ETA model so chapter-length variance (especially webtoons) is reflected more accurately. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
384aee7372
commit
fe43d3ef2c
10 changed files with 130 additions and 10 deletions
|
|
@ -108,7 +108,12 @@ class MangaBackupCreator(
|
|||
if (historyByMangaId.isNotEmpty()) {
|
||||
val history = historyByMangaId.map { history ->
|
||||
val chapter = handler.awaitOne { chaptersQueries.getChapterById(history.chapterId) }
|
||||
BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration)
|
||||
BackupHistory(
|
||||
url = chapter.url,
|
||||
lastRead = history.readAt?.time ?: 0L,
|
||||
readDuration = history.readDuration,
|
||||
readPages = history.readPages,
|
||||
)
|
||||
}
|
||||
if (history.isNotEmpty()) {
|
||||
mangaObject.history = history
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ data class BackupHistory(
|
|||
@ProtoNumber(1) var url: String,
|
||||
@ProtoNumber(2) var lastRead: Long,
|
||||
@ProtoNumber(3) var readDuration: Long = 0,
|
||||
@ProtoNumber(4) var readPages: Long = 0,
|
||||
) {
|
||||
fun getHistoryImpl(): History {
|
||||
return History.create().copy(
|
||||
readAt = Date(lastRead),
|
||||
readDuration = readDuration,
|
||||
readPages = readPages,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -427,6 +427,7 @@ class MangaRestorer(
|
|||
.takeIf { it > 0L }
|
||||
?.let { Date(it) },
|
||||
readDuration = max(item.readDuration, dbHistory.time_read) - dbHistory.time_read,
|
||||
readPages = max(item.readPages, dbHistory.pages_read) - dbHistory.pages_read,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -437,6 +438,7 @@ class MangaRestorer(
|
|||
it.chapterId,
|
||||
it.readAt,
|
||||
it.readDuration,
|
||||
it.readPages,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,9 +247,12 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
* The time the chapter was started reading
|
||||
*/
|
||||
private var chapterReadStartTime: Long? = null
|
||||
private var chapterReadStartPageIndex: Int? = null
|
||||
|
||||
private val chapterReadDurationsSession = mutableMapOf<Long, Long>()
|
||||
private val chapterReadDurationsPersistent = mutableMapOf<Long, Long>()
|
||||
private val chapterReadPagesSession = mutableMapOf<Long, Long>()
|
||||
private val chapterReadPagesPersistent = mutableMapOf<Long, Long>()
|
||||
|
||||
private var orderedSeriesChapterIds = emptyList<Long>()
|
||||
private var seriesChapterIndexById = emptyMap<Long, Int>()
|
||||
|
|
@ -910,6 +913,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
|
||||
fun restartReadTimer() {
|
||||
chapterReadStartTime = SystemClock.elapsedRealtime()
|
||||
chapterReadStartPageIndex = getCurrentChapter()?.chapter?.last_page_read ?: 0
|
||||
updateSeriesProgressState()
|
||||
}
|
||||
|
||||
|
|
@ -926,13 +930,24 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
?.let { SystemClock.elapsedRealtime() - it }
|
||||
?.sanitizeTrackedDuration()
|
||||
?: 0
|
||||
val sessionReadPages = chapterReadStartPageIndex
|
||||
?.let { startPageIndex ->
|
||||
val endPageIndex = readerChapter.chapter.last_page_read
|
||||
(endPageIndex - startPageIndex + 1L).sanitizeTrackedPages()
|
||||
}
|
||||
?: 0L
|
||||
|
||||
upsertHistory.await(HistoryUpdate(chapterId, endTime, sessionReadDuration))
|
||||
upsertHistory.await(HistoryUpdate(chapterId, endTime, sessionReadDuration, sessionReadPages))
|
||||
if (sessionReadDuration > 0) {
|
||||
chapterReadDurationsSession.merge(chapterId, sessionReadDuration, Long::plus)
|
||||
chapterReadDurationsPersistent.merge(chapterId, sessionReadDuration, Long::plus)
|
||||
}
|
||||
if (sessionReadPages > 0) {
|
||||
chapterReadPagesSession.merge(chapterId, sessionReadPages, Long::plus)
|
||||
chapterReadPagesPersistent.merge(chapterId, sessionReadPages, Long::plus)
|
||||
}
|
||||
chapterReadStartTime = null
|
||||
chapterReadStartPageIndex = null
|
||||
updateSeriesProgressState()
|
||||
}
|
||||
}
|
||||
|
|
@ -948,10 +963,16 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
|
||||
chapterReadDurationsSession.clear()
|
||||
chapterReadDurationsPersistent.clear()
|
||||
chapterReadPagesSession.clear()
|
||||
chapterReadPagesPersistent.clear()
|
||||
chapterReadDurationsPersistent.putAll(
|
||||
getHistory.await(manga.id)
|
||||
.associate { history -> history.chapterId to history.readDuration },
|
||||
)
|
||||
chapterReadPagesPersistent.putAll(
|
||||
getHistory.await(manga.id)
|
||||
.associate { history -> history.chapterId to history.readPages },
|
||||
)
|
||||
updateSeriesProgressState()
|
||||
}
|
||||
|
||||
|
|
@ -975,6 +996,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
|
||||
).coerceAtLeast(0)
|
||||
val chapterDurations = getEtaChapterDurations(currentChapterId)
|
||||
val chapterPages = getEtaChapterPages(currentChapterId)
|
||||
val currentChapterProgress = calculateChapterProgress(
|
||||
currentPage = state.value.currentPage,
|
||||
totalPages = state.value.totalPages,
|
||||
|
|
@ -990,10 +1012,20 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
isCurrentChapterRead = isCurrentChapterRead,
|
||||
currentChapterProgress = currentChapterProgress,
|
||||
)
|
||||
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,
|
||||
)
|
||||
val chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration
|
||||
val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters
|
||||
|
||||
val etaMillis = when {
|
||||
val chapterBasedEta = when {
|
||||
totalChapters == 1 -> {
|
||||
if (isCurrentChapterRead) {
|
||||
0L
|
||||
|
|
@ -1023,7 +1055,8 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
}?.coerceAtLeast(0L)
|
||||
}
|
||||
val etaMillis = (pageBasedEta ?: chapterBasedEta)?.coerceAtLeast(0L)
|
||||
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
|
|
@ -1059,6 +1092,66 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
return trackedDurations
|
||||
}
|
||||
|
||||
private fun getEtaChapterPages(currentChapterId: Long): Map<Long, Long> {
|
||||
val trackedPages = if (readerPreferences.persistentSeriesEta().get()) {
|
||||
chapterReadPagesPersistent.toMutableMap()
|
||||
} else {
|
||||
chapterReadPagesSession.toMutableMap()
|
||||
}
|
||||
val inProgressPages = chapterReadStartPageIndex
|
||||
?.let { startPageIndex ->
|
||||
val currentPageIndex = (state.value.currentPage - 1).coerceAtLeast(startPageIndex)
|
||||
(currentPageIndex - startPageIndex + 1L).sanitizeTrackedPages()
|
||||
}
|
||||
?: 0L
|
||||
if (inProgressPages > 0L) {
|
||||
trackedPages.merge(currentChapterId, inProgressPages, Long::plus)
|
||||
}
|
||||
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,
|
||||
): Long? {
|
||||
val pageRates = orderedSeriesChapterIds
|
||||
.take(currentChapterIndex + 1)
|
||||
.mapNotNull { chapterId ->
|
||||
val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration() ?: return@mapNotNull null
|
||||
val pages = chapterPages[chapterId]?.sanitizeTrackedPages() ?: return@mapNotNull null
|
||||
if (duration <= 0L || pages <= 0L) return@mapNotNull null
|
||||
duration.toDouble() / pages.toDouble()
|
||||
}
|
||||
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
|
||||
if (pageRates.isEmpty()) return null
|
||||
val avgMsPerPage = pageRates.average().toLong().coerceAtLeast(1L)
|
||||
|
||||
val sampledPages = orderedSeriesChapterIds
|
||||
.take(currentChapterIndex + 1)
|
||||
.mapNotNull { chapterPages[it]?.sanitizeTrackedPages() }
|
||||
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
|
||||
if (sampledPages.isEmpty()) return null
|
||||
val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L)
|
||||
|
||||
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,
|
||||
|
|
@ -1131,6 +1224,10 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
return if (this in MIN_TRACKED_CHAPTER_DURATION_MS..MAX_TRACKED_CHAPTER_DURATION_MS) this else 0L
|
||||
}
|
||||
|
||||
private fun Long.sanitizeTrackedPages(): Long {
|
||||
return if (this in MIN_TRACKED_PAGES..MAX_TRACKED_PAGES) this else 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the activity to load and set the next chapter as active.
|
||||
*/
|
||||
|
|
@ -1686,6 +1783,8 @@ class ReaderViewModel @JvmOverloads constructor(
|
|||
const val MAX_TRACKED_CHAPTER_DURATION_MS = 4 * 60 * 60 * 1_000L
|
||||
const val ETA_SAMPLE_CHAPTER_LIMIT = 20
|
||||
const val MIN_PROGRESS_FOR_DURATION_PROJECTION = 0.05
|
||||
const val MIN_TRACKED_PAGES = 1L
|
||||
const val MAX_TRACKED_PAGES = 2_000L
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ object HistoryMapper {
|
|||
chapterId: Long,
|
||||
readAt: Date?,
|
||||
readDuration: Long,
|
||||
readPages: Long,
|
||||
): History = History(
|
||||
id = id,
|
||||
chapterId = chapterId,
|
||||
readAt = readAt,
|
||||
readDuration = readDuration,
|
||||
readPages = readPages,
|
||||
)
|
||||
|
||||
fun mapHistoryWithRelations(
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ class HistoryRepositoryImpl(
|
|||
historyUpdate.chapterId,
|
||||
historyUpdate.readAt,
|
||||
historyUpdate.sessionReadDuration,
|
||||
historyUpdate.sessionReadPages,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -109,6 +110,7 @@ class HistoryRepositoryImpl(
|
|||
historyUpdate.chapterId,
|
||||
historyUpdate.readAt,
|
||||
historyUpdate.sessionReadDuration,
|
||||
historyUpdate.sessionReadPages,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ CREATE TABLE history(
|
|||
chapter_id INTEGER NOT NULL UNIQUE,
|
||||
last_read INTEGER AS Date,
|
||||
time_read INTEGER NOT NULL,
|
||||
pages_read INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(chapter_id) REFERENCES chapters (_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
|
@ -17,7 +18,8 @@ SELECT
|
|||
H._id,
|
||||
H.chapter_id,
|
||||
H.last_read,
|
||||
H.time_read
|
||||
H.time_read,
|
||||
H.pages_read
|
||||
FROM history H
|
||||
JOIN chapters C
|
||||
ON H.chapter_id = C._id
|
||||
|
|
@ -28,7 +30,8 @@ SELECT
|
|||
H._id,
|
||||
H.chapter_id,
|
||||
H.last_read,
|
||||
H.time_read
|
||||
H.time_read,
|
||||
H.pages_read
|
||||
FROM history H
|
||||
JOIN chapters C
|
||||
ON H.chapter_id = C._id
|
||||
|
|
@ -66,13 +69,14 @@ DELETE FROM history
|
|||
WHERE last_read = 0;
|
||||
|
||||
upsert:
|
||||
INSERT INTO history(chapter_id, last_read, time_read)
|
||||
VALUES (:chapterId, :readAt, :time_read)
|
||||
INSERT INTO history(chapter_id, last_read, time_read, pages_read)
|
||||
VALUES (:chapterId, :readAt, :time_read, :pages_read)
|
||||
ON CONFLICT(chapter_id)
|
||||
DO UPDATE
|
||||
SET
|
||||
last_read = :readAt,
|
||||
time_read = time_read + :time_read
|
||||
time_read = time_read + :time_read,
|
||||
pages_read = pages_read + :pages_read
|
||||
WHERE chapter_id = :chapterId;
|
||||
|
||||
getReadDuration:
|
||||
|
|
|
|||
1
data/src/main/sqldelight/tachiyomi/migrations/46.sqm
Normal file
1
data/src/main/sqldelight/tachiyomi/migrations/46.sqm
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE history ADD COLUMN pages_read INTEGER NOT NULL DEFAULT 0;
|
||||
|
|
@ -7,6 +7,7 @@ data class History(
|
|||
val chapterId: Long,
|
||||
val readAt: Date?,
|
||||
val readDuration: Long,
|
||||
val readPages: Long = 0,
|
||||
) {
|
||||
companion object {
|
||||
fun create() = History(
|
||||
|
|
@ -14,6 +15,7 @@ data class History(
|
|||
chapterId = -1L,
|
||||
readAt = null,
|
||||
readDuration = -1L,
|
||||
readPages = 0L,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,5 @@ data class HistoryUpdate(
|
|||
val chapterId: Long,
|
||||
val readAt: Date,
|
||||
val sessionReadDuration: Long,
|
||||
val sessionReadPages: Long = 0,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue