mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-05 11:01:37 +00:00
feat: normalize watch progress entries and update completion logic
This commit is contained in:
parent
55d9bbe246
commit
b3082eb412
7 changed files with 153 additions and 57 deletions
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ object WatchProgressRepository {
|
|||
pauseDescription = session.pauseDescription,
|
||||
lastSourceUrl = session.lastSourceUrl,
|
||||
isCompleted = isCompleted,
|
||||
)
|
||||
).normalizedCompletion()
|
||||
|
||||
entriesByVideoId[session.videoId] = entry
|
||||
if (shouldUseTraktProgress()) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue