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()) {
|
if (historyByMangaId.isNotEmpty()) {
|
||||||
val history = historyByMangaId.map { history ->
|
val history = historyByMangaId.map { history ->
|
||||||
val chapter = handler.awaitOne { chaptersQueries.getChapterById(history.chapterId) }
|
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()) {
|
if (history.isNotEmpty()) {
|
||||||
mangaObject.history = history
|
mangaObject.history = history
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,13 @@ data class BackupHistory(
|
||||||
@ProtoNumber(1) var url: String,
|
@ProtoNumber(1) var url: String,
|
||||||
@ProtoNumber(2) var lastRead: Long,
|
@ProtoNumber(2) var lastRead: Long,
|
||||||
@ProtoNumber(3) var readDuration: Long = 0,
|
@ProtoNumber(3) var readDuration: Long = 0,
|
||||||
|
@ProtoNumber(4) var readPages: Long = 0,
|
||||||
) {
|
) {
|
||||||
fun getHistoryImpl(): History {
|
fun getHistoryImpl(): History {
|
||||||
return History.create().copy(
|
return History.create().copy(
|
||||||
readAt = Date(lastRead),
|
readAt = Date(lastRead),
|
||||||
readDuration = readDuration,
|
readDuration = readDuration,
|
||||||
|
readPages = readPages,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -427,6 +427,7 @@ class MangaRestorer(
|
||||||
.takeIf { it > 0L }
|
.takeIf { it > 0L }
|
||||||
?.let { Date(it) },
|
?.let { Date(it) },
|
||||||
readDuration = max(item.readDuration, dbHistory.time_read) - dbHistory.time_read,
|
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.chapterId,
|
||||||
it.readAt,
|
it.readAt,
|
||||||
it.readDuration,
|
it.readDuration,
|
||||||
|
it.readPages,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -247,9 +247,12 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
* The time the chapter was started reading
|
* The time the chapter was started reading
|
||||||
*/
|
*/
|
||||||
private var chapterReadStartTime: Long? = null
|
private var chapterReadStartTime: Long? = null
|
||||||
|
private var chapterReadStartPageIndex: Int? = null
|
||||||
|
|
||||||
private val chapterReadDurationsSession = mutableMapOf<Long, Long>()
|
private val chapterReadDurationsSession = mutableMapOf<Long, Long>()
|
||||||
private val chapterReadDurationsPersistent = 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 orderedSeriesChapterIds = emptyList<Long>()
|
||||||
private var seriesChapterIndexById = emptyMap<Long, Int>()
|
private var seriesChapterIndexById = emptyMap<Long, Int>()
|
||||||
|
|
@ -910,6 +913,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
|
|
||||||
fun restartReadTimer() {
|
fun restartReadTimer() {
|
||||||
chapterReadStartTime = SystemClock.elapsedRealtime()
|
chapterReadStartTime = SystemClock.elapsedRealtime()
|
||||||
|
chapterReadStartPageIndex = getCurrentChapter()?.chapter?.last_page_read ?: 0
|
||||||
updateSeriesProgressState()
|
updateSeriesProgressState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -926,13 +930,24 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
?.let { SystemClock.elapsedRealtime() - it }
|
?.let { SystemClock.elapsedRealtime() - it }
|
||||||
?.sanitizeTrackedDuration()
|
?.sanitizeTrackedDuration()
|
||||||
?: 0
|
?: 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) {
|
if (sessionReadDuration > 0) {
|
||||||
chapterReadDurationsSession.merge(chapterId, sessionReadDuration, Long::plus)
|
chapterReadDurationsSession.merge(chapterId, sessionReadDuration, Long::plus)
|
||||||
chapterReadDurationsPersistent.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
|
chapterReadStartTime = null
|
||||||
|
chapterReadStartPageIndex = null
|
||||||
updateSeriesProgressState()
|
updateSeriesProgressState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -948,10 +963,16 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
|
|
||||||
chapterReadDurationsSession.clear()
|
chapterReadDurationsSession.clear()
|
||||||
chapterReadDurationsPersistent.clear()
|
chapterReadDurationsPersistent.clear()
|
||||||
|
chapterReadPagesSession.clear()
|
||||||
|
chapterReadPagesPersistent.clear()
|
||||||
chapterReadDurationsPersistent.putAll(
|
chapterReadDurationsPersistent.putAll(
|
||||||
getHistory.await(manga.id)
|
getHistory.await(manga.id)
|
||||||
.associate { history -> history.chapterId to history.readDuration },
|
.associate { history -> history.chapterId to history.readDuration },
|
||||||
)
|
)
|
||||||
|
chapterReadPagesPersistent.putAll(
|
||||||
|
getHistory.await(manga.id)
|
||||||
|
.associate { history -> history.chapterId to history.readPages },
|
||||||
|
)
|
||||||
updateSeriesProgressState()
|
updateSeriesProgressState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -975,6 +996,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
|
totalChapters - currentChapterIndex - if (isCurrentChapterRead) 1 else 0
|
||||||
).coerceAtLeast(0)
|
).coerceAtLeast(0)
|
||||||
val chapterDurations = getEtaChapterDurations(currentChapterId)
|
val chapterDurations = getEtaChapterDurations(currentChapterId)
|
||||||
|
val chapterPages = getEtaChapterPages(currentChapterId)
|
||||||
val currentChapterProgress = calculateChapterProgress(
|
val currentChapterProgress = calculateChapterProgress(
|
||||||
currentPage = state.value.currentPage,
|
currentPage = state.value.currentPage,
|
||||||
totalPages = state.value.totalPages,
|
totalPages = state.value.totalPages,
|
||||||
|
|
@ -990,10 +1012,20 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
isCurrentChapterRead = isCurrentChapterRead,
|
isCurrentChapterRead = isCurrentChapterRead,
|
||||||
currentChapterProgress = currentChapterProgress,
|
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 chapterDurationForWholeChapters = averageChapterDuration ?: projectedCurrentChapterDuration
|
||||||
val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters
|
val chapterDurationForCurrentChapter = projectedCurrentChapterDuration ?: chapterDurationForWholeChapters
|
||||||
|
val chapterBasedEta = when {
|
||||||
val etaMillis = when {
|
|
||||||
totalChapters == 1 -> {
|
totalChapters == 1 -> {
|
||||||
if (isCurrentChapterRead) {
|
if (isCurrentChapterRead) {
|
||||||
0L
|
0L
|
||||||
|
|
@ -1023,7 +1055,8 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}?.coerceAtLeast(0L)
|
}
|
||||||
|
val etaMillis = (pageBasedEta ?: chapterBasedEta)?.coerceAtLeast(0L)
|
||||||
|
|
||||||
mutableState.update {
|
mutableState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
@ -1059,6 +1092,66 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
return trackedDurations
|
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(
|
private fun calculateAverageChapterDurationMs(
|
||||||
chapterDurations: Map<Long, Long>,
|
chapterDurations: Map<Long, Long>,
|
||||||
currentChapterId: 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
|
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.
|
* 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 MAX_TRACKED_CHAPTER_DURATION_MS = 4 * 60 * 60 * 1_000L
|
||||||
const val ETA_SAMPLE_CHAPTER_LIMIT = 20
|
const val ETA_SAMPLE_CHAPTER_LIMIT = 20
|
||||||
const val MIN_PROGRESS_FOR_DURATION_PROJECTION = 0.05
|
const val MIN_PROGRESS_FOR_DURATION_PROJECTION = 0.05
|
||||||
|
const val MIN_TRACKED_PAGES = 1L
|
||||||
|
const val MAX_TRACKED_PAGES = 2_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,13 @@ object HistoryMapper {
|
||||||
chapterId: Long,
|
chapterId: Long,
|
||||||
readAt: Date?,
|
readAt: Date?,
|
||||||
readDuration: Long,
|
readDuration: Long,
|
||||||
|
readPages: Long,
|
||||||
): History = History(
|
): History = History(
|
||||||
id = id,
|
id = id,
|
||||||
chapterId = chapterId,
|
chapterId = chapterId,
|
||||||
readAt = readAt,
|
readAt = readAt,
|
||||||
readDuration = readDuration,
|
readDuration = readDuration,
|
||||||
|
readPages = readPages,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun mapHistoryWithRelations(
|
fun mapHistoryWithRelations(
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ class HistoryRepositoryImpl(
|
||||||
historyUpdate.chapterId,
|
historyUpdate.chapterId,
|
||||||
historyUpdate.readAt,
|
historyUpdate.readAt,
|
||||||
historyUpdate.sessionReadDuration,
|
historyUpdate.sessionReadDuration,
|
||||||
|
historyUpdate.sessionReadPages,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -109,6 +110,7 @@ class HistoryRepositoryImpl(
|
||||||
historyUpdate.chapterId,
|
historyUpdate.chapterId,
|
||||||
historyUpdate.readAt,
|
historyUpdate.readAt,
|
||||||
historyUpdate.sessionReadDuration,
|
historyUpdate.sessionReadDuration,
|
||||||
|
historyUpdate.sessionReadPages,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ CREATE TABLE history(
|
||||||
chapter_id INTEGER NOT NULL UNIQUE,
|
chapter_id INTEGER NOT NULL UNIQUE,
|
||||||
last_read INTEGER AS Date,
|
last_read INTEGER AS Date,
|
||||||
time_read INTEGER NOT NULL,
|
time_read INTEGER NOT NULL,
|
||||||
|
pages_read INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY(chapter_id) REFERENCES chapters (_id)
|
FOREIGN KEY(chapter_id) REFERENCES chapters (_id)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
@ -17,7 +18,8 @@ SELECT
|
||||||
H._id,
|
H._id,
|
||||||
H.chapter_id,
|
H.chapter_id,
|
||||||
H.last_read,
|
H.last_read,
|
||||||
H.time_read
|
H.time_read,
|
||||||
|
H.pages_read
|
||||||
FROM history H
|
FROM history H
|
||||||
JOIN chapters C
|
JOIN chapters C
|
||||||
ON H.chapter_id = C._id
|
ON H.chapter_id = C._id
|
||||||
|
|
@ -28,7 +30,8 @@ SELECT
|
||||||
H._id,
|
H._id,
|
||||||
H.chapter_id,
|
H.chapter_id,
|
||||||
H.last_read,
|
H.last_read,
|
||||||
H.time_read
|
H.time_read,
|
||||||
|
H.pages_read
|
||||||
FROM history H
|
FROM history H
|
||||||
JOIN chapters C
|
JOIN chapters C
|
||||||
ON H.chapter_id = C._id
|
ON H.chapter_id = C._id
|
||||||
|
|
@ -66,13 +69,14 @@ DELETE FROM history
|
||||||
WHERE last_read = 0;
|
WHERE last_read = 0;
|
||||||
|
|
||||||
upsert:
|
upsert:
|
||||||
INSERT INTO history(chapter_id, last_read, time_read)
|
INSERT INTO history(chapter_id, last_read, time_read, pages_read)
|
||||||
VALUES (:chapterId, :readAt, :time_read)
|
VALUES (:chapterId, :readAt, :time_read, :pages_read)
|
||||||
ON CONFLICT(chapter_id)
|
ON CONFLICT(chapter_id)
|
||||||
DO UPDATE
|
DO UPDATE
|
||||||
SET
|
SET
|
||||||
last_read = :readAt,
|
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;
|
WHERE chapter_id = :chapterId;
|
||||||
|
|
||||||
getReadDuration:
|
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 chapterId: Long,
|
||||||
val readAt: Date?,
|
val readAt: Date?,
|
||||||
val readDuration: Long,
|
val readDuration: Long,
|
||||||
|
val readPages: Long = 0,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun create() = History(
|
fun create() = History(
|
||||||
|
|
@ -14,6 +15,7 @@ data class History(
|
||||||
chapterId = -1L,
|
chapterId = -1L,
|
||||||
readAt = null,
|
readAt = null,
|
||||||
readDuration = -1L,
|
readDuration = -1L,
|
||||||
|
readPages = 0L,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,5 @@ data class HistoryUpdate(
|
||||||
val chapterId: Long,
|
val chapterId: Long,
|
||||||
val readAt: Date,
|
val readAt: Date,
|
||||||
val sessionReadDuration: Long,
|
val sessionReadDuration: Long,
|
||||||
|
val sessionReadPages: Long = 0,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue