From efddba9df2f60c7d875291bb7d99c4ef49cf82c1 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 22 May 2026 02:57:48 +0530 Subject: [PATCH] Improve watched marking parity --- .../composeResources/values/strings.xml | 1 + .../app/features/details/MetaDetailsScreen.kt | 142 +++++++++++++++--- .../details/components/DetailSeriesContent.kt | 20 ++- .../components/EpisodeWatchedActionSheet.kt | 72 ++++++++- .../app/features/watched/WatchedRepository.kt | 70 ++++++++- .../features/watched/WatchedRepositoryTest.kt | 54 ++++++- 6 files changed, 326 insertions(+), 33 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index cc326794..8f585ada 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1019,6 +1019,7 @@ Mark previous as watched Mark %1$s as unwatched Mark %1$s as watched + Mark previous seasons as watched Mark as unwatched Mark as watched Up next diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index d8bfbf27..899747c7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -75,6 +75,7 @@ import com.nuvio.app.features.details.components.DetailProductionSection import com.nuvio.app.features.details.components.DetailSeriesContent import com.nuvio.app.features.details.components.DetailTrailersSection import com.nuvio.app.features.details.components.EpisodeWatchedActionSheet +import com.nuvio.app.features.details.components.SeasonWatchedActionSheet import com.nuvio.app.features.details.components.TrailerPlayerPopup import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.library.LibraryRepository @@ -92,6 +93,7 @@ import com.nuvio.app.features.trailer.TrailerPlaybackResolver import com.nuvio.app.features.trailer.TrailerPlaybackSource import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watched.previousReleasedEpisodesBefore +import com.nuvio.app.features.watched.releasedPlayableEpisodes import com.nuvio.app.features.watched.releasedEpisodesForSeason import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.WatchProgressEntry @@ -151,6 +153,7 @@ fun MetaDetailsScreen( var autoLoadAttempted by remember(type, id) { mutableStateOf(false) } var observedOfflineState by remember(type, id) { mutableStateOf(false) } var selectedEpisodeForActions by remember(type, id) { mutableStateOf(null) } + var selectedSeasonForActions by remember(type, id) { mutableStateOf(null) } val commentsEnabled by remember { TraktCommentsSettings.ensureLoaded() TraktCommentsSettings.enabled @@ -337,7 +340,10 @@ fun MetaDetailsScreen( LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L)) } } - val movieProgress = watchProgressUiState.byVideoId[meta.id] + val progressByVideoId = remember(watchProgressUiState.entries) { + watchProgressUiState.byVideoId + } + val movieProgress = progressByVideoId[meta.id] ?.takeUnless { it.isCompleted } val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode) { @@ -715,11 +721,12 @@ fun MetaDetailsScreen( }, onCommentClick = { review -> selectedComment = review }, onTrailerClick = resolveTrailer, - progressByVideoId = watchProgressUiState.byVideoId, + progressByVideoId = progressByVideoId, watchedKeys = watchedUiState.watchedKeys, blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, onEpisodeClick = onEpisodePlayClick, onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, + onSeasonLongPress = { season -> selectedSeasonForActions = season }, onOpenMeta = onOpenMeta, onCastClick = onCastClick, onCompanyClick = onCompanyClick, @@ -776,12 +783,12 @@ fun MetaDetailsScreen( ) selectedEpisodeForActions?.let { selectedEpisode -> - val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys) { - WatchingState.isEpisodeWatched( - watchedKeys = watchedUiState.watchedKeys, - metaType = meta.type, - metaId = meta.id, + val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys, progressByVideoId) { + isEpisodeWatchedForActions( + meta = meta, episode = selectedEpisode, + watchedKeys = watchedUiState.watchedKeys, + progressByVideoId = progressByVideoId, ) } val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) { @@ -796,20 +803,20 @@ fun MetaDetailsScreen( todayIsoDate = todayIsoDate, ) } - val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys) { - WatchingState.areEpisodesWatched( - watchedKeys = watchedUiState.watchedKeys, - metaType = meta.type, - metaId = meta.id, + val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys, progressByVideoId) { + areEpisodesWatchedForActions( + meta = meta, episodes = previousEpisodes, + watchedKeys = watchedUiState.watchedKeys, + progressByVideoId = progressByVideoId, ) } - val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys) { - WatchingState.areEpisodesWatched( - watchedKeys = watchedUiState.watchedKeys, - metaType = meta.type, - metaId = meta.id, + val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) { + areEpisodesWatchedForActions( + meta = meta, episodes = seasonEpisodes, + watchedKeys = watchedUiState.watchedKeys, + progressByVideoId = progressByVideoId, ) } EpisodeWatchedActionSheet( @@ -850,6 +857,62 @@ fun MetaDetailsScreen( ) } + selectedSeasonForActions?.let { selectedSeason -> + val seasonLabel = selectedSeasonLabel(selectedSeason) + val seasonEpisodes = remember(meta, selectedSeason, todayIsoDate) { + meta.releasedEpisodesForSeason( + seasonNumber = selectedSeason, + todayIsoDate = todayIsoDate, + ) + } + val previousSeasonEpisodes = remember(meta, selectedSeason, todayIsoDate) { + val normalizedSelectedSeason = selectedSeason.coerceAtLeast(0) + meta.releasedPlayableEpisodes(todayIsoDate) + .filter { episode -> + val season = episode.season?.coerceAtLeast(0) ?: 0 + season > 0 && season < normalizedSelectedSeason + } + } + val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) { + areEpisodesWatchedForActions( + meta = meta, + episodes = seasonEpisodes, + watchedKeys = watchedUiState.watchedKeys, + progressByVideoId = progressByVideoId, + ) + } + val canMarkPreviousSeasons = remember(previousSeasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) { + previousSeasonEpisodes.any { episode -> + !isEpisodeWatchedForActions( + meta = meta, + episode = episode, + watchedKeys = watchedUiState.watchedKeys, + progressByVideoId = progressByVideoId, + ) + } + } + SeasonWatchedActionSheet( + seasonLabel = seasonLabel, + isSeasonWatched = isSeasonWatched, + canMarkPreviousSeasons = canMarkPreviousSeasons, + onDismiss = { selectedSeasonForActions = null }, + onToggleSeasonWatched = { + WatchingActions.toggleSeasonWatched( + meta = meta, + episodes = seasonEpisodes, + areCurrentlyWatched = isSeasonWatched, + ) + }, + onMarkPreviousSeasonsWatched = { + WatchingActions.togglePreviousEpisodesWatched( + meta = meta, + episodes = previousSeasonEpisodes, + areCurrentlyWatched = false, + ) + }, + ) + } + if (inAppTrailerPlaybackEnabled) { TrailerPlayerPopup( visible = selectedTrailer != null, @@ -970,6 +1033,49 @@ private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean { return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow") } +@Composable +private fun selectedSeasonLabel(season: Int): String = + if (season == 0) { + stringResource(Res.string.episodes_specials) + } else { + stringResource(Res.string.episodes_season, season) + } + +private fun isEpisodeWatchedForActions( + meta: MetaDetails, + episode: MetaVideo, + watchedKeys: Set, + progressByVideoId: Map, +): Boolean { + val episodeVideoId = buildPlaybackVideoId( + parentMetaId = meta.id, + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = episode.id, + ) + return progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true || + WatchingState.isEpisodeWatched( + watchedKeys = watchedKeys, + metaType = meta.type, + metaId = meta.id, + episode = episode, + ) +} + +private fun areEpisodesWatchedForActions( + meta: MetaDetails, + episodes: Collection, + watchedKeys: Set, + progressByVideoId: Map, +): Boolean = episodes.isNotEmpty() && episodes.all { episode -> + isEpisodeWatchedForActions( + meta = meta, + episode = episode, + watchedKeys = watchedKeys, + progressByVideoId = progressByVideoId, + ) +} + private fun extractImdbId(value: String?): String? = value ?.trim() @@ -1026,6 +1132,7 @@ private fun ConfiguredMetaSections( blurUnwatchedEpisodes: Boolean, onEpisodeClick: (MetaVideo) -> Unit, onEpisodeLongPress: (MetaVideo) -> Unit, + onSeasonLongPress: (Int) -> Unit, onOpenMeta: ((MetaPreview) -> Unit)?, onCastClick: ((MetaPerson, String?) -> Unit)?, onCompanyClick: ((MetaCompany, String) -> Unit)?, @@ -1120,6 +1227,7 @@ private fun ConfiguredMetaSections( blurUnwatchedEpisodes = blurUnwatchedEpisodes, onEpisodeClick = onEpisodeClick, onEpisodeLongPress = onEpisodeLongPress, + onSeasonLongPress = onSeasonLongPress, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index e5140b74..1c598f66 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -100,6 +100,7 @@ fun DetailSeriesContent( blurUnwatchedEpisodes: Boolean = false, onEpisodeClick: ((MetaVideo) -> Unit)? = null, onEpisodeLongPress: ((MetaVideo) -> Unit)? = null, + onSeasonLongPress: ((Int) -> Unit)? = null, ) { val hasVideos = meta.videos.isNotEmpty() if (meta.type != "series" && !hasVideos) return @@ -230,12 +231,14 @@ fun DetailSeriesContent( currentSeason = currentSeason, sizing = sizing, onSelect = { selectedSeasonOverride = it }, + onLongPress = onSeasonLongPress, ) SeasonViewMode.Text -> SeasonTextChipScrollRow( seasons = seasons, currentSeason = currentSeason, sizing = sizing, onSelect = { selectedSeasonOverride = it }, + onLongPress = onSeasonLongPress, ) } } @@ -245,6 +248,7 @@ fun DetailSeriesContent( currentSeason = currentSeason, sizing = sizing, onSelect = { selectedSeasonOverride = it }, + onLongPress = onSeasonLongPress, ) } } @@ -372,12 +376,14 @@ private fun SeasonViewModeToggle( } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun SeasonTextChipScrollRow( seasons: List, currentSeason: Int, sizing: SeriesContentSizing, onSelect: (Int) -> Unit, + onLongPress: ((Int) -> Unit)?, ) { val seasonListState = rememberLazyListState() var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) } @@ -411,7 +417,10 @@ private fun SeasonTextChipScrollRow( Color.Transparent }, ) - .clickable { onSelect(season) } + .combinedClickable( + onClick = { onSelect(season) }, + onLongClick = onLongPress?.let { handler -> { handler(season) } }, + ) .padding( horizontal = sizing.seasonChipHorizontalPadding, vertical = sizing.seasonChipVerticalPadding, @@ -443,6 +452,7 @@ private fun SeasonPosterScrollRow( currentSeason: Int, sizing: SeriesContentSizing, onSelect: (Int) -> Unit, + onLongPress: ((Int) -> Unit)?, ) { val seasonListState = rememberLazyListState() var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) } @@ -475,11 +485,13 @@ private fun SeasonPosterScrollRow( isSelected = season == currentSeason, sizing = sizing, onClick = { onSelect(season) }, + onLongClick = onLongPress?.let { handler -> { handler(season) } }, ) } } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun SeasonPosterButton( label: String, @@ -487,11 +499,15 @@ private fun SeasonPosterButton( isSelected: Boolean, sizing: SeriesContentSizing, onClick: () -> Unit, + onLongClick: (() -> Unit)?, ) { Column( modifier = Modifier .width(sizing.seasonPosterWidth) - .clickable(onClick = onClick), + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Box( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt index 44c02bba..00dd40f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt @@ -1,14 +1,9 @@ package com.nuvio.app.features.details.components -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.DoneAll @@ -135,6 +130,73 @@ fun EpisodeWatchedActionSheet( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SeasonWatchedActionSheet( + seasonLabel: String, + isSeasonWatched: Boolean, + canMarkPreviousSeasons: Boolean, + onDismiss: () -> Unit, + onToggleSeasonWatched: () -> Unit, + onMarkPreviousSeasonsWatched: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + + NuvioModalBottomSheet( + onDismissRequest = { + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = nuvioSafeBottomPadding(16.dp)), + ) { + Text( + text = seasonLabel, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) + NuvioBottomSheetDivider() + NuvioBottomSheetActionRow( + icon = Icons.Default.PlaylistAddCheckCircle, + title = if (isSeasonWatched) { + stringResource(Res.string.episode_mark_season_unwatched, seasonLabel) + } else { + stringResource(Res.string.episode_mark_season_watched, seasonLabel) + }, + onClick = { + onToggleSeasonWatched() + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + ) + if (canMarkPreviousSeasons) { + NuvioBottomSheetDivider() + NuvioBottomSheetActionRow( + icon = Icons.Default.DoneAll, + title = stringResource(Res.string.episode_mark_previous_seasons_watched), + onClick = { + onMarkPreviousSeasonsWatched() + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + ) + } + } + } +} + @Composable private fun EpisodeActionSheetHeader( episode: MetaVideo, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index c2ae8997..a208fe94 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -25,6 +25,7 @@ import kotlinx.serialization.json.Json @Serializable private data class StoredWatchedPayload( val items: List = emptyList(), + val lastSuccessfulPushEpochMs: Long = 0L, ) object WatchedRepository { @@ -43,6 +44,7 @@ object WatchedRepository { private var hasLoaded = false private var currentProfileId: Int = 1 private var itemsByKey: MutableMap = mutableMapOf() + private var lastSuccessfulPushEpochMs: Long = 0L internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter private fun activePullSyncAdapter(): WatchedSyncAdapter = @@ -62,6 +64,7 @@ object WatchedRepository { hasLoaded = false currentProfileId = 1 itemsByKey.clear() + lastSuccessfulPushEpochMs = 0L _uiState.value = WatchedUiState() } @@ -72,13 +75,16 @@ object WatchedRepository { val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim() if (payload.isNotEmpty()) { - val items = runCatching { - json.decodeFromString(payload).items - }.getOrDefault(emptyList()) - itemsByKey = items + val storedPayload = runCatching { + json.decodeFromString(payload) + }.getOrDefault(StoredWatchedPayload()) + lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs + itemsByKey = storedPayload.items .map(WatchedItem::normalizedMarkedAt) .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .toMutableMap() + } else { + lastSuccessfulPushEpochMs = 0L } publish() @@ -88,16 +94,23 @@ object WatchedRepository { TraktAuthRepository.ensureLoaded() TraktSettingsRepository.ensureLoaded() currentProfileId = profileId + val pullStartedEpochMs = WatchedClock.nowEpochMs() + val localBeforePull = itemsByKey.values + .map(WatchedItem::normalizedMarkedAt) + .toList() + val lastPushEpochMs = lastSuccessfulPushEpochMs runCatching { val serverItems = activePullSyncAdapter().pull( profileId = profileId, pageSize = watchedItemsPageSize, ) - itemsByKey = serverItems - .map(WatchedItem::normalizedMarkedAt) - .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } - .toMutableMap() + itemsByKey = mergeWatchedItemsPreservingUnsynced( + serverItems = serverItems, + localItems = localBeforePull, + lastSuccessfulPushEpochMs = lastPushEpochMs, + pullStartedEpochMs = pullStartedEpochMs, + ).toMutableMap() hasLoaded = true publish() persist() @@ -213,6 +226,7 @@ object WatchedRepository { if (items.isEmpty()) return@runCatching val profileId = ProfileRepository.activeProfileId pushToActiveTargets(profileId = profileId, items = items) + recordSuccessfulPush(profileId = profileId, items = items) }.onFailure { e -> log.e(e) { "Failed to push watched items" } } @@ -252,11 +266,24 @@ object WatchedRepository { items = itemsByKey.values .map(WatchedItem::normalizedMarkedAt) .sortedByDescending { it.markedAtEpochMs }, + lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, ), ), ) } + private fun recordSuccessfulPush(profileId: Int, items: Collection) { + if (profileId != currentProfileId) return + val latestPushed = items + .asSequence() + .map { item -> normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs) } + .maxOrNull() + ?: return + if (latestPushed <= lastSuccessfulPushEpochMs) return + lastSuccessfulPushEpochMs = latestPushed + persist() + } + private fun shouldUseTraktWatchedSync(): Boolean = shouldUseTraktWatchedSync( isAuthenticated = TraktAuthRepository.isAuthenticated.value, @@ -294,6 +321,33 @@ object WatchedRepository { } } +internal fun mergeWatchedItemsPreservingUnsynced( + serverItems: Collection, + localItems: Collection, + lastSuccessfulPushEpochMs: Long, + pullStartedEpochMs: Long, +): Map { + val merged = serverItems + .map(WatchedItem::normalizedMarkedAt) + .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } + .toMutableMap() + + localItems + .map(WatchedItem::normalizedMarkedAt) + .forEach { localItem -> + val key = watchedItemKey(localItem.type, localItem.id, localItem.season, localItem.episode) + if (key in merged) return@forEach + val markedAt = localItem.markedAtEpochMs + val wasMarkedAfterLastPush = lastSuccessfulPushEpochMs > 0L && markedAt > lastSuccessfulPushEpochMs + val wasMarkedDuringPull = pullStartedEpochMs > 0L && markedAt >= pullStartedEpochMs + if (wasMarkedAfterLastPush || wasMarkedDuringPull) { + merged[key] = localItem + } + } + + return merged +} + internal fun shouldUseTraktWatchedSync( isAuthenticated: Boolean, source: WatchProgressSource, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt index ca489fe2..43356837 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt @@ -44,5 +44,57 @@ class WatchedRepositoryTest { assertTrue(result) } -} + @Test + fun mergeWatchedItemsPreservingUnsynced_keeps_local_items_marked_after_last_push() { + val serverItem = WatchedItem( + id = "show", + type = "series", + name = "Episode 1", + season = 1, + episode = 1, + markedAtEpochMs = 1_000L, + ) + val unsyncedLocalItem = WatchedItem( + id = "show", + type = "series", + name = "Episode 2", + season = 1, + episode = 2, + markedAtEpochMs = 3_000L, + ) + + val merged = mergeWatchedItemsPreservingUnsynced( + serverItems = listOf(serverItem), + localItems = listOf(serverItem, unsyncedLocalItem), + lastSuccessfulPushEpochMs = 2_000L, + pullStartedEpochMs = 4_000L, + ) + + assertEquals( + setOf("series:show:1:1", "series:show:1:2"), + merged.keys, + ) + } + + @Test + fun mergeWatchedItemsPreservingUnsynced_drops_old_local_items_missing_from_server() { + val oldLocalItem = WatchedItem( + id = "show", + type = "series", + name = "Episode 1", + season = 1, + episode = 1, + markedAtEpochMs = 1_000L, + ) + + val merged = mergeWatchedItemsPreservingUnsynced( + serverItems = emptyList(), + localItems = listOf(oldLocalItem), + lastSuccessfulPushEpochMs = 2_000L, + pullStartedEpochMs = 4_000L, + ) + + assertTrue(merged.isEmpty()) + } +}