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.ShouldUpdateDbChapter
|
||||||
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
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.GetHistory
|
import tachiyomi.domain.history.interactor.GetHistory
|
||||||
import tachiyomi.domain.history.interactor.GetNextChapters
|
import tachiyomi.domain.history.interactor.GetNextChapters
|
||||||
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
|
import tachiyomi.domain.history.interactor.GetReadDurationEntriesByMangaIds
|
||||||
|
|
@ -176,6 +177,7 @@ class DomainModule : InjektModule {
|
||||||
addFactory { RemoveHistory(get()) }
|
addFactory { RemoveHistory(get()) }
|
||||||
addFactory { GetTotalReadDuration(get()) }
|
addFactory { GetTotalReadDuration(get()) }
|
||||||
addFactory { GetReadDurationEntriesByMangaIds(get()) }
|
addFactory { GetReadDurationEntriesByMangaIds(get()) }
|
||||||
|
addFactory { GetCrossMangaPageRateHistorySamples(get()) }
|
||||||
|
|
||||||
addFactory { DeleteDownload(get(), 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.model.ChapterUpdate
|
||||||
import tachiyomi.domain.chapter.service.getChapterSort
|
import tachiyomi.domain.chapter.service.getChapterSort
|
||||||
import tachiyomi.domain.download.service.DownloadPreferences
|
import tachiyomi.domain.download.service.DownloadPreferences
|
||||||
|
import tachiyomi.domain.history.interactor.GetCrossMangaPageRateHistorySamples
|
||||||
import tachiyomi.domain.history.interactor.GetHistory
|
import tachiyomi.domain.history.interactor.GetHistory
|
||||||
import tachiyomi.domain.history.interactor.GetNextChapters
|
import tachiyomi.domain.history.interactor.GetNextChapters
|
||||||
import tachiyomi.domain.history.interactor.UpsertHistory
|
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.ReadingEtaPageRateEstimator
|
||||||
import tachiyomi.domain.reading.ReadingEtaDefaults
|
import tachiyomi.domain.reading.ReadingEtaDefaults
|
||||||
import tachiyomi.domain.manga.interactor.GetFlatMetadataById
|
import tachiyomi.domain.manga.interactor.GetFlatMetadataById
|
||||||
import tachiyomi.domain.manga.interactor.GetManga
|
import tachiyomi.domain.manga.interactor.GetManga
|
||||||
|
|
@ -128,6 +131,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
|
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
|
||||||
private val getNextChapters: GetNextChapters = Injekt.get(),
|
private val getNextChapters: GetNextChapters = Injekt.get(),
|
||||||
private val getHistory: GetHistory = Injekt.get(),
|
private val getHistory: GetHistory = Injekt.get(),
|
||||||
|
private val getCrossMangaPageRateHistorySamples: GetCrossMangaPageRateHistorySamples = Injekt.get(),
|
||||||
private val upsertHistory: UpsertHistory = Injekt.get(),
|
private val upsertHistory: UpsertHistory = Injekt.get(),
|
||||||
private val updateChapter: UpdateChapter = Injekt.get(),
|
private val updateChapter: UpdateChapter = Injekt.get(),
|
||||||
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
|
private val setMangaViewerFlags: SetMangaViewerFlags = Injekt.get(),
|
||||||
|
|
@ -255,6 +259,12 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
private val chapterReadPagesSession = mutableMapOf<Long, Long>()
|
private val chapterReadPagesSession = mutableMapOf<Long, Long>()
|
||||||
private val chapterReadPagesPersistent = 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 orderedSeriesChapterIds = emptyList<Long>()
|
||||||
private var seriesChapterIndexById = emptyMap<Long, Int>()
|
private var seriesChapterIndexById = emptyMap<Long, Int>()
|
||||||
|
|
||||||
|
|
@ -974,6 +984,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
getHistory.await(manga.id)
|
getHistory.await(manga.id)
|
||||||
.associate { history -> history.chapterId to history.readPages },
|
.associate { history -> history.chapterId to history.readPages },
|
||||||
)
|
)
|
||||||
|
crossMangaPageRateSamples = getCrossMangaPageRateHistorySamples.await(manga.id)
|
||||||
updateSeriesProgressState()
|
updateSeriesProgressState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1125,7 +1136,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
currentPage: Int,
|
currentPage: Int,
|
||||||
remainingChapters: Int,
|
remainingChapters: Int,
|
||||||
): Long? {
|
): Long? {
|
||||||
val pageRates = orderedSeriesChapterIds
|
val seriesPageRatesMsPerPage = orderedSeriesChapterIds
|
||||||
.take(currentChapterIndex + 1)
|
.take(currentChapterIndex + 1)
|
||||||
.mapNotNull { chapterId ->
|
.mapNotNull { chapterId ->
|
||||||
val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration() ?: return@mapNotNull null
|
val duration = chapterDurations[chapterId]?.sanitizeTrackedDuration() ?: return@mapNotNull null
|
||||||
|
|
@ -1134,11 +1145,6 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
duration.toDouble() / pages.toDouble()
|
duration.toDouble() / pages.toDouble()
|
||||||
}
|
}
|
||||||
.takeLast(ETA_SAMPLE_CHAPTER_LIMIT)
|
.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
|
val sampledPages = orderedSeriesChapterIds
|
||||||
.take(currentChapterIndex + 1)
|
.take(currentChapterIndex + 1)
|
||||||
|
|
@ -1147,6 +1153,20 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||||
if (sampledPages.isEmpty()) return null
|
if (sampledPages.isEmpty()) return null
|
||||||
val avgPagesPerChapter = (sampledPages.sum() / sampledPages.size).coerceAtLeast(1L)
|
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) {
|
val currentRemainingPages = if (isCurrentChapterRead) {
|
||||||
0L
|
0L
|
||||||
} else if (totalPages > 0 && currentPage > 0) {
|
} 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.History
|
||||||
import tachiyomi.domain.history.model.HistoryWithRelations
|
import tachiyomi.domain.history.model.HistoryWithRelations
|
||||||
import tachiyomi.domain.manga.model.MangaCover
|
import tachiyomi.domain.manga.model.MangaCover
|
||||||
|
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
object HistoryMapper {
|
object HistoryMapper {
|
||||||
|
fun mapReadingEtaHistorySample(
|
||||||
|
readDuration: Long,
|
||||||
|
readPages: Long,
|
||||||
|
lastPageRead: Long,
|
||||||
|
chapterRead: Boolean,
|
||||||
|
): ReadingEtaHistorySample = ReadingEtaHistorySample(
|
||||||
|
readDurationMs = readDuration,
|
||||||
|
pagesRead = readPages,
|
||||||
|
chapterFullyRead = chapterRead,
|
||||||
|
lastPageReadIndex = lastPageRead,
|
||||||
|
)
|
||||||
|
|
||||||
fun mapHistory(
|
fun mapHistory(
|
||||||
id: Long,
|
id: Long,
|
||||||
chapterId: Long,
|
chapterId: Long,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import tachiyomi.domain.history.model.HistoryUpdate
|
||||||
import tachiyomi.domain.history.model.HistoryWithRelations
|
import tachiyomi.domain.history.model.HistoryWithRelations
|
||||||
import tachiyomi.domain.history.repository.HistoryRepository
|
import tachiyomi.domain.history.repository.HistoryRepository
|
||||||
import tachiyomi.domain.manga.model.Manga
|
import tachiyomi.domain.manga.model.Manga
|
||||||
|
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
||||||
|
|
||||||
class HistoryRepositoryImpl(
|
class HistoryRepositoryImpl(
|
||||||
private val handler: DatabaseHandler,
|
private val handler: DatabaseHandler,
|
||||||
|
|
@ -56,6 +57,25 @@ class HistoryRepositoryImpl(
|
||||||
return handler.awaitList { historyQueries.getHistoryByMangaId(mangaId, HistoryMapper::mapHistory) }
|
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 -->
|
// KMK -->
|
||||||
override suspend fun resetHistory(historyIds: List<Long>) {
|
override suspend fun resetHistory(historyIds: List<Long>) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -82,3 +82,20 @@ WHERE chapter_id = :chapterId;
|
||||||
getReadDuration:
|
getReadDuration:
|
||||||
SELECT coalesce(sum(time_read), 0)
|
SELECT coalesce(sum(time_read), 0)
|
||||||
FROM history;
|
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.History
|
||||||
import tachiyomi.domain.history.model.HistoryUpdate
|
import tachiyomi.domain.history.model.HistoryUpdate
|
||||||
import tachiyomi.domain.history.model.HistoryWithRelations
|
import tachiyomi.domain.history.model.HistoryWithRelations
|
||||||
|
import tachiyomi.domain.reading.ReadingEtaHistorySample
|
||||||
|
|
||||||
interface HistoryRepository {
|
interface HistoryRepository {
|
||||||
|
|
||||||
|
|
@ -22,6 +23,14 @@ interface HistoryRepository {
|
||||||
|
|
||||||
suspend fun getHistoryByMangaId(mangaId: Long): List<History>
|
suspend fun getHistoryByMangaId(mangaId: Long): List<History>
|
||||||
|
|
||||||
|
suspend fun getCrossMangaPageRateHistorySamples(
|
||||||
|
excludeMangaId: Long,
|
||||||
|
minDurationMs: Long,
|
||||||
|
maxDurationMs: Long,
|
||||||
|
maxPagesRead: Long,
|
||||||
|
limit: Long,
|
||||||
|
): List<ReadingEtaHistorySample>
|
||||||
|
|
||||||
// KMK -->
|
// KMK -->
|
||||||
suspend fun resetHistory(historyIds: List<Long>)
|
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.
|
* 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 {
|
object ReadingEtaDefaults {
|
||||||
|
|
||||||
|
|
@ -13,11 +17,23 @@ object ReadingEtaDefaults {
|
||||||
*/
|
*/
|
||||||
const val MAX_CHAPTER_DURATION_MS_FOR_STATS: Long = 45 * 60 * 1000L
|
const val MAX_CHAPTER_DURATION_MS_FOR_STATS: Long = 45 * 60 * 1000L
|
||||||
|
|
||||||
/**
|
/** Same lower bound as reader tracking: ignore ultra-short noise. */
|
||||||
* If we have few per-chapter page-rate samples, do not go below this ms/page
|
const val MIN_CHAPTER_DURATION_MS_FOR_PAGE_RATE: Long = 2_000L
|
||||||
* (avoids "2 minutes for the whole series" after a quick flip through ch.1).
|
|
||||||
*/
|
|
||||||
const val FALLBACK_MS_PER_PAGE: Long = 12_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