From e51d2b047c79dc465c8c75587bb5feb970265143 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Thu, 30 Jul 2026 10:58:50 +0200 Subject: [PATCH] Add a setting to let user select anime ids, fix stale sibbilings watched checkmarks and don't use anime ids when providing seasons --- .../composeResources/values/strings.xml | 11 ++ .../app/features/library/LibraryScreen.kt | 3 + .../features/settings/TrackingSettingsPage.kt | 106 +++++++++++++++-- .../features/simkl/SimklAnimeIdPreference.kt | 30 +++++ .../app/features/simkl/SimklProjections.kt | 109 ++++++++++++++---- .../app/features/simkl/SimklSyncModels.kt | 1 + .../app/features/simkl/SimklSyncRepository.kt | 10 ++ .../app/features/tracking/TrackingSettings.kt | 4 + .../features/trakt/TraktSettingsRepository.kt | 14 +++ .../watched/WatchedBadgeBulkResolver.kt | 27 +++-- .../app/features/watched/WatchedRepository.kt | 8 ++ 11 files changed, 279 insertions(+), 44 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklAnimeIdPreference.kt 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/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/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index 701ee879..31ba673e 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 @@ -227,32 +228,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? = @@ -400,9 +451,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 @@ -428,6 +479,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 68f12fa9..97ee631a 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 @@ -182,6 +182,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 e3ba8f30..d00f9ec0 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 @@ -229,6 +229,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/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 index 231c5b6d..1bd41797 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedBadgeBulkResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedBadgeBulkResolver.kt @@ -103,20 +103,25 @@ suspend fun resolveWatchedBadgesBulk( fun expandFullyWatchedWithSiblings() { val siblingMap = getActiveProviderSiblingMap() - if (siblingMap.isEmpty()) return + if (siblingMap.isEmpty()) { + WatchedRepository.setExpandedFullyWatchedSeriesKeys(emptySet()) + return + } - val currentKeys = WatchedRepository.fullyWatchedSeriesKeys.value - if (currentKeys.isEmpty()) return + // Use base keys (without previous sibling expansion) to avoid feedback loops + val baseKeys = WatchedRepository.baseFullyWatchedSeriesKeys() + if (baseKeys.isEmpty()) { + WatchedRepository.setExpandedFullyWatchedSeriesKeys(emptySet()) + return + } - val expanded = buildSet { - addAll(currentKeys) - for (key in currentKeys) { - // Extract the contentId from watchedItemKey format ("series:tt1234567" or "tv:tt1234567") + // 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)) { - // Build watched key with same type prefix val siblingKey = rebuildWatchedKeyWithSiblingId(key, siblingId) if (siblingKey != null) add(siblingKey) } @@ -124,9 +129,9 @@ fun expandFullyWatchedWithSiblings() { } } - if (expanded.size > currentKeys.size) { - log.i { "Sibling expansion: ${currentKeys.size} -> ${expanded.size} keys" } - WatchedRepository.setExpandedFullyWatchedSeriesKeys(expanded) + if (siblingKeys != WatchedRepository.currentExpandedSiblingKeys()) { + log.i { "Sibling expansion: ${baseKeys.size} base keys -> +${siblingKeys.size} sibling keys" } + WatchedRepository.setExpandedFullyWatchedSeriesKeys(siblingKeys) } } 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 35d1a479..f19133f2 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 @@ -933,6 +933,14 @@ object WatchedRepository { 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,