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,