This commit is contained in:
skoruppa 2026-07-31 21:20:52 +00:00 committed by GitHub
commit 10753a4eab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 529 additions and 103 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

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

View file

@ -25,6 +25,7 @@ fun DetailPosterRailSection(
items: List<MetaPreview>,
watchedKeys: Set<String>,
modifier: Modifier = Modifier,
fullyWatchedSeriesKeys: Set<String> = 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) } },

View file

@ -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<MetaPreview>,
watchedItems: List<WatchedItem>,
progressEntries: List<WatchProgressEntry>,
) {
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")

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

@ -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<Set<String>> =
SimklSyncRepository.state
.map { state ->
SimklAnimeWatchedFallback.clearOptimisticRemovals()
state.snapshot.animeAlternateWatchedKeys()
state.snapshot.animeAlternateWatchedKeys() + state.snapshot.movieAlternateWatchedKeys()
}
.distinctUntilChanged()

View file

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

View file

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

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
@ -141,6 +142,26 @@ internal fun SimklSyncSnapshot.animeAlternateWatchedKeys(): Set<String> {
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<String> {
val extraKeys = linkedSetOf<String>()
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<WatchProgressEntry> =
playback
.mapNotNull(SimklPlaybackSession::toWatchProgressEntry)
@ -148,6 +169,20 @@ internal fun SimklSyncSnapshot.toSimklProgressEntries(): List<WatchProgressEntry
.mapNotNull { (_, candidates) -> candidates.maxByOrNull(WatchProgressEntry::lastUpdatedEpochMs) }
.sortedByDescending(WatchProgressEntry::lastUpdatedEpochMs)
internal fun SimklSyncSnapshot.toSimklShowIdSiblings(): Map<String, Set<String>> {
val siblingsMap = mutableMapOf<String, MutableSet<String>>()
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<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? =
@ -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)

View file

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

View file

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

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

@ -109,6 +109,8 @@ object TraktProgressRepository {
private var showIdToTraktPathId: Map<String, String> = emptyMap()
private var showIdSiblingsMap: Map<String, Set<String>> = emptyMap()
fun getShowIdSiblings(): Map<String, Set<String>> = showIdSiblingsMap
init {
scope.launch {
while (true) {

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

@ -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<WatchedItem>,
progressEntries: List<WatchProgressEntry>,
) {
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<String>()
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<String, Set<String>> {
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")

View file

@ -39,6 +39,7 @@ import kotlinx.serialization.json.Json
private data class StoredWatchedPayload(
val items: List<WatchedItem> = emptyList(),
val fullyWatchedSeriesKeys: Set<String> = emptySet(),
val expandedSiblingKeys: Set<String> = emptySet(),
val lastSuccessfulPushEpochMs: Long = 0L,
val deltaCursorEventId: Long = 0L,
val deltaInitialized: Boolean = false,
@ -136,6 +137,7 @@ object WatchedRepository {
private var providerItemsByKey: MutableMap<TrackingProviderId, MutableMap<String, WatchedItem>> = mutableMapOf()
private var nuvioFullyWatchedSeriesKeys: Set<String> = emptySet()
private var providerFullyWatchedSeriesKeys: MutableMap<TrackingProviderId, Set<String>> = mutableMapOf()
private var expandedSiblingKeys: Set<String> = emptySet()
private var providerExtraWatchedKeys: MutableMap<TrackingProviderId, Set<String>> = mutableMapOf()
private var nuvioHasLoaded: Boolean = false
private var loadedProviders: MutableSet<TrackingProviderId> = 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<String>) {
expandedSiblingKeys = keys
publish()
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,
@ -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,

View file

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

View file

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