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,