fix(simkl): reconcile completed and dropped progress

This commit is contained in:
tapframe 2026-07-24 16:09:09 +05:30
parent a69b7efd8b
commit 94ec538c10
6 changed files with 153 additions and 11 deletions

View file

@ -212,16 +212,14 @@ fun HomeScreen(
progressProviderOwnsCompletedHistory,
continueWatchingPreferences.upNextFromFurthestEpisode,
) {
val visibleProviderEntries = watchProgressUiState.entries.filterNot { entry ->
WatchProgressRepository.isDroppedShow(entry.parentMetaId)
}
buildHomeNextUpSeedCandidates(
progressEntries = visibleProviderEntries,
progressEntries = watchProgressUiState.entries,
watchedItems = nextUpWatchedItems,
providerOwnsCompletedHistory = progressProviderOwnsCompletedHistory,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
nowEpochMs = WatchProgressClock.nowEpochMs(),
shouldUseProgressSeed = WatchProgressRepository::shouldUseAsNextUpSeed,
isContentHidden = WatchProgressRepository::isDroppedShow,
)
}
@ -1192,9 +1190,11 @@ internal fun buildHomeNextUpSeedCandidates(
shouldUseProgressSeed: (WatchProgressEntry, Long) -> Boolean = { entry, _ ->
entry.shouldUseAsCompletedSeedForContinueWatching()
},
isContentHidden: (String) -> Boolean = { false },
): List<CompletedSeriesCandidate> {
val progressSeeds = progressEntries
.asSequence()
.filterNot { entry -> isContentHidden(entry.parentMetaId) }
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 }
.filter { entry -> !isMalformedNextUpSeedContentId(entry.parentMetaId) }
@ -1204,7 +1204,8 @@ internal fun buildHomeNextUpSeedCandidates(
emptyList()
} else {
watchedItems.filter { item ->
item.type.isSeriesTypeForContinueWatching() &&
!isContentHidden(item.id) &&
item.type.isSeriesTypeForContinueWatching() &&
item.season != null &&
item.episode != null &&
item.season != 0 &&

View file

@ -204,6 +204,9 @@ object SimklTrackingProgressProvider : TrackingProgressProvider {
override suspend fun removeProgress(entries: Collection<WatchProgressEntry>) =
SimklProgressRepository.removeProgress(entries)
override fun isHiddenFromProgress(contentId: String): Boolean =
SimklSyncRepository.state.value.snapshot.isDroppedContent(contentId)
}
private const val SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX = "simkl-playback:"

View file

@ -4,17 +4,23 @@ import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
internal fun SimklSyncSnapshot.reconcileWatchedPlayback(): SimklSyncSnapshot {
if (entries.isEmpty() || playback.isEmpty()) return this
if (playback.isEmpty()) return this
val watchedItems = toSimklWatchedProjection().items
if (watchedItems.isEmpty()) return this
val retainedPlayback = playback.filterNot { session ->
session.toWatchProgressEntry()?.let { progress ->
watchedItems.any { watched -> watched.supersedes(progress) }
entries.any { entry -> entry.hidesPlayback(progress) } ||
watchedItems.any { watched -> watched.supersedes(progress) }
} == true
}
return if (retainedPlayback.size == playback.size) this else copy(playback = retainedPlayback)
}
internal fun SimklSyncSnapshot.isDroppedContent(contentId: String): Boolean =
entries.any { entry ->
entry.status == SimklListStatus.DROPPED &&
entry.matchesContent(contentId = contentId, trackingProviderItemId = null)
}
private fun WatchedItem.supersedes(progress: WatchProgressEntry): Boolean {
if (!type.equals(progress.contentType, ignoreCase = true)) return false
if (season != progress.seasonNumber || episode != progress.episodeNumber) return false
@ -24,3 +30,32 @@ private fun WatchedItem.supersedes(progress: WatchProgressEntry): Boolean {
val sameContent = id.equals(progress.parentMetaId, ignoreCase = true)
return (sameProviderItem || sameContent) && markedAtEpochMs >= progress.lastUpdatedEpochMs
}
private fun SimklLibraryEntry.hidesPlayback(progress: WatchProgressEntry): Boolean {
if (
!matchesContent(
contentId = progress.parentMetaId,
trackingProviderItemId = progress.trackingProviderItemId,
)
) {
return false
}
return when (status) {
SimklListStatus.DROPPED -> true
SimklListStatus.COMPLETED ->
parseSimklUtcEpochMs(lastWatchedAt)?.let { completedAt ->
completedAt >= progress.lastUpdatedEpochMs
} == true
else -> false
}
}
private fun SimklLibraryEntry.matchesContent(
contentId: String,
trackingProviderItemId: String?,
): Boolean {
val providerItemId = media?.simklTrackingProviderItemId()
val sameProviderItem = providerItemId != null &&
providerItemId.equals(trackingProviderItemId, ignoreCase = true)
return sameProviderItem || matchesContentId(contentId)
}

View file

@ -272,10 +272,10 @@ internal fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? {
)
}
private fun SimklMedia.simklTrackingProviderItemId(): String? =
internal fun SimklMedia.simklTrackingProviderItemId(): String? =
ids.simklIdValue()?.toLongOrNull()?.takeIf { it > 0L }?.let { id -> "simkl:$id" }
private fun SimklLibraryEntry.matchesContentId(contentId: String): Boolean {
internal fun SimklLibraryEntry.matchesContentId(contentId: String): Boolean {
val media = media ?: return false
if (media.canonicalContentId().equals(contentId, ignoreCase = true)) return true
val parsed = parseTrackingExternalIds(contentId)

View file

@ -564,6 +564,35 @@ class HomeScreenTest {
assertEquals(listOf("show"), result.map { it.content.id })
}
@Test
fun `hidden provider content cannot seed next up from progress or watched history`() {
val progress = progressEntry(
videoId = "dropped-show:1:2",
title = "Dropped Show",
seasonNumber = 1,
episodeNumber = 2,
lastUpdatedEpochMs = 2_000L,
isCompleted = true,
)
val watched = watchedItem(
id = "dropped-show",
season = 1,
episode = 2,
markedAtEpochMs = 2_000L,
)
val result = buildHomeNextUpSeedCandidates(
progressEntries = listOf(progress),
watchedItems = listOf(watched),
providerOwnsCompletedHistory = false,
preferFurthestEpisode = true,
nowEpochMs = 3_000L,
isContentHidden = { contentId -> contentId == "dropped-show" },
)
assertTrue(result.isEmpty())
}
@Test
fun `stale live next up item is dropped when current seed advances`() {
val staleNextUp = continueWatchingItem(

View file

@ -4,6 +4,7 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertSame
import kotlin.test.assertTrue
@ -183,7 +184,7 @@ class SimklPlaybackReconciliationTest {
}
@Test
fun `completed series summary does not discard exact episode playback`() {
fun `newer completed series summary discards stale episode playback`() {
val showMedia = media(39687, imdb = "tt4574334")
val snapshot = SimklSyncSnapshot(
entries = listOf(
@ -204,9 +205,82 @@ class SimklPlaybackReconciliationTest {
),
)
assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty())
}
@Test
fun `newer episode playback remains after completed series summary`() {
val showMedia = media(39687, imdb = "tt4574334")
val snapshot = SimklSyncSnapshot(
entries = listOf(
SimklLibraryEntry(
mediaType = SimklMediaType.SHOWS,
status = SimklListStatus.COMPLETED,
lastWatchedAt = "2024-04-30T22:13:00Z",
show = showMedia,
),
),
playback = listOf(
episodePlayback(
playbackMedia = showMedia,
season = 1,
episode = 5,
pausedAt = "2024-04-30T22:14:00Z",
),
),
)
assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback)
}
@Test
fun `dropped series discards playback using provider identity`() {
val snapshot = SimklSyncSnapshot(
entries = listOf(
SimklLibraryEntry(
mediaType = SimklMediaType.SHOWS,
status = SimklListStatus.DROPPED,
show = media(39687, imdb = "tt4574334"),
),
),
playback = listOf(
episodePlayback(
playbackMedia = media(39687, tvdb = "305288"),
season = 1,
episode = 5,
pausedAt = "2024-04-30T22:14:00Z",
),
),
)
assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty())
assertTrue(snapshot.isDroppedContent("tt4574334"))
}
@Test
fun `different dropped series does not discard playback`() {
val snapshot = SimklSyncSnapshot(
entries = listOf(
SimklLibraryEntry(
mediaType = SimklMediaType.SHOWS,
status = SimklListStatus.DROPPED,
show = media(11111, imdb = "tt1111111"),
),
),
playback = listOf(
episodePlayback(
playbackMedia = media(39687, imdb = "tt4574334"),
season = 1,
episode = 5,
pausedAt = "2024-04-30T22:14:00Z",
),
),
)
assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback)
assertFalse(snapshot.isDroppedContent("tt4574334"))
}
@Test
fun `snapshot without a conflict is returned unchanged`() {
val snapshot = SimklSyncSnapshot(playback = listOf(episodePlayback()))