mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-03 18:16:15 +00:00
Merge c96cda9287 into 247afcbf00
This commit is contained in:
commit
fa166496b0
7 changed files with 218 additions and 67 deletions
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue