This commit is contained in:
skoruppa 2026-07-29 00:24:06 +00:00 committed by GitHub
commit fa166496b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 218 additions and 67 deletions

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

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

@ -67,11 +67,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

@ -148,6 +148,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,

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

@ -0,0 +1,173 @@
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()) return
val currentKeys = WatchedRepository.fullyWatchedSeriesKeys.value
if (currentKeys.isEmpty()) return
val expanded = buildSet {
addAll(currentKeys)
for (key in currentKeys) {
// Extract the contentId from watchedItemKey format ("series:tt1234567" or "tv:tt1234567")
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)
}
}
}
}
if (expanded.size > currentKeys.size) {
log.i { "Sibling expansion: ${currentKeys.size} -> ${expanded.size} keys" }
WatchedRepository.setExpandedFullyWatchedSeriesKeys(expanded)
}
}
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
@ -867,12 +872,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 +927,12 @@ object WatchedRepository {
}
}
fun setExpandedFullyWatchedSeriesKeys(keys: Set<String>) {
expandedSiblingKeys = keys
publish()
persistNuvio()
}
private fun pushMarksToServer(
items: Collection<WatchedItem>,
trackerHistorySync: WatchedTrackerHistorySync,
@ -985,7 +1001,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 +1072,7 @@ object WatchedRepository {
.map(WatchedItem::normalizedMarkedAt)
.sortedByDescending { it.markedAtEpochMs },
fullyWatchedSeriesKeys = nuvioFullyWatchedSeriesKeys,
expandedSiblingKeys = expandedSiblingKeys,
lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs,
deltaCursorEventId = deltaCursorEventId,
deltaInitialized = deltaInitialized,