mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-27 06:52:18 +00:00
fix(simkl): reconcile watched playback
This commit is contained in:
parent
200851c6f6
commit
4f3eaee2cc
5 changed files with 386 additions and 3 deletions
|
|
@ -0,0 +1,26 @@
|
|||
package com.nuvio.app.features.simkl
|
||||
|
||||
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
|
||||
val watchedItems = toSimklWatchedProjection().items
|
||||
if (watchedItems.isEmpty()) return this
|
||||
val retainedPlayback = playback.filterNot { session ->
|
||||
session.toWatchProgressEntry()?.let { progress ->
|
||||
watchedItems.any { watched -> watched.supersedes(progress) }
|
||||
} == true
|
||||
}
|
||||
return if (retainedPlayback.size == playback.size) this else copy(playback = retainedPlayback)
|
||||
}
|
||||
|
||||
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
|
||||
val sameProviderItem = trackingProviderItemId
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.equals(progress.trackingProviderItemId, ignoreCase = true) == true
|
||||
val sameContent = id.equals(progress.parentMetaId, ignoreCase = true)
|
||||
return (sameProviderItem || sameContent) && markedAtEpochMs >= progress.lastUpdatedEpochMs
|
||||
}
|
||||
|
|
@ -226,7 +226,7 @@ internal fun parseSimklUtcEpochMs(value: String?): Long? {
|
|||
return (((days * 24L + hour) * 60L + minute) * 60L + second) * 1_000L + millis
|
||||
}
|
||||
|
||||
private fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? {
|
||||
internal fun SimklPlaybackSession.toWatchProgressEntry(): WatchProgressEntry? {
|
||||
val media = media ?: return null
|
||||
val parentId = media.canonicalContentId() ?: return null
|
||||
val isMovie = mediaType == SimklMediaType.MOVIES
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ internal class SimklSyncEngine(
|
|||
playback = playback,
|
||||
lastSyncedAtEpochMs = now,
|
||||
lastCheckedAtEpochMs = now,
|
||||
)
|
||||
).reconcileWatchedPlayback()
|
||||
}
|
||||
|
||||
private suspend fun initialSync(): SimklSyncSnapshot {
|
||||
|
|
@ -61,7 +61,7 @@ internal class SimklSyncEngine(
|
|||
playback = playback,
|
||||
lastSyncedAtEpochMs = now,
|
||||
lastCheckedAtEpochMs = now,
|
||||
)
|
||||
).reconcileWatchedPlayback()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,283 @@
|
|||
package com.nuvio.app.features.simkl
|
||||
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SimklPlaybackReconciliationTest {
|
||||
@Test
|
||||
fun `newer watched episode discards stale playback`() {
|
||||
val snapshot = episodeSnapshot(
|
||||
watchedAt = "2024-04-30T22:14:00Z",
|
||||
pausedAt = "2024-04-30T22:13:00Z",
|
||||
)
|
||||
|
||||
assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equal watched timestamp discards playback`() {
|
||||
val snapshot = episodeSnapshot(
|
||||
watchedAt = "2024-04-30T22:13:00Z",
|
||||
pausedAt = "2024-04-30T22:13:00Z",
|
||||
)
|
||||
|
||||
assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer playback remains as a rewatch`() {
|
||||
val snapshot = episodeSnapshot(
|
||||
watchedAt = "2024-04-30T22:13:00Z",
|
||||
pausedAt = "2024-04-30T22:14:00Z",
|
||||
)
|
||||
|
||||
assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `watched episode does not discard another episode`() {
|
||||
val snapshot = SimklSyncSnapshot(
|
||||
entries = listOf(
|
||||
watchedEpisodeEntry(
|
||||
media = media(39687, imdb = "tt4574334"),
|
||||
season = 1,
|
||||
episode = 5,
|
||||
watchedAt = "2024-04-30T22:14:00Z",
|
||||
),
|
||||
),
|
||||
playback = listOf(
|
||||
episodePlayback(
|
||||
playbackMedia = media(39687, imdb = "tt4574334"),
|
||||
season = 1,
|
||||
episode = 4,
|
||||
pausedAt = "2024-04-30T22:13:00Z",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `provider identity reconciles different canonical ids`() {
|
||||
val snapshot = SimklSyncSnapshot(
|
||||
entries = listOf(
|
||||
watchedEpisodeEntry(
|
||||
media = media(39687, imdb = "tt4574334"),
|
||||
season = 1,
|
||||
episode = 5,
|
||||
watchedAt = "2024-04-30T22:14:00Z",
|
||||
),
|
||||
),
|
||||
playback = listOf(
|
||||
episodePlayback(
|
||||
playbackMedia = media(39687, tvdb = "305288"),
|
||||
season = 1,
|
||||
episode = 5,
|
||||
pausedAt = "2024-04-30T22:13:00Z",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("tvdb:305288", snapshot.playback.single().toWatchProgressEntry()?.parentMetaId)
|
||||
assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapped anime coordinates reconcile the same episode`() {
|
||||
val animeMedia = media(439744, imdb = "tt2560140")
|
||||
val snapshot = SimklSyncSnapshot(
|
||||
entries = listOf(
|
||||
SimklLibraryEntry(
|
||||
mediaType = SimklMediaType.ANIME,
|
||||
status = SimklListStatus.WATCHING,
|
||||
show = animeMedia,
|
||||
seasons = listOf(
|
||||
SimklSeason(
|
||||
number = 1,
|
||||
episodes = listOf(
|
||||
SimklEpisode(
|
||||
number = 4,
|
||||
watchedAt = "2024-04-30T22:14:00Z",
|
||||
tvdb = SimklEpisodeMapping(season = 2, episode = 4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
playback = listOf(
|
||||
SimklPlaybackSession(
|
||||
id = 12345,
|
||||
progress = 62.5,
|
||||
pausedAt = "2024-04-30T22:13:00Z",
|
||||
type = "episode",
|
||||
episode = SimklPlaybackEpisode(
|
||||
season = 1,
|
||||
number = 4,
|
||||
tvdbSeason = 2,
|
||||
tvdbNumber = 4,
|
||||
),
|
||||
anime = animeMedia,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer watched movie discards stale playback`() {
|
||||
val movieMedia = media(53536, imdb = "tt0181852")
|
||||
val snapshot = SimklSyncSnapshot(
|
||||
entries = listOf(
|
||||
SimklLibraryEntry(
|
||||
mediaType = SimklMediaType.MOVIES,
|
||||
status = SimklListStatus.COMPLETED,
|
||||
lastWatchedAt = "2024-04-30T22:14:00Z",
|
||||
movie = movieMedia,
|
||||
),
|
||||
),
|
||||
playback = listOf(
|
||||
SimklPlaybackSession(
|
||||
id = 12345,
|
||||
progress = 62.5,
|
||||
pausedAt = "2024-04-30T22:13:00Z",
|
||||
type = "movie",
|
||||
movie = movieMedia,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(snapshot.reconcileWatchedPlayback().playback.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer movie playback remains as a rewatch`() {
|
||||
val movieMedia = media(53536, imdb = "tt0181852")
|
||||
val snapshot = SimklSyncSnapshot(
|
||||
entries = listOf(
|
||||
SimklLibraryEntry(
|
||||
mediaType = SimklMediaType.MOVIES,
|
||||
status = SimklListStatus.COMPLETED,
|
||||
lastWatchedAt = "2024-04-30T22:13:00Z",
|
||||
movie = movieMedia,
|
||||
),
|
||||
),
|
||||
playback = listOf(
|
||||
SimklPlaybackSession(
|
||||
id = 12345,
|
||||
progress = 62.5,
|
||||
pausedAt = "2024-04-30T22:14:00Z",
|
||||
type = "movie",
|
||||
movie = movieMedia,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completed series summary does not discard exact episode playback`() {
|
||||
val showMedia = media(39687, imdb = "tt4574334")
|
||||
val snapshot = SimklSyncSnapshot(
|
||||
entries = listOf(
|
||||
SimklLibraryEntry(
|
||||
mediaType = SimklMediaType.SHOWS,
|
||||
status = SimklListStatus.COMPLETED,
|
||||
lastWatchedAt = "2024-04-30T22:14:00Z",
|
||||
show = showMedia,
|
||||
),
|
||||
),
|
||||
playback = listOf(
|
||||
episodePlayback(
|
||||
playbackMedia = showMedia,
|
||||
season = 1,
|
||||
episode = 5,
|
||||
pausedAt = "2024-04-30T22:13:00Z",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(snapshot.playback, snapshot.reconcileWatchedPlayback().playback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snapshot without a conflict is returned unchanged`() {
|
||||
val snapshot = SimklSyncSnapshot(playback = listOf(episodePlayback()))
|
||||
|
||||
assertSame(snapshot, snapshot.reconcileWatchedPlayback())
|
||||
}
|
||||
|
||||
private fun episodeSnapshot(watchedAt: String, pausedAt: String): SimklSyncSnapshot {
|
||||
val showMedia = media(39687, imdb = "tt4574334")
|
||||
return SimklSyncSnapshot(
|
||||
entries = listOf(
|
||||
watchedEpisodeEntry(
|
||||
media = showMedia,
|
||||
season = 1,
|
||||
episode = 5,
|
||||
watchedAt = watchedAt,
|
||||
),
|
||||
),
|
||||
playback = listOf(
|
||||
episodePlayback(
|
||||
playbackMedia = showMedia,
|
||||
season = 1,
|
||||
episode = 5,
|
||||
pausedAt = pausedAt,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun watchedEpisodeEntry(
|
||||
media: SimklMedia,
|
||||
season: Int,
|
||||
episode: Int,
|
||||
watchedAt: String,
|
||||
): SimklLibraryEntry = SimklLibraryEntry(
|
||||
mediaType = SimklMediaType.SHOWS,
|
||||
status = SimklListStatus.WATCHING,
|
||||
show = media,
|
||||
seasons = listOf(
|
||||
SimklSeason(
|
||||
number = season,
|
||||
episodes = listOf(SimklEpisode(number = episode, watchedAt = watchedAt)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun episodePlayback(
|
||||
playbackMedia: SimklMedia = media(39687, imdb = "tt4574334"),
|
||||
season: Int = 1,
|
||||
episode: Int = 5,
|
||||
pausedAt: String = "2024-04-30T22:13:00Z",
|
||||
): SimklPlaybackSession = SimklPlaybackSession(
|
||||
id = 12345,
|
||||
progress = 62.5,
|
||||
pausedAt = pausedAt,
|
||||
type = "episode",
|
||||
episode = SimklPlaybackEpisode(season = season, number = episode),
|
||||
show = playbackMedia,
|
||||
)
|
||||
|
||||
private fun media(
|
||||
id: Long,
|
||||
imdb: String? = null,
|
||||
tvdb: String? = null,
|
||||
): SimklMedia = SimklMedia(
|
||||
title = "Title $id",
|
||||
runtime = 50,
|
||||
ids = buildJsonObject {
|
||||
put("simkl", id)
|
||||
imdb?.let { put("imdb", it) }
|
||||
tvdb?.let { put("tvdb", it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -78,6 +78,60 @@ class SimklSyncEngineTest {
|
|||
assertTrue(remote.isExhausted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `watched delta discards retained playback without fetching playback`() = runBlocking {
|
||||
val retainedPlayback = episodePlayback("1", "2024-04-30T22:13:00Z")
|
||||
val current = SimklSyncSnapshot(
|
||||
isInitialized = true,
|
||||
watermark = "v1",
|
||||
activities = activities(all = "v1", playback = "p1"),
|
||||
entries = listOf(entry(SimklMediaType.SHOWS, "1")),
|
||||
playback = listOf(retainedPlayback),
|
||||
)
|
||||
val remote = ScriptedRemote(
|
||||
Step.Activities(activities(all = "v2", playback = "p1")),
|
||||
Step.AllItems(
|
||||
null,
|
||||
responseOf(watchedShowEntry("1", "2024-04-30T22:14:00Z")),
|
||||
),
|
||||
)
|
||||
|
||||
val result = SimklSyncEngine(remote) { 900L }.synchronize(current)
|
||||
|
||||
assertTrue(result.playback.isEmpty())
|
||||
assertTrue(result.toSimklProgressEntries().isEmpty())
|
||||
assertTrue(
|
||||
result.toSimklWatchedProjection().items.any { watched ->
|
||||
watched.season == 1 && watched.episode == 5
|
||||
},
|
||||
)
|
||||
assertEquals(
|
||||
listOf<SimklAllItemsRequest>(SimklAllItemsRequest.Changes("v1")),
|
||||
remote.allItemsRequests,
|
||||
)
|
||||
assertTrue(remote.isExhausted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial sync discards playback superseded by watched history`() = runBlocking {
|
||||
val remote = ScriptedRemote(
|
||||
Step.AllItems(
|
||||
SimklMediaType.SHOWS,
|
||||
responseOf(watchedShowEntry("1", "2024-04-30T22:14:00Z")),
|
||||
),
|
||||
Step.AllItems(SimklMediaType.MOVIES, SimklAllItemsResponse(movies = emptyList())),
|
||||
Step.AllItems(SimklMediaType.ANIME, SimklAllItemsResponse(anime = emptyList())),
|
||||
Step.Playback(listOf(episodePlayback("1", "2024-04-30T22:13:00Z"))),
|
||||
Step.Activities(activities(all = "v1", playback = "p1")),
|
||||
)
|
||||
|
||||
val result = SimklSyncEngine(remote) { 900L }.synchronize(SimklSyncSnapshot())
|
||||
|
||||
assertTrue(result.playback.isEmpty())
|
||||
assertEquals(1, result.toSimklWatchedProjection().items.size)
|
||||
assertTrue(remote.isExhausted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delta merge reconciles removals and replaces changed playback atomically`() = runBlocking {
|
||||
val previousActivities = activities(all = "v1", removed = "r1", playback = "p1")
|
||||
|
|
@ -274,6 +328,26 @@ class SimklSyncEngineTest {
|
|||
movie = media(id),
|
||||
)
|
||||
|
||||
fun episodePlayback(id: String, pausedAt: String) = SimklPlaybackSession(
|
||||
id = id.toLong(),
|
||||
progress = 62.5,
|
||||
pausedAt = pausedAt,
|
||||
type = "episode",
|
||||
episode = SimklPlaybackEpisode(season = 1, number = 5),
|
||||
show = media(id),
|
||||
)
|
||||
|
||||
fun watchedShowEntry(id: String, watchedAt: String) =
|
||||
entry(SimklMediaType.SHOWS, id).copy(
|
||||
lastWatchedAt = watchedAt,
|
||||
seasons = listOf(
|
||||
SimklSeason(
|
||||
number = 1,
|
||||
episodes = listOf(SimklEpisode(number = 5, watchedAt = watchedAt)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun activities(
|
||||
all: String,
|
||||
removed: String = "removed",
|
||||
|
|
|
|||
Loading…
Reference in a new issue