mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-01 00:59:27 +00:00
Merge ebbc9709fc into e4911b7742
This commit is contained in:
commit
10753a4eab
20 changed files with 529 additions and 103 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) } },
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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? =
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue