diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 40b2142e..514177b7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1144,6 +1144,17 @@ Resume with playback progress from Simkl. Use TMDB recommendations on details pages. Use personalized related titles from Trakt. + Anime ID preference + Choose which external ID is used as the primary identifier for anime entries. + Anime ID preference + This controls how anime series are identified. Choosing MAL or Kitsu gives each season its own entry instead of grouping under one IMDB ID. + Prefer IMDB + Group anime seasons under a shared IMDB ID (default behavior). + Prefer MyAnimeList + Each anime season gets its own MAL ID as the primary identifier. + Prefer Kitsu + Each anime season gets its own Kitsu ID as the primary identifier. + Simkl features Connect Simkl Connected as %1$s Simkl user diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt index 1f4ceef7..91538d6e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt @@ -90,6 +90,7 @@ fun TmdbEntityBrowseScreen( WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() + val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle() val loadFailedMessage = stringResource(Res.string.details_browse_load_failed, entityName) LaunchedEffect(entityKind, entityId) { @@ -122,6 +123,7 @@ fun TmdbEntityBrowseScreen( data = state.data, sourceType = sourceType, watchedKeys = watchedUiState.watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, onOpenMeta = onOpenMeta, ) } @@ -150,6 +152,7 @@ private fun EntityBrowseContent( data: TmdbEntityBrowseData, sourceType: String, watchedKeys: Set, + fullyWatchedSeriesKeys: Set, onOpenMeta: (MetaPreview) -> Unit, ) { val backgroundUrl = remember(data.rails, sourceType) { @@ -192,6 +195,7 @@ private fun EntityBrowseContent( WideEntityBrowseContent( data = data, watchedKeys = watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, onOpenMeta = onOpenMeta, ) } else if (data.rails.isEmpty()) { @@ -225,6 +229,7 @@ private fun EntityBrowseContent( title = entityRailTitle(rail), items = rail.items, watchedKeys = watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, headerHorizontalPadding = 20.dp, onPosterClick = onOpenMeta, ) @@ -242,6 +247,7 @@ private fun EntityBrowseContent( private fun WideEntityBrowseContent( data: TmdbEntityBrowseData, watchedKeys: Set, + fullyWatchedSeriesKeys: Set, onOpenMeta: (MetaPreview) -> Unit, ) { Row( @@ -292,6 +298,7 @@ private fun WideEntityBrowseContent( title = entityRailTitle(rail), items = rail.items, watchedKeys = watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, headerHorizontalPadding = 0.dp, onPosterClick = onOpenMeta, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt index ba229f7d..b5b570e4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt @@ -25,6 +25,7 @@ fun DetailPosterRailSection( items: List, watchedKeys: Set, modifier: Modifier = Modifier, + fullyWatchedSeriesKeys: Set = emptySet(), showHeader: Boolean = true, headerHorizontalPadding: Dp = 0.dp, horizontalScrollPadding: Dp = 0.dp, @@ -50,6 +51,7 @@ fun DetailPosterRailSection( isWatched = WatchingState.isPosterWatched( watchedKeys = watchedKeys, item = item, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, ), onClick = onPosterClick?.let { { it(item) } }, onLongClick = onPosterLongClick?.let { { it(item) } }, 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 828dc04e..2f6b9bb8 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, ) @@ -1118,53 +1109,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/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 3bbd3c7d..33b3eeee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -429,6 +429,7 @@ fun LibraryScreen( LibraryLayoutMode.HORIZONTAL -> librarySections( displaySections = librarySectionsDisplay, watchedKeys = watchedUiState.watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, sortOption = effectiveSortOption, onPosterClick = onPosterClick, onSectionViewAllClick = onSectionViewAllClick, @@ -1179,6 +1180,7 @@ private enum class LibraryViewMode { private fun LazyListScope.librarySections( displaySections: List, watchedKeys: Set, + fullyWatchedSeriesKeys: Set, sortOption: LibrarySortOption, onPosterClick: ((LibraryItem) -> Unit)?, onSectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)?, @@ -1214,6 +1216,7 @@ private fun LazyListScope.librarySections( isWatched = WatchingState.isPosterWatched( watchedKeys = watchedKeys, item = posterItem, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, ), onClick = if (entry.exiting) null else onPosterClick?.let { { it(item) } }, onLongClick = if (entry.exiting || entrySource == null) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt index 3d702ce9..84686ce4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TrackingSettingsPage.kt @@ -26,6 +26,7 @@ import com.nuvio.app.core.ui.NuvioLoadingIndicator import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.simkl.SimklAnimeIdPreference import com.nuvio.app.features.simkl.SimklAuthUiState import com.nuvio.app.features.simkl.SimklConnectionMode import com.nuvio.app.features.tracking.TrackingProviderId @@ -58,6 +59,17 @@ import nuvio.composeapp.generated.resources.settings_tracking_trakt_library_desc import nuvio.composeapp.generated.resources.settings_tracking_trakt_progress_description import nuvio.composeapp.generated.resources.settings_tracking_trakt_recommendations_description import nuvio.composeapp.generated.resources.settings_tracking_viewing_discovery +import nuvio.composeapp.generated.resources.settings_tracking_anime_id_dialog_subtitle +import nuvio.composeapp.generated.resources.settings_tracking_anime_id_dialog_title +import nuvio.composeapp.generated.resources.settings_tracking_anime_id_imdb +import nuvio.composeapp.generated.resources.settings_tracking_anime_id_imdb_description +import nuvio.composeapp.generated.resources.settings_tracking_anime_id_kitsu +import nuvio.composeapp.generated.resources.settings_tracking_anime_id_kitsu_description +import nuvio.composeapp.generated.resources.settings_tracking_anime_id_mal +import nuvio.composeapp.generated.resources.settings_tracking_anime_id_mal_description +import nuvio.composeapp.generated.resources.settings_tracking_anime_id_subtitle +import nuvio.composeapp.generated.resources.settings_tracking_anime_id_title +import nuvio.composeapp.generated.resources.settings_tracking_anime_section import nuvio.composeapp.generated.resources.settings_trakt_comments import nuvio.composeapp.generated.resources.settings_trakt_comments_description import nuvio.composeapp.generated.resources.tracking_source_simkl @@ -122,18 +134,34 @@ internal fun LazyListScope.trackingSettingsContent( } } - item { - SettingsSection( - title = stringResource(Res.string.settings_tracking_viewing_discovery), - isTablet = isTablet, - ) { - TrackingViewingAndDiscovery( + if (traktUiState.mode == TraktConnectionMode.CONNECTED) { + item { + SettingsSection( + title = stringResource(Res.string.settings_tracking_viewing_discovery), isTablet = isTablet, - settingsUiState = settingsUiState, - traktConnected = traktUiState.mode == TraktConnectionMode.CONNECTED, - commentsEnabled = commentsEnabled, - onCommentsEnabledChange = onCommentsEnabledChange, - ) + ) { + TrackingViewingAndDiscovery( + isTablet = isTablet, + settingsUiState = settingsUiState, + traktConnected = true, + commentsEnabled = commentsEnabled, + onCommentsEnabledChange = onCommentsEnabledChange, + ) + } + } + } + + if (simklUiState.mode == SimklConnectionMode.CONNECTED) { + item { + SettingsSection( + title = stringResource(Res.string.settings_tracking_anime_section), + isTablet = isTablet, + ) { + AnimeIdPreferenceSection( + isTablet = isTablet, + settingsUiState = settingsUiState, + ) + } } } } @@ -548,3 +576,59 @@ internal fun effectiveTrackingRecommendationsSource( } else { source } + +@Composable +private fun AnimeIdPreferenceSection( + isTablet: Boolean, + settingsUiState: TrackingSettingsUiState, +) { + var showPicker by rememberSaveable { mutableStateOf(false) } + + SettingsGroup(isTablet = isTablet) { + TrackingPreferenceActionRow( + title = stringResource(Res.string.settings_tracking_anime_id_title), + description = stringResource(Res.string.settings_tracking_anime_id_subtitle), + value = animeIdPreferenceLabel(settingsUiState.simklAnimeIdPreference), + isTablet = isTablet, + onClick = { showPicker = true }, + ) + } + + if (showPicker) { + TrackingAdaptivePicker( + isTablet = isTablet, + title = stringResource(Res.string.settings_tracking_anime_id_dialog_title), + subtitle = stringResource(Res.string.settings_tracking_anime_id_dialog_subtitle), + selectedValue = settingsUiState.simklAnimeIdPreference, + options = animeIdPreferenceOptions(), + onSelected = TrackingSettingsRepository::setSimklAnimeIdPreference, + onDismiss = { showPicker = false }, + ) + } +} + +@Composable +private fun animeIdPreferenceOptions(): List> = listOf( + TrackingPickerOption( + value = SimklAnimeIdPreference.IMDB, + title = stringResource(Res.string.settings_tracking_anime_id_imdb), + description = stringResource(Res.string.settings_tracking_anime_id_imdb_description), + ), + TrackingPickerOption( + value = SimklAnimeIdPreference.MAL, + title = stringResource(Res.string.settings_tracking_anime_id_mal), + description = stringResource(Res.string.settings_tracking_anime_id_mal_description), + ), + TrackingPickerOption( + value = SimklAnimeIdPreference.KITSU, + title = stringResource(Res.string.settings_tracking_anime_id_kitsu), + description = stringResource(Res.string.settings_tracking_anime_id_kitsu_description), + ), +) + +@Composable +private fun animeIdPreferenceLabel(preference: SimklAnimeIdPreference): String = when (preference) { + SimklAnimeIdPreference.IMDB -> stringResource(Res.string.settings_tracking_anime_id_imdb) + SimklAnimeIdPreference.MAL -> stringResource(Res.string.settings_tracking_anime_id_mal) + SimklAnimeIdPreference.KITSU -> stringResource(Res.string.settings_tracking_anime_id_kitsu) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeIdPreference.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeIdPreference.kt new file mode 100644 index 00000000..64800221 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeIdPreference.kt @@ -0,0 +1,30 @@ +package com.nuvio.app.features.simkl + +import kotlinx.serialization.Serializable + +/** + * Determines which external ID is used as the canonical content ID for anime entries + * resolved through Simkl. + * + * By default (IMDB), anime entries that share the same IMDB ID get grouped together + * (e.g. multiple MAL seasons of one franchise). Choosing MAL or KITSU gives each season + * its own canonical ID so they appear as separate items in the library and watched state. + */ +@Serializable +enum class SimklAnimeIdPreference { + /** Use IMDB ID when available (groups multiple seasons under one ID). */ + IMDB, + + /** Prefer MyAnimeList ID — each MAL entry gets its own canonical ID. */ + MAL, + + /** Prefer Kitsu ID — each Kitsu entry gets its own canonical ID. */ + KITSU; + + companion object { + fun fromStorage(value: String?): SimklAnimeIdPreference = + entries.firstOrNull { it.name == value } ?: DEFAULT_SIMKL_ANIME_ID_PREFERENCE + } +} + +val DEFAULT_SIMKL_ANIME_ID_PREFERENCE: SimklAnimeIdPreference = SimklAnimeIdPreference.IMDB diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index cb9cc14c..dffc0a8f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -63,14 +63,15 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider { intent = TrackingRefreshIntent.AUTOMATIC, origin = SimklRefreshOrigin.WATCHED_ITEMS, ) - return SimklSyncRepository.state.value.snapshot.animeAlternateWatchedKeys() + val snapshot = SimklSyncRepository.state.value.snapshot + return snapshot.animeAlternateWatchedKeys() + snapshot.movieAlternateWatchedKeys() } override fun observeExtraWatchedKeys(profileId: Int): kotlinx.coroutines.flow.Flow> = SimklSyncRepository.state .map { state -> SimklAnimeWatchedFallback.clearOptimisticRemovals() - state.snapshot.animeAlternateWatchedKeys() + state.snapshot.animeAlternateWatchedKeys() + state.snapshot.movieAlternateWatchedKeys() } .distinctUntilChanged() 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 83eceb81..f3a44865 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 94d4b83f..132ff077 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 @@ -64,11 +64,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/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index 6b63777a..451e037b 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 @@ -6,6 +6,7 @@ import com.nuvio.app.features.tracking.TrackingExternalIds import com.nuvio.app.features.tracking.TrackingMediaKind import com.nuvio.app.features.tracking.TrackingMediaReference import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.extractTrackingYear import com.nuvio.app.features.tracking.parseTrackingExternalIds import com.nuvio.app.features.tracking.trackingMediaKind @@ -141,6 +142,26 @@ internal fun SimklSyncSnapshot.animeAlternateWatchedKeys(): Set { return extraKeys } +/** + * Builds watched keys under alternate content IDs for completed/watched movies. + * This allows entity browsers showing movies under tmdb: IDs to display watched badges + * even though the primary watched key uses an IMDB ID. + */ +internal fun SimklSyncSnapshot.movieAlternateWatchedKeys(): Set { + val extraKeys = linkedSetOf() + entries.forEach { entry -> + if (entry.mediaType != SimklMediaType.MOVIES) return@forEach + if (entry.lastWatchedAt == null && entry.status != SimklListStatus.COMPLETED) return@forEach + val media = entry.media ?: return@forEach + val contentId = media.canonicalContentId() ?: return@forEach + val alternateIds = media.alternateContentIds() - contentId + alternateIds.forEach { altId -> + extraKeys += watchedItemKey("movie", altId) + } + } + return extraKeys +} + internal fun SimklSyncSnapshot.toSimklProgressEntries(): List = playback .mapNotNull(SimklPlaybackSession::toWatchProgressEntry) @@ -148,6 +169,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, @@ -215,32 +250,82 @@ internal fun SimklMedia.toTrackingExternalIds(): TrackingExternalIds = TrackingE kitsu = ids.idValue("kitsu")?.toLongOrNull(), ) -internal fun SimklMedia.canonicalContentId(): String? = when { - !ids.idValue("imdb").isNullOrBlank() -> ids.idValue("imdb") - !ids.idValue("tmdb").isNullOrBlank() -> "tmdb:${ids.idValue("tmdb")}" - !ids.idValue("tvdb").isNullOrBlank() -> "tvdb:${ids.idValue("tvdb")}" - !ids.idValue("mal").isNullOrBlank() -> "mal:${ids.idValue("mal")}" - !ids.idValue("anidb").isNullOrBlank() -> "anidb:${ids.idValue("anidb")}" - !ids.idValue("anilist").isNullOrBlank() -> "anilist:${ids.idValue("anilist")}" - !ids.idValue("kitsu").isNullOrBlank() -> "kitsu:${ids.idValue("kitsu")}" - !ids.simklIdValue().isNullOrBlank() -> "simkl:${ids.simklIdValue()}" - else -> null +internal fun SimklMedia.canonicalContentId(): String? = + canonicalContentId(TrackingSettingsRepository.uiState.value.simklAnimeIdPreference) + +/** + * Resolves the canonical content ID for this media entry. + * + * For non-anime content the priority is always IMDB → TMDB → TVDB → (fallback chain). + * For anime, the [animeIdPreference] allows the user to choose which ID system takes priority: + * - [SimklAnimeIdPreference.IMDB]: standard fallback (IMDB wins, seasons get grouped) + * - [SimklAnimeIdPreference.MAL]: prefer MAL ID so each season is separate + * - [SimklAnimeIdPreference.KITSU]: prefer Kitsu ID so each season is separate + * + * The function is also available without the preference parameter for call-sites that + * don't have anime context (movies/shows always use the default chain). + */ +internal fun SimklMedia.canonicalContentId(animeIdPreference: SimklAnimeIdPreference): String? { + // Try the preferred anime ID first when preference is not IMDB + when (animeIdPreference) { + SimklAnimeIdPreference.MAL -> { + ids.idValue("mal")?.takeIf(String::isNotBlank)?.let { return "mal:$it" } + ids.idValue("kitsu")?.takeIf(String::isNotBlank)?.let { return "kitsu:$it" } + ids.idValue("anidb")?.takeIf(String::isNotBlank)?.let { return "anidb:$it" } + } + SimklAnimeIdPreference.KITSU -> { + ids.idValue("kitsu")?.takeIf(String::isNotBlank)?.let { return "kitsu:$it" } + ids.idValue("mal")?.takeIf(String::isNotBlank)?.let { return "mal:$it" } + ids.idValue("anidb")?.takeIf(String::isNotBlank)?.let { return "anidb:$it" } + } + SimklAnimeIdPreference.IMDB -> Unit // fall through to standard chain + } + // Standard fallback chain + return when { + !ids.idValue("imdb").isNullOrBlank() -> ids.idValue("imdb") + !ids.idValue("tmdb").isNullOrBlank() -> "tmdb:${ids.idValue("tmdb")}" + !ids.idValue("tvdb").isNullOrBlank() -> "tvdb:${ids.idValue("tvdb")}" + !ids.idValue("mal").isNullOrBlank() -> "mal:${ids.idValue("mal")}" + !ids.idValue("anidb").isNullOrBlank() -> "anidb:${ids.idValue("anidb")}" + !ids.idValue("anilist").isNullOrBlank() -> "anilist:${ids.idValue("anilist")}" + !ids.idValue("kitsu").isNullOrBlank() -> "kitsu:${ids.idValue("kitsu")}" + !ids.simklIdValue().isNullOrBlank() -> "simkl:${ids.simklIdValue()}" + else -> null + } } /** * Returns all content IDs (IMDB, MAL, AniDB, AniList, Kitsu, etc.) that this media entry * is known under. Used to emit watched items under all possible IDs for anime entries so * that the UI can find them regardless of which content ID the addon uses. + * + * When the user prefers MAL or Kitsu as the canonical anime ID, only the preferred ID type + * is emitted to prevent duplicates in continue watching and watched badge resolution. */ -private fun SimklMedia.alternateContentIds(): Set = buildSet { - ids.idValue("imdb")?.takeIf(String::isNotBlank)?.let(::add) - ids.idValue("tmdb")?.takeIf(String::isNotBlank)?.let { add("tmdb:$it") } - ids.idValue("tvdb")?.takeIf(String::isNotBlank)?.let { add("tvdb:$it") } - ids.idValue("mal")?.takeIf(String::isNotBlank)?.let { add("mal:$it") } - ids.idValue("anidb")?.takeIf(String::isNotBlank)?.let { add("anidb:$it") } - ids.idValue("anilist")?.takeIf(String::isNotBlank)?.let { add("anilist:$it") } - ids.idValue("kitsu")?.takeIf(String::isNotBlank)?.let { add("kitsu:$it") } - ids.simklIdValue()?.takeIf(String::isNotBlank)?.let { add("simkl:$it") } +private fun SimklMedia.alternateContentIds(): Set { + val preference = TrackingSettingsRepository.uiState.value.simklAnimeIdPreference + return when (preference) { + SimklAnimeIdPreference.IMDB -> buildSet { + ids.idValue("imdb")?.takeIf(String::isNotBlank)?.let(::add) + ids.idValue("tmdb")?.takeIf(String::isNotBlank)?.let { add("tmdb:$it") } + ids.idValue("tvdb")?.takeIf(String::isNotBlank)?.let { add("tvdb:$it") } + ids.idValue("mal")?.takeIf(String::isNotBlank)?.let { add("mal:$it") } + ids.idValue("anidb")?.takeIf(String::isNotBlank)?.let { add("anidb:$it") } + ids.idValue("anilist")?.takeIf(String::isNotBlank)?.let { add("anilist:$it") } + ids.idValue("kitsu")?.takeIf(String::isNotBlank)?.let { add("kitsu:$it") } + ids.simklIdValue()?.takeIf(String::isNotBlank)?.let { add("simkl:$it") } + } + SimklAnimeIdPreference.MAL -> buildSet { + ids.idValue("mal")?.takeIf(String::isNotBlank)?.let { add("mal:$it") } + ids.idValue("kitsu")?.takeIf(String::isNotBlank)?.let { add("kitsu:$it") } + ids.idValue("anidb")?.takeIf(String::isNotBlank)?.let { add("anidb:$it") } + } + SimklAnimeIdPreference.KITSU -> buildSet { + ids.idValue("kitsu")?.takeIf(String::isNotBlank)?.let { add("kitsu:$it") } + ids.idValue("mal")?.takeIf(String::isNotBlank)?.let { add("mal:$it") } + ids.idValue("anidb")?.takeIf(String::isNotBlank)?.let { add("anidb:$it") } + } + } } internal fun simklPosterUrl(path: String?): String? = @@ -391,9 +476,9 @@ private fun daysInMonths(year: Int): IntArray = if (year.isLeapYear()) { */ internal fun TrackingMediaReference.resolveAnimeEpisodeForSimkl(): TrackingMediaReference { if (kind != TrackingMediaKind.ANIME) return this - val videoId = catalog?.videoId ?: return this - val parsed = parseSimklAnimeVideoId(videoId) ?: return this - val videoEpisodeNumber = parsed.episodeNumber ?: return this + val videoId = catalog?.videoId ?: return stripAnimeIdsIfSeasoned() + val parsed = parseSimklAnimeVideoId(videoId) ?: return stripAnimeIdsIfSeasoned() + val videoEpisodeNumber = parsed.episodeNumber ?: return stripAnimeIdsIfSeasoned() // Override IDs: use ONLY the anime-specific ID from videoId. // Clear all other IDs to prevent Simkl from matching a wrong entry @@ -419,6 +504,20 @@ internal fun TrackingMediaReference.resolveAnimeEpisodeForSimkl(): TrackingMedia ) } +/** + * If this anime reference uses TVDB-style season coordinates, strip anime-specific IDs + * (MAL, Kitsu, AniDB, AniList) to prevent Simkl from matching a wrong per-season entry. + * When a season is present the parent IMDB/TMDB is the correct identifier. + */ +private fun TrackingMediaReference.stripAnimeIdsIfSeasoned(): TrackingMediaReference { + val season = episode?.season ?: return this + if (season <= 0) return this + if (ids.mal == null && ids.kitsu == null && ids.anidb == null && ids.anilist == null && ids.simkl == null) return this + return copy( + ids = ids.copy(mal = null, kitsu = null, anidb = null, anilist = null, simkl = null), + ) +} + /** * Resolve a contentId to its canonical form by finding a matching Simkl entry. * e.g. "mal:123" → finds entry with mal=123 → returns "tt2560140" (its IMDB/canonical ID) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt index 34fb1023..f46381e2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncModels.kt @@ -183,6 +183,7 @@ data class SimklSyncUiState( val isLoading: Boolean = false, val hasLoaded: Boolean = false, val errorMessage: String? = null, + val projectionVersion: Long = 0L, ) internal fun Map.idValue(key: String): String? = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt index 9ebe2ba5..f62dac74 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklSyncRepository.kt @@ -251,6 +251,16 @@ object SimklSyncRepository : TrackingProfileStore { SimklSyncStorage.savePayload("") } + /** + * Bumps the projection version to force downstream collectors (CW, library, watched badges) + * to recompute their projections without re-fetching from the network. + * Call this when a setting that affects projection output changes (e.g. anime ID preference). + */ + fun invalidateProjections() { + val current = _state.value + _state.value = current.copy(projectionVersion = current.projectionVersion + 1L) + } + override fun removeStoredProfile(profileId: Int) { SimklSyncStorage.removeProfile(profileId) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt index 9756a934..1e53550a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingSettings.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.tracking import com.nuvio.app.features.library.LibrarySourceMode +import com.nuvio.app.features.simkl.SimklAnimeIdPreference import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.TraktSettingsUiState @@ -36,4 +37,7 @@ object TrackingSettingsRepository { fun setMoreLikeThisSource(source: MoreLikeThisSourcePreference) = TraktSettingsRepository.setMoreLikeThisSource(source) + + fun setSimklAnimeIdPreference(preference: SimklAnimeIdPreference) = + TraktSettingsRepository.setSimklAnimeIdPreference(preference) } 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 8da4ec19..7e77567d 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/trakt/TraktSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt index 0fce69c1..5b1e4174 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt @@ -4,6 +4,8 @@ import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.simkl.DEFAULT_SIMKL_ANIME_ID_PREFERENCE +import com.nuvio.app.features.simkl.SimklAnimeIdPreference import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -55,6 +57,7 @@ data class TraktSettingsUiState( val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, val librarySourceMode: LibrarySourceMode = DEFAULT_LIBRARY_SOURCE_MODE, val moreLikeThisSource: MoreLikeThisSourcePreference = DEFAULT_MORE_LIKE_THIS_SOURCE, + val simklAnimeIdPreference: SimklAnimeIdPreference = DEFAULT_SIMKL_ANIME_ID_PREFERENCE, ) @Serializable @@ -63,6 +66,7 @@ private data class StoredTraktSettings( val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, val librarySourceMode: String? = null, val moreLikeThisSource: String? = null, + val simklAnimeIdPreference: String? = null, ) object TraktSettingsRepository { @@ -131,6 +135,14 @@ object TraktSettingsRepository { persist() } + fun setSimklAnimeIdPreference(preference: SimklAnimeIdPreference) { + ensureLoaded() + if (_uiState.value.simklAnimeIdPreference == preference) return + _uiState.value = _uiState.value.copy(simklAnimeIdPreference = preference) + persist() + com.nuvio.app.features.simkl.SimklSyncRepository.invalidateProjections() + } + private fun loadFromDisk() { hasLoaded = true @@ -150,6 +162,7 @@ object TraktSettingsRepository { continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap), librarySourceMode = librarySourceModeFromStorage(stored.librarySourceMode), moreLikeThisSource = MoreLikeThisSourcePreference.fromStorage(stored.moreLikeThisSource), + simklAnimeIdPreference = SimklAnimeIdPreference.fromStorage(stored.simklAnimeIdPreference), ) } else { TraktSettingsUiState() @@ -164,6 +177,7 @@ object TraktSettingsRepository { continueWatchingDaysCap = state.continueWatchingDaysCap, librarySourceMode = state.librarySourceMode.name, moreLikeThisSource = state.moreLikeThisSource.name, + simklAnimeIdPreference = state.simklAnimeIdPreference.name, ), ), ) 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 00000000..1bd41797 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedBadgeBulkResolver.kt @@ -0,0 +1,178 @@ +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()) { + WatchedRepository.setExpandedFullyWatchedSeriesKeys(emptySet()) + return + } + + // Use base keys (without previous sibling expansion) to avoid feedback loops + val baseKeys = WatchedRepository.baseFullyWatchedSeriesKeys() + if (baseKeys.isEmpty()) { + WatchedRepository.setExpandedFullyWatchedSeriesKeys(emptySet()) + return + } + + // Compute only the sibling-derived keys (not including base keys themselves) + val siblingKeys = buildSet { + for (key in baseKeys) { + val contentId = extractContentIdFromWatchedKey(key) ?: continue + val siblings = siblingMap[contentId] ?: continue + siblings.forEach { siblingId -> + if (siblingId != contentId && !siblingId.startsWith(AMBIGUOUS_MARKER)) { + val siblingKey = rebuildWatchedKeyWithSiblingId(key, siblingId) + if (siblingKey != null) add(siblingKey) + } + } + } + } + + if (siblingKeys != WatchedRepository.currentExpandedSiblingKeys()) { + log.i { "Sibling expansion: ${baseKeys.size} base keys -> +${siblingKeys.size} sibling keys" } + WatchedRepository.setExpandedFullyWatchedSeriesKeys(siblingKeys) + } +} + +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 d70c98ca..d65129a5 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 @@ -833,6 +838,11 @@ object WatchedRepository { return itemsForSource(activeSource).containsKey(watchedItemKey(type, id, season, episode)) } + fun isFullyWatchedSeries(id: String, type: String): Boolean { + val key = watchedItemKey(type, id) + return _fullyWatchedSeriesKeys.value.contains(key) + } + fun reconcileSeriesWatchedState( meta: MetaDetails, todayIsoDate: String, @@ -867,12 +877,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 +932,20 @@ object WatchedRepository { } } + fun setExpandedFullyWatchedSeriesKeys(keys: Set) { + expandedSiblingKeys = keys + publish() + persistNuvio() + } + + fun currentExpandedSiblingKeys(): Set = expandedSiblingKeys + + /** + * Returns the base fully-watched series keys from the active source, + * without sibling expansion. Used by sibling expansion to avoid feedback loops. + */ + fun baseFullyWatchedSeriesKeys(): Set = fullyWatchedSeriesKeysForSource(activeSource) + private fun pushMarksToServer( items: Collection, trackerHistorySync: WatchedTrackerHistorySync, @@ -985,7 +1014,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, @@ -1056,6 +1085,7 @@ object WatchedRepository { .map(WatchedItem::normalizedMarkedAt) .sortedByDescending { it.markedAtEpochMs }, fullyWatchedSeriesKeys = nuvioFullyWatchedSeriesKeys, + expandedSiblingKeys = expandedSiblingKeys, lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt index 6977e02c..d3d46372 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt @@ -31,7 +31,7 @@ object WatchingActions { val isCurrentlyWatched = WatchedRepository.isWatched( id = preview.id, type = preview.type, - ) + ) || WatchedRepository.isFullyWatchedSeries(id = preview.id, type = preview.type) val meta = MetaDetailsRepository.fetch(type = preview.type, id = preview.id) if (meta == null) { if (isCurrentlyWatched) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjection.kt index 77e3d0ce..c7d419d1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressMetadataProjection.kt @@ -27,6 +27,11 @@ internal fun enrichWatchProgressEntry( null } return current.copy( + videoId = if (current.source != WatchProgressSourceLocal && episodeVideo != null) { + episodeVideo.id.takeIf(String::isNotBlank) ?: current.videoId + } else { + current.videoId + }, title = meta.name.takeIf(String::isNotBlank) ?: current.title, poster = meta.poster?.takeIf(String::isNotBlank) ?: current.poster, background = meta.background?.takeIf(String::isNotBlank) ?: current.background,