mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-18 05:15:41 +00:00
fix(simkl): reconcile completed and dropped progress
This commit is contained in:
parent
a69b7efd8b
commit
94ec538c10
6 changed files with 153 additions and 11 deletions
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
Loading…
Reference in a new issue