fix: stabilize home startup and watched badges

This commit is contained in:
tapframe 2026-06-30 21:56:34 +05:30
parent b9caec19e7
commit c17900ce0b
13 changed files with 173 additions and 241 deletions

View file

@ -210,7 +210,6 @@ import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.nextUpDismissKey
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
import com.nuvio.app.features.watching.application.SeriesWatchedReconciliationService
import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.flow.Flow
@ -692,9 +691,6 @@ private fun MainAppContent(
remember {
ProfileSettingsSync.startObserving()
}
remember {
SeriesWatchedReconciliationService.startObserving()
}
val hapticFeedback = LocalHapticFeedback.current
val focusManager = LocalFocusManager.current
val coroutineScope = rememberCoroutineScope()

View file

@ -82,6 +82,7 @@ fun CatalogScreen(
WatchedRepository.ensureLoaded()
WatchedRepository.uiState
}.collectAsStateWithLifecycle()
val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle()
val initialScrollPosition = remember(
target,
homeCatalogSettingsUiState.hideUnreleasedContent,
@ -203,6 +204,7 @@ fun CatalogScreen(
isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedUiState.watchedKeys,
item = item,
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
),
onClick = onPosterClick?.let { { it(item) } },
onLongClick = onPosterLongClick?.let { { it(item) } },

View file

@ -162,6 +162,7 @@ fun MetaDetailsScreen(
WatchedRepository.ensureLoaded()
WatchedRepository.uiState
}.collectAsStateWithLifecycle()
val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle()
val watchProgressUiState by remember {
WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState
@ -360,10 +361,11 @@ fun MetaDetailsScreen(
) {
LibraryRepository.isSaved(meta.id, meta.type)
}
val isWatched = remember(watchedUiState.watchedKeys, metaPreview) {
val isWatched = remember(watchedUiState.watchedKeys, fullyWatchedSeriesKeys, metaPreview) {
WatchingState.isPosterWatched(
watchedKeys = watchedUiState.watchedKeys,
item = metaPreview,
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
)
}
val openLibraryListPicker = remember(meta) {
@ -411,6 +413,22 @@ fun MetaDetailsScreen(
WatchProgressRepository.refreshEpisodeProgress(meta.id)
}
}
LaunchedEffect(
meta.id,
meta.type,
todayIsoDate,
watchedUiState.isLoaded,
watchProgressUiState.hasLoadedRemoteProgress,
watchedUiState.watchedKeys,
watchProgressUiState.entries,
) {
if (watchedUiState.isLoaded && watchProgressUiState.hasLoadedRemoteProgress) {
WatchingActions.reconcileSeriesWatchedState(
meta = meta,
todayIsoDate = todayIsoDate,
)
}
}
val movieProgress = progressByVideoId[meta.id]
?.takeUnless { it.isCompleted }
val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()

View file

@ -70,6 +70,14 @@ private fun buildHomeCatalogDescriptorSignature(
buildString {
append(addon.displayTitle)
append('|')
append(addon.enabled)
append('|')
append(addon.isRefreshing)
append('|')
append(addon.errorMessage.orEmpty())
append('|')
append(addon.manifestUrl)
append('|')
append(manifest.id)
append('|')
append(manifest.name)

View file

@ -34,7 +34,7 @@ object HomeRepository {
private var activeJob: Job? = null
private var activeRequestKey: String? = null
private var lastRequestKey: String? = null
private var completedRequestKey: String? = null
private var currentDefinitions: List<HomeCatalogDefinition> = emptyList()
private var cachedSections: Map<String, HomeCatalogSection> = emptyMap()
private var cachedCollectionHeroItems: List<MetaPreview> = emptyList()
@ -49,25 +49,28 @@ object HomeRepository {
currentDefinitions = requests
val requestCacheKeys = requests.mapTo(mutableSetOf(), HomeCatalogDefinition::cacheKey)
cachedSections = cachedSections.filterKeys(requestCacheKeys::contains)
val requestKey = requests.joinToString(separator = "|") { request ->
request.cacheKey
}
val requestKey = requests.joinToString(separator = "|", transform = HomeCatalogDefinition::cacheKey)
if (!force && activeRequestKey == requestKey && _uiState.value.isLoading) return
if (!force && requestKey == lastRequestKey && requestCacheKeys.all(cachedSections::containsKey)) {
if (
!force &&
requestKey == completedRequestKey &&
requestCacheKeys.all(cachedSections::containsKey) &&
requestCacheKeys.any(::hasRenderableCachedSection)
) {
if (_uiState.value.sections.isEmpty() || _uiState.value.heroItems.isEmpty()) {
applyCurrentSettings()
}
return
}
lastRequestKey = requestKey
activeRequestKey = requestKey
if (requests.isEmpty()) {
activeJob?.cancel()
activeJob = null
activeRequestKey = null
completedRequestKey = requestKey
cachedSections = emptyMap()
lastErrorMessage = null
publishCurrentState(
@ -138,6 +141,10 @@ object HomeRepository {
cachedSections = loadedSections.toMap()
lastErrorMessage = firstErrorMessage
if (cachedSections.values.any { section -> section.items.isNotEmpty() }) {
completedRequestKey = requestKey
}
activeRequestKey = null
publishCurrentState(
isLoading = false,
requestKey = requestKey,
@ -153,12 +160,12 @@ object HomeRepository {
fun applyCurrentSettings() {
publishCurrentState(
isLoading = _uiState.value.isLoading,
requestKey = activeRequestKey ?: lastRequestKey,
requestKey = activeRequestKey ?: completedRequestKey,
)
ensureCollectionHeroFallback(
addons = AddonRepository.uiState.value.addons.enabledAddons(),
force = false,
requestKey = activeRequestKey ?: lastRequestKey,
requestKey = activeRequestKey ?: completedRequestKey,
)
}
@ -166,7 +173,7 @@ object HomeRepository {
activeJob?.cancel()
activeJob = null
activeRequestKey = null
lastRequestKey = null
completedRequestKey = null
currentDefinitions = emptyList()
cachedSections = emptyMap()
cachedCollectionHeroItems = emptyList()
@ -178,6 +185,9 @@ object HomeRepository {
_uiState.value = HomeUiState()
}
private fun hasRenderableCachedSection(cacheKey: String): Boolean =
cachedSections[cacheKey]?.items?.isNotEmpty() == true
private fun publishCurrentState(
isLoading: Boolean,
requestKey: String?,

View file

@ -46,6 +46,8 @@ import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
import com.nuvio.app.features.trakt.shouldUseTraktProgress
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.episodePlaybackId
import com.nuvio.app.features.watched.watchedItemKey
import com.nuvio.app.features.watchprogress.CachedInProgressItem
import com.nuvio.app.features.watchprogress.CachedNextUpItem
import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
@ -122,6 +124,7 @@ fun HomeScreen(
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle()
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
@ -853,6 +856,7 @@ fun HomeScreen(
null
},
watchedKeys = watchedUiState.watchedKeys,
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
@ -1013,6 +1017,23 @@ private suspend fun resolveHomeNextUpCandidate(
} else {
watchedItems
}
val resolvedWatchedKeys = resolvedWatchedItems.mapTo(linkedSetOf()) { item ->
watchedItemKey(item.type, item.id, item.season, item.episode)
}
WatchedRepository.reconcileFullyWatchedSeriesState(
meta = meta,
todayIsoDate = todayIsoDate,
isEpisodeWatched = { episode ->
watchedItemKey(meta.type, meta.id, episode.season, episode.episode) in resolvedWatchedKeys
},
isEpisodeCompleted = { episode ->
val playbackId = meta.episodePlaybackId(episode)
resolvedProgressEntries.any { entry ->
entry.videoId == playbackId && entry.isEffectivelyCompleted
}
},
)
val action = meta.seriesPrimaryAction(
content = completedEntry.content,

View file

@ -24,6 +24,7 @@ fun HomeCatalogRowSection(
modifier: Modifier = Modifier,
entries: List<MetaPreview> = section.items,
watchedKeys: Set<String> = emptySet(),
fullyWatchedSeriesKeys: Set<String> = emptySet(),
sectionPadding: Dp? = null,
onViewAllClick: (() -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
@ -34,6 +35,7 @@ fun HomeCatalogRowSection(
section = section,
entries = entries,
watchedKeys = watchedKeys,
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding,
onViewAllClick = onViewAllClick,
@ -46,6 +48,7 @@ fun HomeCatalogRowSection(
section = section,
entries = entries,
watchedKeys = watchedKeys,
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
modifier = Modifier.fillMaxWidth(),
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
onViewAllClick = onViewAllClick,
@ -61,6 +64,7 @@ private fun HomeCatalogRowSectionContent(
section: HomeCatalogSection,
entries: List<MetaPreview>,
watchedKeys: Set<String>,
fullyWatchedSeriesKeys: Set<String>,
modifier: Modifier,
sectionPadding: Dp,
onViewAllClick: (() -> Unit)?,
@ -90,6 +94,7 @@ private fun HomeCatalogRowSectionContent(
isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedKeys,
item = item,
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
),
onClick = onPosterClick?.let { { it(item) } },
onLongClick = onPosterLongClick?.let { { it(item) } },

View file

@ -55,6 +55,7 @@ internal fun LazyListScope.discoverContent(
onGenreSelected: (String?) -> Unit,
onRetry: (() -> Unit)? = null,
watchedKeys: Set<String> = emptySet(),
fullyWatchedSeriesKeys: Set<String> = emptySet(),
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
) {
@ -117,6 +118,7 @@ internal fun LazyListScope.discoverContent(
columns = columns,
modifier = Modifier.padding(horizontal = 16.dp),
watchedKeys = watchedKeys,
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
@ -197,6 +199,7 @@ private fun DiscoverGridRow(
columns: Int,
modifier: Modifier = Modifier,
watchedKeys: Set<String> = emptySet(),
fullyWatchedSeriesKeys: Set<String> = emptySet(),
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
) {
@ -216,6 +219,7 @@ private fun DiscoverGridRow(
isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedKeys,
item = item,
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
),
onClick = onPosterClick?.let { { it(item) } },
onLongClick = onPosterLongClick?.let { { it(item) } },

View file

@ -111,6 +111,7 @@ fun SearchScreen(
}.collectAsStateWithLifecycle()
val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle()
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
var lastRequestedQuery by rememberSaveable { mutableStateOf<String?>(null) }
@ -305,6 +306,7 @@ fun SearchScreen(
SearchRepository.refreshDiscover(addonsUiState.addons)
},
watchedKeys = watchedUiState.watchedKeys,
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
@ -360,6 +362,7 @@ fun SearchScreen(
section = section,
modifier = Modifier.padding(bottom = 12.dp),
watchedKeys = watchedUiState.watchedKeys,
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.watched
import co.touchlab.kermit.Logger
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
@ -26,6 +27,7 @@ import kotlinx.serialization.json.Json
@Serializable
private data class StoredWatchedPayload(
val items: List<WatchedItem> = emptyList(),
val fullyWatchedSeriesKeys: Set<String> = emptySet(),
val lastSuccessfulPushEpochMs: Long = 0L,
val deltaCursorEventId: Long = 0L,
val deltaInitialized: Boolean = false,
@ -56,6 +58,8 @@ object WatchedRepository {
private val _uiState = MutableStateFlow(WatchedUiState())
val uiState: StateFlow<WatchedUiState> = _uiState.asStateFlow()
private val _fullyWatchedSeriesKeys = MutableStateFlow<Set<String>>(emptySet())
val fullyWatchedSeriesKeys: StateFlow<Set<String>> = _fullyWatchedSeriesKeys.asStateFlow()
private var hasLoaded = false
private var currentProfileId: Int = 1
@ -84,6 +88,7 @@ object WatchedRepository {
lastSuccessfulPushEpochMs = 0L
deltaCursorEventId = 0L
deltaInitialized = false
_fullyWatchedSeriesKeys.value = emptySet()
_uiState.value = WatchedUiState()
}
@ -105,10 +110,12 @@ object WatchedRepository {
.map(WatchedItem::normalizedMarkedAt)
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
.toMutableMap()
_fullyWatchedSeriesKeys.value = storedPayload.fullyWatchedSeriesKeys
} else {
lastSuccessfulPushEpochMs = 0L
deltaCursorEventId = 0L
deltaInitialized = false
_fullyWatchedSeriesKeys.value = emptySet()
}
publish()
@ -380,14 +387,11 @@ object WatchedRepository {
if (!meta.type.isSeriesLikeWatchedType()) return
ensureLoaded()
val shouldMarkSeriesWatched = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate) { episode ->
isWatched(
id = meta.id,
type = meta.type,
season = episode.season,
episode = episode.episode,
) || isEpisodeCompleted(episode)
}
val shouldMarkSeriesWatched = reconcileFullyWatchedSeriesState(
meta = meta,
todayIsoDate = todayIsoDate,
isEpisodeCompleted = isEpisodeCompleted,
)
val seriesWatchedItem = meta.toSeriesWatchedItem()
val hasSeriesWatchedMarker = isWatched(id = meta.id, type = meta.type)
if (shouldMarkSeriesWatched) {
@ -399,6 +403,56 @@ object WatchedRepository {
}
}
fun reconcileFullyWatchedSeriesState(
meta: MetaDetails,
todayIsoDate: String,
isEpisodeWatched: (MetaVideo) -> Boolean = { episode ->
isWatched(
id = meta.id,
type = meta.type,
season = episode.season,
episode = episode.episode,
)
},
isEpisodeCompleted: (MetaVideo) -> Boolean = { false },
): Boolean {
if (!meta.type.isSeriesLikeWatchedType()) return false
ensureLoaded()
val shouldMarkSeriesWatched = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate) { episode ->
isEpisodeWatched(episode) || isEpisodeCompleted(episode)
}
updateFullyWatchedSeriesKey(
key = watchedItemKey(meta.type, meta.id),
isFullyWatched = shouldMarkSeriesWatched,
)
return shouldMarkSeriesWatched
}
fun updateFullyWatchedSeries(
id: String,
type: String,
isFullyWatched: Boolean,
) {
if (!type.isSeriesLikeWatchedType()) return
ensureLoaded()
updateFullyWatchedSeriesKey(
key = watchedItemKey(type, id),
isFullyWatched = isFullyWatched,
)
}
private fun updateFullyWatchedSeriesKey(
key: String,
isFullyWatched: Boolean,
) {
val current = _fullyWatchedSeriesKeys.value
val updated = if (isFullyWatched) current + key else current - key
if (updated == current) return
_fullyWatchedSeriesKeys.value = updated
persist()
}
private fun pushMarksToServer(
items: Collection<WatchedItem>,
traktHistorySync: WatchedTraktHistorySync,
@ -454,6 +508,7 @@ object WatchedRepository {
items = itemsByKey.values
.map(WatchedItem::normalizedMarkedAt)
.sortedByDescending { it.markedAtEpochMs },
fullyWatchedSeriesKeys = _fullyWatchedSeriesKeys.value,
lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs,
deltaCursorEventId = deltaCursorEventId,
deltaInitialized = deltaInitialized,

View file

@ -1,218 +0,0 @@
package com.nuvio.app.features.watching.application
import co.touchlab.kermit.Logger
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.episodePlaybackId
import com.nuvio.app.features.watched.watchedItemKey
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.WatchProgressUiState
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
private const val SERIES_RECONCILIATION_DEBOUNCE_MS = 500L
private const val SERIES_RECONCILIATION_BATCH_LIMIT = 24
private const val SERIES_RECONCILIATION_CONCURRENCY = 3
private data class SeriesWatchedKey(
val profileId: Int,
val type: String,
val id: String,
)
private data class SeriesReconciliationCandidate(
val key: SeriesWatchedKey,
val signature: String,
val latestUpdatedAt: Long,
)
object SeriesWatchedReconciliationService {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log = Logger.withTag("SeriesWatchedReconciliation")
private val lastReconciledSignatures = linkedMapOf<SeriesWatchedKey, String>()
private var observeJob: Job? = null
@OptIn(FlowPreview::class)
fun startObserving() {
if (observeJob?.isActive == true) return
observeJob = scope.launch {
combine(
WatchedRepository.uiState,
WatchProgressRepository.uiState,
) { watchedState, progressState ->
if (!watchedState.isLoaded || !progressState.hasLoadedRemoteProgress) {
emptyList()
} else {
buildReconciliationCandidates(
watchedItems = watchedState.items,
progressState = progressState,
)
}
}
.distinctUntilChanged()
.debounce(SERIES_RECONCILIATION_DEBOUNCE_MS)
.collectLatest { candidates ->
reconcile(candidates)
}
}
}
fun clear() {
observeJob?.cancel()
observeJob = null
lastReconciledSignatures.clear()
}
private suspend fun reconcile(candidates: List<SeriesReconciliationCandidate>) {
if (candidates.isEmpty()) return
val activeProfileId = ProfileRepository.activeProfileId
val pending = candidates
.filter { candidate -> lastReconciledSignatures[candidate.key] != candidate.signature }
.sortedByDescending(SeriesReconciliationCandidate::latestUpdatedAt)
.take(SERIES_RECONCILIATION_BATCH_LIMIT)
if (pending.isEmpty()) return
val todayIsoDate = CurrentDateProvider.todayIsoDate()
val progressByVideoId = WatchProgressRepository.uiState.value.byVideoId
val semaphore = Semaphore(SERIES_RECONCILIATION_CONCURRENCY)
coroutineScope {
pending.map { candidate ->
async {
semaphore.withPermit {
reconcileCandidate(
candidate = candidate,
activeProfileId = activeProfileId,
todayIsoDate = todayIsoDate,
progressByVideoId = progressByVideoId,
)
}
}
}.awaitAll()
}
}
private suspend fun reconcileCandidate(
candidate: SeriesReconciliationCandidate,
activeProfileId: Int,
todayIsoDate: String,
progressByVideoId: Map<String, WatchProgressEntry>,
) {
if (ProfileRepository.activeProfileId != activeProfileId) return
val meta = try {
MetaDetailsRepository.fetch(
type = candidate.key.type,
id = candidate.key.id,
)
} catch (error: Throwable) {
if (error is CancellationException) throw error
log.d { "Skipping series watched reconciliation for ${candidate.key.id}: ${error.message}" }
null
} ?: return
if (ProfileRepository.activeProfileId != activeProfileId) return
WatchedRepository.reconcileSeriesWatchedState(
meta = meta,
todayIsoDate = todayIsoDate,
isEpisodeCompleted = { episode ->
progressByVideoId[meta.episodePlaybackId(episode)]?.isEffectivelyCompleted == true
},
)
lastReconciledSignatures[candidate.key] = candidate.signature
}
private fun buildReconciliationCandidates(
watchedItems: List<WatchedItem>,
progressState: WatchProgressUiState,
): List<SeriesReconciliationCandidate> {
val groupedWatched = watchedItems
.filter { item -> item.type.isSeriesLikeType() }
.groupBy { item ->
SeriesWatchedKey(
profileId = ProfileRepository.activeProfileId,
type = item.type,
id = item.id,
)
}
val groupedProgress = progressState.entries
.filter { entry -> entry.parentMetaType.isSeriesLikeType() && entry.isEpisode }
.groupBy { entry ->
SeriesWatchedKey(
profileId = ProfileRepository.activeProfileId,
type = entry.parentMetaType,
id = entry.parentMetaId,
)
}
return (groupedWatched.keys + groupedProgress.keys)
.mapNotNull { key ->
val watched = groupedWatched[key].orEmpty()
val progress = groupedProgress[key].orEmpty()
val hasSeriesMarker = watched.any { item -> item.season == null && item.episode == null }
val watchedEpisodes = watched
.filter { item -> item.season != null && item.episode != null }
.sortedWith(compareBy<WatchedItem> { it.season ?: -1 }.thenBy { it.episode ?: -1 })
val completedProgress = progress
.filter(WatchProgressEntry::isEffectivelyCompleted)
.sortedWith(compareBy<WatchProgressEntry> { it.seasonNumber ?: -1 }.thenBy { it.episodeNumber ?: -1 })
if (!hasSeriesMarker && watchedEpisodes.isEmpty() && completedProgress.isEmpty()) {
return@mapNotNull null
}
SeriesReconciliationCandidate(
key = key,
latestUpdatedAt = maxOf(
watched.maxOfOrNull(WatchedItem::markedAtEpochMs) ?: 0L,
progress.maxOfOrNull(WatchProgressEntry::lastUpdatedEpochMs) ?: 0L,
),
signature = buildString {
append("series=")
append(hasSeriesMarker)
append("|watched=")
watchedEpisodes.forEach { item ->
append(item.season)
append(":")
append(item.episode)
append(":")
append(item.markedAtEpochMs)
append(",")
}
append("|progress=")
completedProgress.forEach { entry ->
append(entry.seasonNumber)
append(":")
append(entry.episodeNumber)
append(":")
append(entry.lastUpdatedEpochMs)
append(",")
}
},
)
}
}
}
private fun String.isSeriesLikeType(): Boolean =
trim().lowercase() in setOf("series", "show", "tv", "tvshow")

View file

@ -36,6 +36,11 @@ object WatchingActions {
if (meta == null) {
if (isCurrentlyWatched) {
WatchedRepository.unmarkWatched(preview.toWatchedItem(markedAtEpochMs = 0L))
WatchedRepository.updateFullyWatchedSeries(
id = preview.id,
type = preview.type,
isFullyWatched = false,
)
}
return
}
@ -45,6 +50,11 @@ object WatchingActions {
if (releasedMainEpisodes.isEmpty()) {
if (isCurrentlyWatched) {
WatchedRepository.unmarkWatched(meta.toSeriesWatchedItem())
WatchedRepository.updateFullyWatchedSeries(
id = meta.id,
type = meta.type,
isFullyWatched = false,
)
}
return
}
@ -55,8 +65,18 @@ object WatchingActions {
if (isCurrentlyWatched) {
WatchedRepository.unmarkWatched(seriesItems)
WatchedRepository.updateFullyWatchedSeries(
id = meta.id,
type = meta.type,
isFullyWatched = false,
)
} else {
WatchedRepository.markWatched(seriesItems)
WatchedRepository.updateFullyWatchedSeries(
id = meta.id,
type = meta.type,
isFullyWatched = true,
)
WatchProgressRepository.clearProgress(
releasedMainEpisodes.map(meta::episodePlaybackId),
)

View file

@ -18,7 +18,12 @@ object WatchingState {
fun isPosterWatched(
watchedKeys: Set<String>,
item: MetaPreview,
): Boolean = watchedKeys.contains(watchedItemKey(item.type, item.id))
fullyWatchedSeriesKeys: Set<String> = emptySet(),
): Boolean {
val posterKey = watchedItemKey(item.type, item.id)
if (watchedKeys.contains(posterKey)) return true
return item.type.isSeriesLikePosterType() && fullyWatchedSeriesKeys.contains(posterKey)
}
fun isEpisodeWatched(
watchedKeys: Set<String>,
@ -82,6 +87,9 @@ object WatchingState {
): List<WatchProgressEntry> = progressEntries.continueWatchingEntries()
}
private fun String.isSeriesLikePosterType(): Boolean =
trim().lowercase() in setOf("series", "show", "tv", "tvshow")
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
normalizedCompletion().let { entry ->
WatchingProgressRecord(