mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-22 17:52:06 +00:00
Improve watched marking parity
This commit is contained in:
parent
4e5a32510b
commit
efddba9df2
6 changed files with 326 additions and 33 deletions
|
|
@ -1019,6 +1019,7 @@
|
|||
<string name="episode_mark_previous_watched">Mark previous as watched</string>
|
||||
<string name="episode_mark_season_unwatched">Mark %1$s as unwatched</string>
|
||||
<string name="episode_mark_season_watched">Mark %1$s as watched</string>
|
||||
<string name="episode_mark_previous_seasons_watched">Mark previous seasons as watched</string>
|
||||
<string name="episode_mark_unwatched">Mark as unwatched</string>
|
||||
<string name="episode_mark_watched">Mark as watched</string>
|
||||
<string name="home_continue_watching_up_next">Up next</string>
|
||||
|
|
|
|||
|
|
@ -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<MetaVideo?>(null) }
|
||||
var selectedSeasonForActions by remember(type, id) { mutableStateOf<Int?>(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<String>,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
): 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<MetaVideo>,
|
||||
watchedKeys: Set<String>,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
): 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Int>,
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import kotlinx.serialization.json.Json
|
|||
@Serializable
|
||||
private data class StoredWatchedPayload(
|
||||
val items: List<WatchedItem> = 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<String, WatchedItem> = 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<StoredWatchedPayload>(payload).items
|
||||
}.getOrDefault(emptyList())
|
||||
itemsByKey = items
|
||||
val storedPayload = runCatching {
|
||||
json.decodeFromString<StoredWatchedPayload>(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<WatchedItem>) {
|
||||
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<WatchedItem>,
|
||||
localItems: Collection<WatchedItem>,
|
||||
lastSuccessfulPushEpochMs: Long,
|
||||
pullStartedEpochMs: Long,
|
||||
): Map<String, WatchedItem> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue