From b21b36f8e0423ff3cbd260f7361d2b61c1ffaabe Mon Sep 17 00:00:00 2001 From: skoruppa Date: Tue, 28 Jul 2026 11:11:34 +0200 Subject: [PATCH 1/2] fix: bulk watched badge resolution with sibling expansion and anime fallback --- .../com/nuvio/app/features/home/HomeScreen.kt | 60 +----- .../app/features/simkl/SimklProjections.kt | 14 ++ .../features/trakt/TraktProgressRepository.kt | 2 + .../watched/WatchedBadgeBulkResolver.kt | 173 ++++++++++++++++++ .../app/features/watched/WatchedRepository.kt | 23 ++- 5 files changed, 208 insertions(+), 64 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedBadgeBulkResolver.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 547f2a6d5..30c07a96a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -54,6 +54,7 @@ import com.nuvio.app.features.tracking.WatchProgressSource 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.resolveWatchedBadgesBulk import com.nuvio.app.features.watched.watchedItemKey import com.nuvio.app.features.watchprogress.CachedInProgressItem import com.nuvio.app.features.watchprogress.CachedNextUpItem @@ -832,21 +833,11 @@ fun HomeScreen( val keyedEnabledHomeItems = remember(enabledHomeItems) { enabledHomeItems.withDuplicateSafeLazyKeys(HomeCatalogSettingsItem::key) } - val visibleSeriesPosterTargets = remember(enabledHomeItems, sectionsMap) { - enabledHomeItems - .filterNot { it.isCollection } - .mapNotNull { settingsItem -> sectionsMap[settingsItem.key] } - .flatMap { section -> section.items.take(HOME_CATALOG_PREVIEW_LIMIT) } - .filter { item -> item.type.isHomeSeriesLikeType() } - .distinctBy { item -> watchedItemKey(item.type, item.id) } - } LaunchedEffect( - visibleSeriesPosterTargets, watchedUiState.items, watchProgressUiState.entries, ) { - reconcileVisibleSeriesPosterBadges( - items = visibleSeriesPosterTargets, + resolveWatchedBadgesBulk( watchedItems = watchedUiState.items, progressEntries = watchProgressUiState.entries, ) @@ -1121,53 +1112,6 @@ private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4 private const val MAX_NEXT_UP_RESOLUTION_RETRIES = 3 private const val NEXT_UP_RESOLUTION_RETRY_BASE_DELAY_MS = 1_500L -private suspend fun reconcileVisibleSeriesPosterBadges( - items: List, - watchedItems: List, - progressEntries: List, -) { - if (items.isEmpty()) return - val watchedKeys = watchedItems.mapTo(linkedSetOf()) { item -> - watchedItemKey(item.type, item.id, item.season, item.episode) - } - val touchedSeriesIds = buildSet { - watchedItems.forEach { item -> - if (item.type.isHomeSeriesLikeType() && item.season != null && item.episode != null) { - add(item.id) - } - } - progressEntries.forEach { entry -> - if (entry.parentMetaType.isHomeSeriesLikeType() && entry.isEpisode && entry.isEffectivelyCompleted) { - add(entry.parentMetaId) - } - } - } - if (touchedSeriesIds.isEmpty()) return - val todayIsoDate = CurrentDateProvider.todayIsoDate() - withContext(Dispatchers.Default) { - items - .filter { item -> item.id in touchedSeriesIds } - .forEach { item -> - val meta = runCatching { - MetaDetailsRepository.fetch(type = item.type, id = item.id) - }.getOrNull() ?: return@forEach - WatchedRepository.reconcileFullyWatchedSeriesState( - meta = meta, - todayIsoDate = todayIsoDate, - isEpisodeWatched = { episode -> - watchedItemKey(meta.type, meta.id, episode.season, episode.episode) in watchedKeys - }, - isEpisodeCompleted = { episode -> - val playbackId = meta.episodePlaybackId(episode) - progressEntries.any { entry -> - entry.videoId == playbackId && entry.isEffectivelyCompleted - } - }, - ) - } - } -} - private fun String.isHomeSeriesLikeType(): Boolean = trim().lowercase() in setOf("series", "show", "tv", "tvshow") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index db7e94118..701ee8797 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -148,6 +148,20 @@ internal fun SimklSyncSnapshot.toSimklProgressEntries(): List candidates.maxByOrNull(WatchProgressEntry::lastUpdatedEpochMs) } .sortedByDescending(WatchProgressEntry::lastUpdatedEpochMs) +internal fun SimklSyncSnapshot.toSimklShowIdSiblings(): Map> { + val siblingsMap = mutableMapOf>() + entries.forEach { entry -> + val media = entry.media ?: return@forEach + if (entry.mediaType == SimklMediaType.MOVIES) return@forEach + val keys = media.alternateContentIds().toList() + if (keys.size <= 1) return@forEach + for (key in keys) { + siblingsMap.getOrPut(key) { mutableSetOf() }.addAll(keys - key) + } + } + return siblingsMap.mapValues { (_, siblings) -> siblings.toSet() } +} + internal fun SimklSyncSnapshot.mediaReference( contentId: String, contentType: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index 8da4ec199..7e77567db 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -109,6 +109,8 @@ object TraktProgressRepository { private var showIdToTraktPathId: Map = emptyMap() private var showIdSiblingsMap: Map> = emptyMap() + fun getShowIdSiblings(): Map> = showIdSiblingsMap + init { scope.launch { while (true) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedBadgeBulkResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedBadgeBulkResolver.kt new file mode 100644 index 000000000..231c5b6df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedBadgeBulkResolver.kt @@ -0,0 +1,173 @@ +package com.nuvio.app.features.watched + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.simkl.SimklSyncRepository +import com.nuvio.app.features.simkl.toSimklShowIdSiblings +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingSettingsRepository +import com.nuvio.app.features.tracking.WatchProgressSource +import com.nuvio.app.features.tracking.effectiveWatchProgressSource +import com.nuvio.app.features.tracking.providerId +import com.nuvio.app.features.trakt.TraktProgressRepository +import com.nuvio.app.features.watchprogress.CurrentDateProvider +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield + +private const val BADGE_RESOLUTION_CONCURRENCY = 2 +private const val AMBIGUOUS_MARKER = "__ambiguous__" + +private val log = Logger.withTag("WatchedBadgeBulk") + +suspend fun resolveWatchedBadgesBulk( + watchedItems: List, + progressEntries: List, +) { + val touchedSeriesIds = buildSet { + watchedItems.forEach { item -> + if (item.type.isSeriesLikeWatchedType() && item.season != null && item.episode != null) { + add(item.id) + } + } + progressEntries.forEach { entry -> + if (entry.parentMetaType.isSeriesLikeWatchedType() && entry.isEpisode && entry.isEffectivelyCompleted) { + add(entry.parentMetaId) + } + } + } + if (touchedSeriesIds.isEmpty()) return + + val todayIsoDate = CurrentDateProvider.todayIsoDate() + // Use the full watchedKeys from UI state which includes extra keys from + // provider alternate IDs (e.g. Simkl anime alternate MAL/Kitsu keys). + val watchedKeys = WatchedRepository.uiState.value.watchedKeys + + log.i { "Bulk badge resolution starting: ${touchedSeriesIds.size} series candidates" } + + withContext(Dispatchers.Default) { + val semaphore = Semaphore(BADGE_RESOLUTION_CONCURRENCY) + val resolvedIds = mutableSetOf() + + for (contentId in touchedSeriesIds) { + semaphore.withPermit { + val meta = try { + MetaDetailsRepository.fetch(type = "series", id = contentId) + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } + if (meta != null) { + WatchedRepository.reconcileFullyWatchedSeriesState( + meta = meta, + todayIsoDate = todayIsoDate, + isEpisodeWatched = { episode -> + val key = watchedItemKey(meta.type, meta.id, episode.season, episode.episode) + if (key in watchedKeys) { + true + } else { + val episodeNumber = episode.episode + if (episodeNumber != null) { + com.nuvio.app.features.simkl.SimklAnimeWatchedFallback.isWatched(episode.id, episodeNumber) + } else { + false + } + } + }, + isEpisodeCompleted = { episode -> + val playbackId = meta.episodePlaybackId(episode) + progressEntries.any { entry -> + entry.videoId == playbackId && entry.isEffectivelyCompleted + } + }, + ) + resolvedIds.add(contentId) + } + } + yield() + } + + log.i { "Bulk badge resolution complete: resolved ${resolvedIds.size}/${touchedSeriesIds.size}" } + + // Sibling expansion + expandFullyWatchedWithSiblings() + } +} + +fun expandFullyWatchedWithSiblings() { + val siblingMap = getActiveProviderSiblingMap() + if (siblingMap.isEmpty()) return + + val currentKeys = WatchedRepository.fullyWatchedSeriesKeys.value + if (currentKeys.isEmpty()) return + + val expanded = buildSet { + addAll(currentKeys) + for (key in currentKeys) { + // Extract the contentId from watchedItemKey format ("series:tt1234567" or "tv:tt1234567") + val contentId = extractContentIdFromWatchedKey(key) ?: continue + val siblings = siblingMap[contentId] ?: continue + siblings.forEach { siblingId -> + if (siblingId != contentId && !siblingId.startsWith(AMBIGUOUS_MARKER)) { + // Build watched key with same type prefix + val siblingKey = rebuildWatchedKeyWithSiblingId(key, siblingId) + if (siblingKey != null) add(siblingKey) + } + } + } + } + + if (expanded.size > currentKeys.size) { + log.i { "Sibling expansion: ${currentKeys.size} -> ${expanded.size} keys" } + WatchedRepository.setExpandedFullyWatchedSeriesKeys(expanded) + } +} + +private fun getActiveProviderSiblingMap(): Map> { + val source = TrackingSettingsRepository.uiState.value.watchProgressSource + val effectiveSource = effectiveWatchProgressSource( + requestedSource = source, + isProviderAuthenticated = { providerId -> + com.nuvio.app.features.tracking.TrackingProviderRegistry.isAuthenticated(providerId) + }, + ) + return when (effectiveSource.providerId) { + TrackingProviderId.TRAKT -> TraktProgressRepository.getShowIdSiblings() + TrackingProviderId.SIMKL -> { + SimklSyncRepository.state.value.snapshot.toSimklShowIdSiblings() + } + else -> emptyMap() + } +} + +private fun extractContentIdFromWatchedKey(key: String): String? { + // Format: "type:contentId:season:episode" + // Split from the end to handle contentIds with colons (like "tmdb:123") + val parts = key.split(':') + if (parts.size < 4) return null + // Last two parts are season and episode (-1:-1) + // First part is type, everything in between is contentId + val type = parts.first() + val season = parts[parts.size - 2] + val episode = parts.last() + if (season.toIntOrNull() == null || episode.toIntOrNull() == null) return null + val contentId = parts.subList(1, parts.size - 2).joinToString(":") + return contentId.takeIf { it.isNotBlank() } +} + +private fun rebuildWatchedKeyWithSiblingId(originalKey: String, siblingId: String): String? { + val parts = originalKey.split(':') + if (parts.size < 4) return null + val type = parts.first() + return watchedItemKey(type = type, id = siblingId) +} + +private fun String.isSeriesLikeWatchedType(): Boolean = + trim().lowercase() in setOf("series", "show", "tv", "tvshow", "anime") 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 d70c98ca1..082c8dfb9 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 @@ -867,12 +867,17 @@ object WatchedRepository { meta: MetaDetails, todayIsoDate: String, isEpisodeWatched: (MetaVideo) -> Boolean = { episode -> - isWatched( - id = meta.id, - type = meta.type, - season = episode.season, - episode = episode.episode, - ) + val key = watchedItemKey(meta.type, meta.id, episode.season, episode.episode) + if (key in _uiState.value.watchedKeys) { + true + } else { + val episodeNumber = episode.episode + if (episodeNumber != null) { + com.nuvio.app.features.simkl.SimklAnimeWatchedFallback.isWatched(episode.id, episodeNumber) + } else { + false + } + } }, isEpisodeCompleted: (MetaVideo) -> Boolean = { false }, ): Boolean { @@ -917,6 +922,12 @@ object WatchedRepository { } } + fun setExpandedFullyWatchedSeriesKeys(keys: Set) { + val source = activeSource + setFullyWatchedSeriesKeysForSource(source = source, keys = keys) + publish() + } + private fun pushMarksToServer( items: Collection, trackerHistorySync: WatchedTrackerHistorySync, From c96cda928747bb761131d410544283e6c21766f5 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Wed, 29 Jul 2026 02:24:00 +0200 Subject: [PATCH 2/2] Store sibilings and use correct meta path and change --- .../app/features/simkl/SimklLibraryProjection.kt | 2 +- .../app/features/simkl/SimklMutationRepository.kt | 3 ++- .../nuvio/app/features/watched/WatchedRepository.kt | 12 +++++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt index 218bbdfa1..41769717d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryProjection.kt @@ -102,7 +102,7 @@ private fun SimklLibraryEntry.toLibraryItem( val simklId = media.ids.simklIdValue()?.toLongOrNull() val entryType = when (mediaType) { SimklMediaType.MOVIES -> "movie" - SimklMediaType.ANIME -> "anime" + SimklMediaType.ANIME -> if (animeType == "movie") "movie" else "series" SimklMediaType.SHOWS -> "series" } return LibraryItem( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt index 3f9a52bee..df368ccd3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -67,11 +67,12 @@ internal class SimklMutationService( } } if (candidates.isEmpty()) return TrackingMutationResult(attemptedCount = 0) + val body = buildSimklHistoryMutationBody(candidates, json) val response = client.execute( SimklApiRequest( method = SimklHttpMethod.POST, path = "/sync/history", - body = buildSimklHistoryMutationBody(candidates, json), + body = body, retryPolicy = SimklRetryPolicy.SYNC_WRITE, ), ) 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 082c8dfb9..35d1a4796 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 @@ -39,6 +39,7 @@ import kotlinx.serialization.json.Json private data class StoredWatchedPayload( val items: List = emptyList(), val fullyWatchedSeriesKeys: Set = emptySet(), + val expandedSiblingKeys: Set = emptySet(), val lastSuccessfulPushEpochMs: Long = 0L, val deltaCursorEventId: Long = 0L, val deltaInitialized: Boolean = false, @@ -136,6 +137,7 @@ object WatchedRepository { private var providerItemsByKey: MutableMap> = mutableMapOf() private var nuvioFullyWatchedSeriesKeys: Set = emptySet() private var providerFullyWatchedSeriesKeys: MutableMap> = mutableMapOf() + private var expandedSiblingKeys: Set = emptySet() private var providerExtraWatchedKeys: MutableMap> = mutableMapOf() private var nuvioHasLoaded: Boolean = false private var loadedProviders: MutableSet = mutableSetOf() @@ -187,6 +189,7 @@ object WatchedRepository { providerItemsByKey.clear() nuvioFullyWatchedSeriesKeys = emptySet() providerFullyWatchedSeriesKeys.clear() + expandedSiblingKeys = emptySet() providerExtraWatchedKeys.clear() nuvioHasLoaded = false loadedProviders.clear() @@ -210,6 +213,7 @@ object WatchedRepository { providerItemsByKey.clear() nuvioFullyWatchedSeriesKeys = emptySet() providerFullyWatchedSeriesKeys.clear() + expandedSiblingKeys = emptySet() providerExtraWatchedKeys.clear() nuvioHasLoaded = true loadedProviders.clear() @@ -232,6 +236,7 @@ object WatchedRepository { nuvioDirtyWatchedKeys = storedPayload.dirtyWatchedKeys .filterTo(mutableSetOf()) { key -> key in nuvioItemsByKey } nuvioFullyWatchedSeriesKeys = storedPayload.fullyWatchedSeriesKeys + expandedSiblingKeys = storedPayload.expandedSiblingKeys } else { lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -923,9 +928,9 @@ object WatchedRepository { } fun setExpandedFullyWatchedSeriesKeys(keys: Set) { - val source = activeSource - setFullyWatchedSeriesKeysForSource(source = source, keys = keys) + expandedSiblingKeys = keys publish() + persistNuvio() } private fun pushMarksToServer( @@ -996,7 +1001,7 @@ object WatchedRepository { val hasLoadedRemoteItems = activeSource.providerId ?.let(providersLoadedFromRemote::contains) ?: nuvioHasLoadedRemote - _fullyWatchedSeriesKeys.value = fullyWatchedSeriesKeys + _fullyWatchedSeriesKeys.value = fullyWatchedSeriesKeys + expandedSiblingKeys _uiState.value = WatchedUiState( items = items, watchedKeys = watchedKeys, @@ -1067,6 +1072,7 @@ object WatchedRepository { .map(WatchedItem::normalizedMarkedAt) .sortedByDescending { it.markedAtEpochMs }, fullyWatchedSeriesKeys = nuvioFullyWatchedSeriesKeys, + expandedSiblingKeys = expandedSiblingKeys, lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized,