fix(progress): isolate continue watching sources

This commit is contained in:
tapframe 2026-07-22 17:18:30 +05:30
parent e930699ae9
commit e408a2f48c
6 changed files with 79 additions and 66 deletions

View file

@ -139,7 +139,6 @@ interface TrackingProgressProvider {
fun applyOptimisticRemoval(entries: Collection<WatchProgressEntry>) = Unit
fun applyOptimisticProgress(entry: WatchProgressEntry) = Unit
fun normalizeParentContentId(parentContentId: String, videoId: String?): String = parentContentId
fun shouldRetainLocalEntry(entry: WatchProgressEntry): Boolean = true
suspend fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean) = Unit
fun isHiddenFromProgress(contentId: String): Boolean = false
}

View file

@ -131,9 +131,6 @@ object TraktTrackingProgressProvider : TrackingProgressProvider {
override fun normalizeParentContentId(parentContentId: String, videoId: String?): String =
resolveEffectiveContentId(parentContentId, videoId)
override fun shouldRetainLocalEntry(entry: WatchProgressEntry): Boolean =
!isTraktCompatibleId(entry.parentMetaId)
override suspend fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean) =
TraktProgressRepository.refreshEpisodeProgress(contentId, forceRefresh)

View file

@ -68,34 +68,6 @@ private data class MetadataProviderReadiness(
get() = providers.isNotEmpty()
}
internal fun mergeTrackerProgressEntries(
remoteEntries: Collection<WatchProgressEntry>,
localEntries: Collection<WatchProgressEntry>,
): List<WatchProgressEntry> {
val newestByMedia = linkedMapOf<String, WatchProgressEntry>()
remoteEntries.forEach { entry ->
newestByMedia[entry.trackerMediaIdentity()] = entry
}
localEntries.forEach { entry ->
val key = entry.trackerMediaIdentity()
val existing = newestByMedia[key]
if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) {
newestByMedia[key] = entry
}
}
return newestByMedia.values.toList()
}
private fun WatchProgressEntry.trackerMediaIdentity(): String = buildString {
append(parentMetaType.trim().lowercase())
append(':')
append(parentMetaId.trim().lowercase())
append(':')
append(seasonNumber ?: -1)
append(':')
append(episodeNumber ?: -1)
}
internal class MetadataResolutionRetryCoordinator {
private val lock = SynchronizedObject()
private var generation = 0L
@ -1489,10 +1461,14 @@ object WatchProgressRepository {
}
private fun currentEntries(): List<WatchProgressEntry> {
val provider = activeProgressProvider() ?: return localEntriesSnapshot()
return mergeTrackerProgressEntries(
remoteEntries = provider.snapshot().entries,
localEntries = localEntriesSnapshot().filter(provider::shouldRetainLocalEntry),
val providerEntries = activeProgressProvider()
?.snapshot()
?.entries
.orEmpty()
return projectWatchProgressSourceEntries(
source = activeSource,
nuvioEntries = localEntriesSnapshot(),
providerEntries = providerEntries,
)
}

View file

@ -0,0 +1,13 @@
package com.nuvio.app.features.watchprogress
import com.nuvio.app.features.tracking.WatchProgressSource
internal fun projectWatchProgressSourceEntries(
source: WatchProgressSource,
nuvioEntries: Collection<WatchProgressEntry>,
providerEntries: Collection<WatchProgressEntry>,
): List<WatchProgressEntry> = if (source.providerId == null) {
nuvioEntries.toList()
} else {
providerEntries.toList()
}

View file

@ -9,36 +9,6 @@ import kotlin.test.assertTrue
class WatchProgressIdentityTest {
@Test
fun `tracker progress merge keeps one entry per media and favors fresher local playback`() {
val remote = entry(
progressKey = "simkl-playback:42",
lastUpdatedEpochMs = 100L,
lastPositionMs = 400L,
)
val staleLocal = entry(lastUpdatedEpochMs = 90L, lastPositionMs = 300L)
val otherLocal = entry(
parentMetaId = "other",
videoId = "other:1:2",
lastUpdatedEpochMs = 80L,
)
val firstMerge = mergeTrackerProgressEntries(
remoteEntries = listOf(remote),
localEntries = listOf(staleLocal, otherLocal),
)
assertEquals(2, firstMerge.size)
assertEquals("simkl-playback:42", firstMerge.single { it.parentMetaId == "show" }.progressKey)
val freshLocal = staleLocal.copy(lastUpdatedEpochMs = 110L, lastPositionMs = 500L)
val secondMerge = mergeTrackerProgressEntries(
remoteEntries = listOf(remote),
localEntries = listOf(freshLocal),
)
assertEquals(500L, secondMerge.single().lastPositionMs)
}
@Test
fun `provider change during metadata batch schedules one follow up`() {
val coordinator = MetadataResolutionRetryCoordinator()

View file

@ -0,0 +1,58 @@
package com.nuvio.app.features.watchprogress
import com.nuvio.app.features.tracking.WatchProgressSource
import kotlin.test.Test
import kotlin.test.assertEquals
class WatchProgressSourceProjectionTest {
@Test
fun `remote source excludes every Nuvio progress entry`() {
val nuvioEntries = listOf(
entry(parentMetaId = "shared", updatedAt = 200L),
entry(parentMetaId = "nuvio-only", updatedAt = 300L),
)
val providerEntries = listOf(
entry(parentMetaId = "shared", updatedAt = 100L),
)
listOf(WatchProgressSource.TRAKT, WatchProgressSource.SIMKL).forEach { source ->
val projected = projectWatchProgressSourceEntries(
source = source,
nuvioEntries = nuvioEntries,
providerEntries = providerEntries,
)
assertEquals(providerEntries, projected)
}
}
@Test
fun `Nuvio source excludes every provider progress entry`() {
val nuvioEntries = listOf(entry(parentMetaId = "nuvio"))
val providerEntries = listOf(entry(parentMetaId = "provider"))
val projected = projectWatchProgressSourceEntries(
source = WatchProgressSource.NUVIO_SYNC,
nuvioEntries = nuvioEntries,
providerEntries = providerEntries,
)
assertEquals(nuvioEntries, projected)
}
private fun entry(
parentMetaId: String,
updatedAt: Long = 1L,
): WatchProgressEntry = WatchProgressEntry(
contentType = "series",
parentMetaId = parentMetaId,
parentMetaType = "series",
videoId = "$parentMetaId:1:1",
title = parentMetaId,
seasonNumber = 1,
episodeNumber = 1,
lastPositionMs = 10L,
durationMs = 100L,
lastUpdatedEpochMs = updatedAt,
)
}