feat: normalize watch progress entries and update completion logic

This commit is contained in:
tapframe 2026-04-07 12:27:19 +05:30
parent 55d9bbe246
commit b3082eb412
7 changed files with 153 additions and 57 deletions

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.trakt
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.watchprogress.WatchProgressCompletionPercentThreshold
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import kotlinx.coroutines.CancellationException
@ -157,9 +158,10 @@ object TraktProgressRepository {
fun applyOptimisticProgress(entry: WatchProgressEntry) {
if (!TraktAuthRepository.isAuthenticated.value) return
val current = _uiState.value.entries.associateBy { it.videoId }.toMutableMap()
val existing = current[entry.videoId]
if (existing == null || entry.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs) {
current[entry.videoId] = entry
val normalizedEntry = entry.normalizedCompletion()
val existing = current[normalizedEntry.videoId]
if (existing == null || normalizedEntry.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs) {
current[normalizedEntry.videoId] = normalizedEntry
}
_uiState.value = _uiState.value.copy(entries = current.values.sortedByDescending { it.lastUpdatedEpochMs })
}
@ -240,7 +242,8 @@ object TraktProgressRepository {
private fun mergeNewestByVideoId(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>()
entries.forEach { entry ->
entries.forEach { rawEntry ->
val entry = rawEntry.normalizedCompletion()
val existing = mergedByVideoId[entry.videoId]
if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) {
mergedByVideoId[entry.videoId] = entry
@ -365,9 +368,9 @@ object TraktProgressRepository {
lastPositionMs = 0L,
durationMs = 0L,
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
isCompleted = false,
isCompleted = progressPercent >= WatchProgressCompletionPercentThreshold,
progressPercent = progressPercent,
)
).normalizedCompletion()
}
private fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? {
@ -399,9 +402,9 @@ object TraktProgressRepository {
lastPositionMs = 0L,
durationMs = 0L,
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
isCompleted = false,
isCompleted = progressPercent >= WatchProgressCompletionPercentThreshold,
progressPercent = progressPercent,
)
).normalizedCompletion()
}
private fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? {

View file

@ -91,17 +91,19 @@ object WatchingState {
}
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
WatchingProgressRecord(
content = WatchingContentRef(type = parentMetaType, id = parentMetaId),
videoId = videoId,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
lastUpdatedEpochMs = lastUpdatedEpochMs,
lastPositionMs = lastPositionMs,
isCompleted = isCompleted,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
)
normalizedCompletion().let { entry ->
WatchingProgressRecord(
content = WatchingContentRef(type = entry.parentMetaType, id = entry.parentMetaId),
videoId = entry.videoId,
seasonNumber = entry.seasonNumber,
episodeNumber = entry.episodeNumber,
lastUpdatedEpochMs = entry.lastUpdatedEpochMs,
lastPositionMs = entry.lastPositionMs,
isCompleted = entry.isCompleted,
episodeTitle = entry.episodeTitle,
episodeThumbnail = entry.episodeThumbnail,
)
}
private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
WatchingWatchedRecord(

View file

@ -4,6 +4,8 @@ import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.watching.domain.WatchingContentRef
import kotlinx.serialization.Serializable
internal const val WatchProgressCompletionPercentThreshold = 99.5f
@Serializable
enum class ContinueWatchingSectionStyle {
Wide,
@ -36,9 +38,17 @@ data class WatchProgressEntry(
val isCompleted: Boolean = false,
val progressPercent: Float? = null,
) {
val normalizedProgressPercent: Float?
get() = progressPercent?.coerceIn(0f, 100f)
val isEffectivelyCompleted: Boolean
get() = isCompleted ||
(normalizedProgressPercent?.let { it >= WatchProgressCompletionPercentThreshold } == true) ||
(durationMs > 0L && lastPositionMs >= durationMs)
val progressFraction: Float
get() {
progressPercent?.let { explicitPercent ->
normalizedProgressPercent?.let { explicitPercent ->
return (explicitPercent / 100f).coerceIn(0f, 1f)
}
return if (durationMs > 0L) {
@ -52,14 +62,41 @@ data class WatchProgressEntry(
get() = seasonNumber != null && episodeNumber != null
val isResumable: Boolean
get() = !isCompleted
get() = !isEffectivelyCompleted
fun normalizedCompletion(): WatchProgressEntry {
val completed = isEffectivelyCompleted
val normalizedPositionMs = when {
completed && durationMs > 0L -> durationMs
else -> lastPositionMs.coerceAtLeast(0L)
}
val normalizedPercent = when {
normalizedProgressPercent != null -> normalizedProgressPercent
completed && durationMs <= 0L -> 100f
else -> null
}
return if (
completed == isCompleted &&
normalizedPositionMs == lastPositionMs &&
normalizedPercent == progressPercent
) {
this
} else {
copy(
lastPositionMs = normalizedPositionMs,
isCompleted = completed,
progressPercent = normalizedPercent,
)
}
}
fun resolveResumePosition(actualDurationMs: Long): Long {
if (actualDurationMs <= 0L) return lastPositionMs.coerceAtLeast(0L)
if (durationMs > 0L && lastPositionMs > 0L) {
return lastPositionMs.coerceIn(0L, actualDurationMs)
}
progressPercent?.let { percent ->
normalizedProgressPercent?.let { percent ->
val fraction = (percent / 100f).coerceIn(0f, 1f)
return (actualDurationMs * fraction).toLong()
}
@ -126,17 +163,18 @@ data class ContinueWatchingPreferencesUiState(
)
internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
val explicitResumeProgressFraction = progressPercent
val normalizedEntry = normalizedCompletion()
val explicitResumeProgressFraction = normalizedEntry.normalizedProgressPercent
?.takeIf { durationMs <= 0L && it > 0f }
?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) }
val subtitle = if (seasonNumber != null && episodeNumber != null) {
val subtitle = if (normalizedEntry.seasonNumber != null && normalizedEntry.episodeNumber != null) {
buildString {
append("S")
append(seasonNumber)
append(normalizedEntry.seasonNumber)
append("E")
append(episodeNumber)
episodeTitle?.takeIf { it.isNotBlank() }?.let {
append(normalizedEntry.episodeNumber)
normalizedEntry.episodeTitle?.takeIf { it.isNotBlank() }?.let {
append("")
append(it)
}
@ -146,24 +184,24 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
}
return ContinueWatchingItem(
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
videoId = videoId,
title = title,
parentMetaId = normalizedEntry.parentMetaId,
parentMetaType = normalizedEntry.parentMetaType,
videoId = normalizedEntry.videoId,
title = normalizedEntry.title,
subtitle = subtitle,
imageUrl = episodeThumbnail ?: background ?: poster,
logo = logo,
poster = poster,
background = background,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
pauseDescription = pauseDescription,
resumePositionMs = if (explicitResumeProgressFraction != null) 0L else lastPositionMs,
imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster,
logo = normalizedEntry.logo,
poster = normalizedEntry.poster,
background = normalizedEntry.background,
seasonNumber = normalizedEntry.seasonNumber,
episodeNumber = normalizedEntry.episodeNumber,
episodeTitle = normalizedEntry.episodeTitle,
episodeThumbnail = normalizedEntry.episodeThumbnail,
pauseDescription = normalizedEntry.pauseDescription,
resumePositionMs = if (explicitResumeProgressFraction != null) 0L else normalizedEntry.lastPositionMs,
resumeProgressFraction = explicitResumeProgressFraction,
durationMs = durationMs,
progressFraction = progressFraction,
durationMs = normalizedEntry.durationMs,
progressFraction = normalizedEntry.progressFraction,
)
}

View file

@ -294,7 +294,7 @@ object WatchProgressRepository {
pauseDescription = session.pauseDescription,
lastSourceUrl = session.lastSourceUrl,
isCompleted = isCompleted,
)
).normalizedCompletion()
entriesByVideoId[session.videoId] = entry
if (shouldUseTraktProgress()) {

View file

@ -28,6 +28,7 @@ internal object WatchProgressCodec {
fun decodeEntries(payload: String): List<WatchProgressEntry> =
runCatching {
json.decodeFromString<StoredWatchProgressPayload>(payload).entries
.map(WatchProgressEntry::normalizedCompletion)
}.getOrDefault(emptyList())
fun encodeEntries(entries: Collection<WatchProgressEntry>): String =
@ -76,17 +77,19 @@ internal fun List<WatchProgressEntry>.continueWatchingEntries(
}
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
WatchingProgressRecord(
normalizedCompletion().let { entry ->
WatchingProgressRecord(
content = WatchingContentRef(
type = parentMetaType,
id = parentMetaId,
type = entry.parentMetaType,
id = entry.parentMetaId,
),
videoId = videoId,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
lastUpdatedEpochMs = lastUpdatedEpochMs,
lastPositionMs = lastPositionMs,
isCompleted = isCompleted,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
videoId = entry.videoId,
seasonNumber = entry.seasonNumber,
episodeNumber = entry.episodeNumber,
lastUpdatedEpochMs = entry.lastUpdatedEpochMs,
lastPositionMs = entry.lastPositionMs,
isCompleted = entry.isCompleted,
episodeTitle = entry.episodeTitle,
episodeThumbnail = entry.episodeThumbnail,
)
}

View file

@ -42,6 +42,7 @@ class HomeScreenTest {
val inProgress = progressEntry(
videoId = "show:1:5",
title = "Show",
episodeNumber = 5,
episodeTitle = "The Wolf and the Lion",
lastUpdatedEpochMs = 500L,
)

View file

@ -94,6 +94,49 @@ class WatchProgressRulesTest {
assertEquals(2, result.size)
}
@Test
fun `continue watching excludes explicit 100 percent entries even when completion flag is false`() {
val completedByPercent = entry(
videoId = "movie-complete",
lastUpdatedEpochMs = 20L,
lastPositionMs = 0L,
durationMs = 0L,
isCompleted = false,
progressPercent = 100f,
)
val inProgress = entry(
videoId = "movie-progress",
lastUpdatedEpochMs = 10L,
lastPositionMs = 120_000L,
durationMs = 1_000_000L,
progressPercent = 12f,
)
val result = listOf(completedByPercent, inProgress).continueWatchingEntries()
assertEquals(listOf("movie-progress"), result.map { it.videoId })
}
@Test
fun `codec normalizes completed entries inferred from percent`() {
val payload = WatchProgressCodec.encodeEntries(
listOf(
entry(
videoId = "movie-complete",
lastPositionMs = 0L,
durationMs = 0L,
isCompleted = false,
progressPercent = 100f,
),
),
)
val decoded = WatchProgressCodec.decodeEntries(payload)
assertEquals(1, decoded.size)
assertTrue(decoded.single().isCompleted)
}
@Test
fun `build playback video id uses season and episode when present`() {
assertEquals("show:1:2", buildPlaybackVideoId(parentMetaId = "show", seasonNumber = 1, episodeNumber = 2, fallbackVideoId = "fallback"))
@ -107,6 +150,10 @@ class WatchProgressRulesTest {
seasonNumber: Int? = null,
episodeNumber: Int? = null,
lastUpdatedEpochMs: Long = 1L,
lastPositionMs: Long = 120_000L,
durationMs: Long = 1_000_000L,
isCompleted: Boolean = false,
progressPercent: Float? = null,
): WatchProgressEntry =
WatchProgressEntry(
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
@ -116,8 +163,10 @@ class WatchProgressRulesTest {
title = "Title",
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
lastPositionMs = 120_000L,
durationMs = 1_000_000L,
lastPositionMs = lastPositionMs,
durationMs = durationMs,
lastUpdatedEpochMs = lastUpdatedEpochMs,
isCompleted = isCompleted,
progressPercent = progressPercent,
)
}