Add a setting to let user select anime ids, fix stale sibbilings watched checkmarks and don't use anime ids when providing seasons

This commit is contained in:
skoruppa 2026-07-30 10:58:50 +02:00
parent c96cda9287
commit e51d2b047c
11 changed files with 279 additions and 44 deletions

View file

@ -1144,6 +1144,17 @@
<string name="settings_tracking_simkl_progress_description">Resume with playback progress from Simkl.</string>
<string name="settings_tracking_tmdb_recommendations_description">Use TMDB recommendations on details pages.</string>
<string name="settings_tracking_trakt_recommendations_description">Use personalized related titles from Trakt.</string>
<string name="settings_tracking_anime_id_title">Anime ID preference</string>
<string name="settings_tracking_anime_id_subtitle">Choose which external ID is used as the primary identifier for anime entries.</string>
<string name="settings_tracking_anime_id_dialog_title">Anime ID preference</string>
<string name="settings_tracking_anime_id_dialog_subtitle">This controls how anime series are identified. Choosing MAL or Kitsu gives each season its own entry instead of grouping under one IMDB ID.</string>
<string name="settings_tracking_anime_id_imdb">Prefer IMDB</string>
<string name="settings_tracking_anime_id_imdb_description">Group anime seasons under a shared IMDB ID (default behavior).</string>
<string name="settings_tracking_anime_id_mal">Prefer MyAnimeList</string>
<string name="settings_tracking_anime_id_mal_description">Each anime season gets its own MAL ID as the primary identifier.</string>
<string name="settings_tracking_anime_id_kitsu">Prefer Kitsu</string>
<string name="settings_tracking_anime_id_kitsu_description">Each anime season gets its own Kitsu ID as the primary identifier.</string>
<string name="settings_tracking_anime_section">Simkl features</string>
<string name="settings_simkl_connect">Connect Simkl</string>
<string name="settings_simkl_connected_as">Connected as %1$s</string>
<string name="settings_simkl_default_user">Simkl user</string>

View file

@ -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<LibraryDisplaySection>,
watchedKeys: Set<String>,
fullyWatchedSeriesKeys: Set<String>,
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) {

View file

@ -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<TrackingPickerOption<SimklAnimeIdPreference>> = 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)
}

View file

@ -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

View file

@ -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<String> = 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<String> {
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)

View file

@ -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<String, JsonElement>.idValue(key: String): String? =

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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,
),
),
)

View file

@ -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)
}
}

View file

@ -933,6 +933,14 @@ object WatchedRepository {
persistNuvio()
}
fun currentExpandedSiblingKeys(): Set<String> = 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<String> = fullyWatchedSeriesKeysForSource(activeSource)
private fun pushMarksToServer(
items: Collection<WatchedItem>,
trackerHistorySync: WatchedTrackerHistorySync,