diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt index 585cb8639..1a2dd60c2 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt @@ -8,4 +8,6 @@ internal actual object TraktPlatformClock { actual fun parseIsoDateTimeToEpochMs(value: String): Long? = runCatching { Instant.parse(value).toEpochMilli() }.getOrNull() ?: parseTraktIsoDateTimeToEpochMs(value) + + actual fun availableProcessors(): Int = Runtime.getRuntime().availableProcessors() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index d06980b95..0d90dfad7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -73,12 +73,15 @@ import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.home.components.HomeCollectionRowSection import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import com.nuvio.app.features.trakt.TraktEpisodeMappingService import com.nuvio.app.features.home.components.ContinueWatchingLayout import com.nuvio.app.features.home.components.continueWatchingLandscapeCardHeight import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth @@ -238,6 +241,12 @@ fun HomeScreen( effectiveWatchProgressEntries.continueWatchingEntries(limit = HomeContinueWatchingMaxRecentProgressItems) } + val watchProgressSeedKey = remember(watchProgressUiState.entries) { + watchProgressUiState.entries.map { entry -> + Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) + } + } + LaunchedEffect(visibleContinueWatchingEntries) { if (visibleContinueWatchingEntries.any(WatchProgressEntry::isCloudLibraryProgressEntry)) { CloudLibraryRepository.ensureLoaded() @@ -440,12 +449,11 @@ fun HomeScreen( LaunchedEffect( completedSeriesCandidates, - visibleContinueWatchingEntries, metaProviderKey, continueWatchingPreferences.showUnairedNextUp, continueWatchingPreferences.upNextFromFurthestEpisode, isRefreshingEnabledAddons, - watchProgressUiState.entries, + watchProgressSeedKey, watchedUiState.items, watchedUiState.isLoaded, ) { @@ -463,93 +471,105 @@ fun HomeScreen( return@LaunchedEffect } - val cachedResolvedNextUpItems = completedSeriesCandidates.mapNotNull { candidate -> - val cached = cachedNextUpItems[candidate.content.id] ?: return@mapNotNull null - val item = cached.second - if ( - item.nextUpSeedSeasonNumber != candidate.seasonNumber || - item.nextUpSeedEpisodeNumber != candidate.episodeNumber - ) { - return@mapNotNull null + withContext(Dispatchers.Default) { + val cachedResolvedNextUpItems = completedSeriesCandidates.mapNotNull { candidate -> + val cached = cachedNextUpItems[candidate.content.id] ?: return@mapNotNull null + val item = cached.second + if ( + item.nextUpSeedSeasonNumber != candidate.seasonNumber || + item.nextUpSeedEpisodeNumber != candidate.episodeNumber + ) { + return@mapNotNull null + } + candidate.content.id to cached + }.toMap() + val candidatesToResolve = completedSeriesCandidates.filter { candidate -> + candidate.content.id !in cachedResolvedNextUpItems } - candidate.content.id to cached - }.toMap() - val candidatesToResolve = completedSeriesCandidates.filter { candidate -> - candidate.content.id !in cachedResolvedNextUpItems - } - val resolutionCandidates = candidatesToResolve.take(HomeNextUpInitialResolutionLimit) - val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs } - if (candidatesToResolve.isEmpty()) { - nextUpItemsBySeries = cachedResolvedNextUpItems - processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> - candidate.content.id - } - saveContinueWatchingSnapshots( - nextUpItemsBySeries = cachedResolvedNextUpItems, - visibleContinueWatchingEntries = visibleContinueWatchingEntries, - todayIsoDate = CurrentDateProvider.todayIsoDate(), - seedLastWatchedMap = seedLastWatchedMap, - ) - return@LaunchedEffect - } - - if (metaProviderKey.isEmpty()) { - return@LaunchedEffect - } - - val todayIsoDate = CurrentDateProvider.todayIsoDate() - val semaphore = Semaphore(NEXT_UP_RESOLUTION_CONCURRENCY) - val freshResults = mutableMapOf>() - val processedFreshContentIds = mutableSetOf() - val candidateBatches = resolutionCandidates.chunked(NEXT_UP_RESOLUTION_BATCH_SIZE) - - for (batch in candidateBatches) { - val batchResults = batch.map { completedEntry -> - async { - semaphore.withPermit { - resolveHomeNextUpCandidate( - completedEntry = completedEntry, - watchProgressEntries = watchProgressUiState.entries, - watchedItems = watchedUiState.items, - todayIsoDate = todayIsoDate, - preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, - showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, - dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys, - ) + val resolutionCandidates = candidatesToResolve.take(HomeNextUpInitialResolutionLimit) + val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs } + if (candidatesToResolve.isEmpty()) { + withContext(Dispatchers.Main) { + nextUpItemsBySeries = cachedResolvedNextUpItems + processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> + candidate.content.id } } - }.awaitAll() - batch.forEach { candidate -> processedFreshContentIds += candidate.content.id } - - val resolvedBeforeBatch = freshResults.size - batchResults.filterNotNull().forEach { (contentId, item) -> - freshResults[contentId] = item + saveContinueWatchingSnapshots( + nextUpItemsBySeries = cachedResolvedNextUpItems, + visibleContinueWatchingEntries = visibleContinueWatchingEntries, + todayIsoDate = CurrentDateProvider.todayIsoDate(), + seedLastWatchedMap = seedLastWatchedMap, + ) + return@withContext } - val batchResolvedCount = freshResults.size - resolvedBeforeBatch - if (batchResolvedCount > 0) { - val progressiveResults = cachedResolvedNextUpItems + freshResults - nextUpItemsBySeries = progressiveResults + + if (metaProviderKey.isEmpty()) { + return@withContext + } + + val todayIsoDate = CurrentDateProvider.todayIsoDate() + val semaphore = Semaphore(NEXT_UP_RESOLUTION_CONCURRENCY) + val freshResults = mutableMapOf>() + val processedFreshContentIds = mutableSetOf() + val candidateBatches = resolutionCandidates.chunked(NEXT_UP_RESOLUTION_BATCH_SIZE) + + for (batch in candidateBatches) { + val batchResults = batch.map { completedEntry -> + async { + semaphore.withPermit { + resolveHomeNextUpCandidate( + completedEntry = completedEntry, + watchProgressEntries = watchProgressUiState.entries, + watchedItems = watchedUiState.items, + todayIsoDate = todayIsoDate, + preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, + showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, + dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys, + isTraktProgressActive = isTraktProgressActive, + ) + } + } + }.awaitAll() + batch.forEach { candidate -> processedFreshContentIds += candidate.content.id } + + val resolvedBeforeBatch = freshResults.size + batchResults.filterNotNull().forEach { (contentId, item) -> + freshResults[contentId] = item + } + val batchResolvedCount = freshResults.size - resolvedBeforeBatch + if (batchResolvedCount > 0) { + val progressiveResults = cachedResolvedNextUpItems + freshResults + withContext(Dispatchers.Main) { + nextUpItemsBySeries = progressiveResults + processedNextUpContentIds = ( + cachedResolvedNextUpItems.keys + + processedFreshContentIds + ).toSet() + } + } + + if (cachedResolvedNextUpItems.size + freshResults.size >= HomeContinueWatchingMaxRecentProgressItems) { + break + } + } + + val results = cachedResolvedNextUpItems + freshResults + withContext(Dispatchers.Main) { + nextUpItemsBySeries = results processedNextUpContentIds = ( cachedResolvedNextUpItems.keys + processedFreshContentIds ).toSet() } + saveContinueWatchingSnapshots( + nextUpItemsBySeries = results, + visibleContinueWatchingEntries = visibleContinueWatchingEntries, + todayIsoDate = todayIsoDate, + seedLastWatchedMap = seedLastWatchedMap, + ) } - - val results = cachedResolvedNextUpItems + freshResults - nextUpItemsBySeries = results - processedNextUpContentIds = ( - cachedResolvedNextUpItems.keys + - processedFreshContentIds - ).toSet() - - saveContinueWatchingSnapshots( - nextUpItemsBySeries = results, - visibleContinueWatchingEntries = visibleContinueWatchingEntries, - todayIsoDate = todayIsoDate, - seedLastWatchedMap = seedLastWatchedMap, - ) } val hasActiveAddons = enabledAddons.any { it.manifest != null } @@ -892,6 +912,7 @@ private suspend fun resolveHomeNextUpCandidate( preferFurthestEpisode: Boolean, showUnairedNextUp: Boolean, dismissedNextUpKeys: Set, + isTraktProgressActive: Boolean, ): Pair>? { val contentId = completedEntry.content.id val meta = try { @@ -905,10 +926,21 @@ private suspend fun resolveHomeNextUpCandidate( } if (meta == null) return null + val resolvedProgressEntries = if (isTraktProgressActive) { + remapTraktProgressEntries(watchProgressEntries, contentId) + } else { + watchProgressEntries + } + val resolvedWatchedItems = if (isTraktProgressActive) { + remapTraktWatchedItems(watchedItems, contentId) + } else { + watchedItems + } + val action = meta.seriesPrimaryAction( content = completedEntry.content, - entries = watchProgressEntries, - watchedItems = watchedItems, + entries = resolvedProgressEntries, + watchedItems = resolvedWatchedItems, todayIsoDate = todayIsoDate, preferFurthestEpisode = preferFurthestEpisode, showUnairedNextUp = showUnairedNextUp, @@ -1318,3 +1350,63 @@ private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean = private fun WatchProgressEntry.isCloudLibraryProgressEntry(): Boolean = contentType.equals(CloudLibraryContentType, ignoreCase = true) || parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) + +private suspend fun remapTraktProgressEntries( + entries: List, + contentId: String, +): List { + return entries.map { entry -> + if (entry.parentMetaId != contentId) { + entry + } else { + val mapping = TraktEpisodeMappingService.resolveAddonEpisodeMapping( + contentId = entry.parentMetaId, + contentType = entry.contentType ?: "series", + season = entry.seasonNumber, + episode = entry.episodeNumber, + episodeTitle = entry.episodeTitle, + ) + if (mapping != null) { + entry.copy( + seasonNumber = mapping.season, + episodeNumber = mapping.episode, + videoId = com.nuvio.app.features.watchprogress.buildPlaybackVideoId( + parentMetaId = entry.parentMetaId, + seasonNumber = mapping.season, + episodeNumber = mapping.episode, + fallbackVideoId = entry.videoId, + ), + episodeTitle = mapping.title ?: entry.episodeTitle, + ) + } else { + entry + } + } + } +} + +private suspend fun remapTraktWatchedItems( + items: List, + contentId: String, +): List { + return items.map { item -> + if (item.id != contentId) { + item + } else { + val mapping = TraktEpisodeMappingService.resolveAddonEpisodeMapping( + contentId = item.id, + contentType = item.type ?: "series", + season = item.season, + episode = item.episode, + ) + if (mapping != null) { + item.copy( + season = mapping.season, + episode = mapping.episode, + ) + } else { + item + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt index 5ecb0492b..0b6c60cce 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt @@ -6,15 +6,25 @@ import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaVideo import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlin.time.TimeSource private const val BASE_URL = "https://api.trakt.tv" +private val NON_ALPHANUMERIC = Regex("[^a-z0-9]+") +private val COLLAPSED_SPACES = Regex("\\s+") +private val titleMutex = Mutex() +private val normalizedTitleCache = mutableMapOf() + /** * Handles episode number remapping between addon metadata (which may use multi-season * numbering for anime) and Trakt (which often uses absolute/single-season numbering). @@ -34,11 +44,81 @@ object TraktEpisodeMappingService { private val addonEpisodesCache = mutableMapOf>() private val traktEpisodesCache = mutableMapOf>() // In-flight dedup: prevents multiple concurrent coroutines from fetching + // In-flight dedup: prevents multiple concurrent coroutines from fetching // the same show's addon episodes simultaneously. private val addonEpisodesInFlight = mutableMapOf>>() + private val traktEpisodesInFlight = mutableMapOf>>() + + var normalizeTitleCount = 0L + var normalizeTitleTimeNs = 0L + var resolveAddonCalls = 0L + var resolveAddonTimeMs = 0L + var resolveForwardCalls = 0L + var resolveForwardTimeMs = 0L + var getAddonCalls = 0L + var getAddonMisses = 0L + var getAddonTimeMs = 0L + var getTraktCalls = 0L + var getTraktMisses = 0L + var getTraktTimeMs = 0L + + fun logAndResetStats() { + log.i { + """ + [TraktMappingStats] + - resolveAddonEpisodeMapping: ${resolveAddonCalls} calls, ${resolveAddonTimeMs}ms total + - resolveEpisodeMapping: ${resolveForwardCalls} calls, ${resolveForwardTimeMs}ms total + - getAddonEpisodes: ${getAddonCalls} calls (${getAddonMisses} misses), ${getAddonTimeMs}ms total + - getTraktEpisodes: ${getTraktCalls} calls (${getTraktMisses} misses), ${getTraktTimeMs}ms total + - normalizeEpisodeTitle: ${normalizeTitleCount} calls, ${normalizeTitleTimeNs / 1_000_000.0}ms total + """.trimIndent() + } + normalizeTitleCount = 0L + normalizeTitleTimeNs = 0L + resolveAddonCalls = 0L + resolveAddonTimeMs = 0L + resolveForwardCalls = 0L + resolveForwardTimeMs = 0L + getAddonCalls = 0L + getAddonMisses = 0L + getAddonTimeMs = 0L + getTraktCalls = 0L + getTraktMisses = 0L + getTraktTimeMs = 0L + } // ── Public API ──────────────────────────────────────────────────────── + /** + * Pre-fetches both addon and Trakt episode data for [contentIds] in parallel so that + * subsequent mapping calls hit caches. Limits concurrency to [concurrency]. + */ + suspend fun prefetchEpisodes( + contentIds: List, + concurrency: Int = 8 + ) { + val unique = contentIds.distinct() + if (unique.isEmpty()) return + val semaphore = Semaphore(concurrency) + coroutineScope { + unique.forEach { contentId -> + launch { + semaphore.withPermit { + getAddonEpisodes(contentId, "series") + } + } + val showLookupId = resolveShowLookupId(contentId, null) + if (showLookupId != null) { + launch { + semaphore.withPermit { + getTraktEpisodes(showLookupId) + } + } + } + } + } + } + /** * Resolves the Trakt-side season/episode for a given addon season/episode. * Used when pushing watched status TO Trakt (forward mapping: addon → Trakt). @@ -53,40 +133,46 @@ object TraktEpisodeMappingService { episode: Int?, episodeTitle: String? = null, ): EpisodeMappingEntry? { - val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null - cacheMutex.withLock { - mappingCache[key]?.let { return it } + val startTime = TimeSource.Monotonic.markNow() + resolveForwardCalls++ + try { + val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null + cacheMutex.withLock { + mappingCache[key]?.let { return it } + } + + val requestedSeason = season ?: return null + val requestedEpisode = episode ?: return null + val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null + val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null + + val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType) + if (addonEpisodes.isEmpty()) return null + + val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = videoId) ?: return null + val traktEpisodes = getTraktEpisodes(showLookupId) + if (traktEpisodes.isEmpty()) return null + + if (hasSameSeasonStructure(addonEpisodes, traktEpisodes)) { + return null + } + + val mapped = remapEpisodeByTitleOrIndex( + requestedSeason = requestedSeason, + requestedEpisode = requestedEpisode, + requestedVideoId = videoId, + requestedTitle = episodeTitle, + addonEpisodes = addonEpisodes, + traktEpisodes = traktEpisodes, + ) ?: return null + + cacheMutex.withLock { + mappingCache[key] = mapped + } + return mapped + } finally { + resolveForwardTimeMs += startTime.elapsedNow().inWholeMilliseconds } - - val requestedSeason = season ?: return null - val requestedEpisode = episode ?: return null - val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null - val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null - - val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType) - if (addonEpisodes.isEmpty()) return null - - val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = videoId) ?: return null - val traktEpisodes = getTraktEpisodes(showLookupId) - if (traktEpisodes.isEmpty()) return null - - if (hasSameSeasonStructure(addonEpisodes, traktEpisodes)) { - return null - } - - val mapped = remapEpisodeByTitleOrIndex( - requestedSeason = requestedSeason, - requestedEpisode = requestedEpisode, - requestedVideoId = videoId, - requestedTitle = episodeTitle, - addonEpisodes = addonEpisodes, - traktEpisodes = traktEpisodes, - ) ?: return null - - cacheMutex.withLock { - mappingCache[key] = mapped - } - return mapped } /** @@ -103,48 +189,54 @@ object TraktEpisodeMappingService { episode: Int?, episodeTitle: String? = null, ): EpisodeMappingEntry? { - val requestedSeason = season ?: return null - val requestedEpisode = episode ?: return null - val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null - val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null + val startTime = TimeSource.Monotonic.markNow() + resolveAddonCalls++ + try { + val requestedSeason = season ?: return null + val requestedEpisode = episode ?: return null + val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null + val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null - val reverseKey = reverseCacheKey( - contentId = resolvedContentId, - contentType = resolvedContentType, - season = requestedSeason, - episode = requestedEpisode, - title = episodeTitle, - ) - cacheMutex.withLock { - reverseMappingCache[reverseKey]?.let { return it } + val reverseKey = reverseCacheKey( + contentId = resolvedContentId, + contentType = resolvedContentType, + season = requestedSeason, + episode = requestedEpisode, + title = episodeTitle, + ) + cacheMutex.withLock { + reverseMappingCache[reverseKey]?.let { return it } + } + + val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType) + if (addonEpisodes.isEmpty()) return null + + val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = null) ?: return null + val traktEpisodes = getTraktEpisodes(showLookupId) + if (traktEpisodes.isEmpty()) return null + + val addonHasEpisode = addonEpisodes.any { + it.season == requestedSeason && it.episode == requestedEpisode + } + if (addonHasEpisode && hasSameSeasonStructure(addonEpisodes, traktEpisodes)) { + return null + } + + val mapped = reverseRemapEpisodeByTitleOrIndex( + requestedSeason = requestedSeason, + requestedEpisode = requestedEpisode, + requestedTitle = episodeTitle, + addonEpisodes = addonEpisodes, + traktEpisodes = traktEpisodes, + ) ?: return null + + cacheMutex.withLock { + reverseMappingCache[reverseKey] = mapped + } + return mapped + } finally { + resolveAddonTimeMs += startTime.elapsedNow().inWholeMilliseconds } - - val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType) - if (addonEpisodes.isEmpty()) return null - - val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = null) ?: return null - val traktEpisodes = getTraktEpisodes(showLookupId) - if (traktEpisodes.isEmpty()) return null - - val addonHasEpisode = addonEpisodes.any { - it.season == requestedSeason && it.episode == requestedEpisode - } - if (addonHasEpisode && hasSameSeasonStructure(addonEpisodes, traktEpisodes)) { - return null - } - - val mapped = reverseRemapEpisodeByTitleOrIndex( - requestedSeason = requestedSeason, - requestedEpisode = requestedEpisode, - requestedTitle = episodeTitle, - addonEpisodes = addonEpisodes, - traktEpisodes = traktEpisodes, - ) ?: return null - - cacheMutex.withLock { - reverseMappingCache[reverseKey] = mapped - } - return mapped } suspend fun getCachedEpisodeMapping( @@ -188,7 +280,7 @@ object TraktEpisodeMappingService { // ── Forward mapping: addon → Trakt ────────────────────────────────── - internal fun remapEpisodeByTitleOrIndex( + internal suspend fun remapEpisodeByTitleOrIndex( requestedSeason: Int, requestedEpisode: Int, requestedVideoId: String?, @@ -208,7 +300,7 @@ object TraktEpisodeMappingService { // ── Reverse mapping: Trakt → addon ────────────────────────────────── - internal fun reverseRemapEpisodeByTitleOrIndex( + internal suspend fun reverseRemapEpisodeByTitleOrIndex( requestedSeason: Int, requestedEpisode: Int, requestedTitle: String?, @@ -225,7 +317,7 @@ object TraktEpisodeMappingService { ) } - private fun remapEpisodeBetweenLists( + private suspend fun remapEpisodeBetweenLists( requestedSeason: Int, requestedEpisode: Int, requestedVideoId: String?, @@ -250,8 +342,11 @@ object TraktEpisodeMappingService { val normalizedTitle = normalizeEpisodeTitle(requestedTitle ?: currentSourceEpisode.title) if (isUsefulEpisodeTitle(normalizedTitle)) { - val titleMatches = orderedTargetEpisodes.filter { - normalizeEpisodeTitle(it.title) == normalizedTitle + val titleMatches = mutableListOf() + for (episode in orderedTargetEpisodes) { + if (normalizeEpisodeTitle(episode.title) == normalizedTitle) { + titleMatches.add(episode) + } } if (titleMatches.size == 1) { return titleMatches.first() @@ -270,6 +365,7 @@ object TraktEpisodeMappingService { contentId: String, contentType: String, ): List { + getAddonCalls++ val cacheKey = addonEpisodesCacheKey(contentId, contentType) // Fast path: cache hit @@ -277,6 +373,8 @@ object TraktEpisodeMappingService { addonEpisodesCache[cacheKey]?.let { return it } } + getAddonMisses++ + val startTime = TimeSource.Monotonic.markNow() // Dedup: if another coroutine is already fetching this show, await its result. val existingDeferred = cacheMutex.withLock { addonEpisodesInFlight[cacheKey] } if (existingDeferred != null) { @@ -302,9 +400,7 @@ object TraktEpisodeMappingService { return try { val addonEpisodes = fetchAddonEpisodes(contentId, contentType) - if (addonEpisodes.isNotEmpty()) { - cacheMutex.withLock { addonEpisodesCache[cacheKey] = addonEpisodes } - } + cacheMutex.withLock { addonEpisodesCache[cacheKey] = addonEpisodes } deferred.complete(addonEpisodes) addonEpisodes } catch (e: Exception) { @@ -313,6 +409,7 @@ object TraktEpisodeMappingService { emptyList() } finally { cacheMutex.withLock { addonEpisodesInFlight.remove(cacheKey) } + getAddonTimeMs += startTime.elapsedNow().inWholeMilliseconds } } @@ -351,11 +448,37 @@ object TraktEpisodeMappingService { // ── Trakt episodes fetching ───────────────────────────────────────── private suspend fun getTraktEpisodes(showLookupId: String): List { + getTraktCalls++ cacheMutex.withLock { traktEpisodesCache[showLookupId]?.let { return it } } - val headers = TraktAuthRepository.authorizedHeaders() ?: return emptyList() + val existingDeferred = cacheMutex.withLock { traktEpisodesInFlight[showLookupId] } + if (existingDeferred != null) { + return try { existingDeferred.await() } catch (_: Exception) { emptyList() } + } + + val deferred = CompletableDeferred>() + val weOwn = cacheMutex.withLock { + traktEpisodesCache[showLookupId]?.let { return it } + if (traktEpisodesInFlight.containsKey(showLookupId)) { + false + } else { + traktEpisodesInFlight[showLookupId] = deferred + true + } + } + if (!weOwn) { + val other = cacheMutex.withLock { traktEpisodesInFlight[showLookupId] } + return try { other?.await() ?: emptyList() } catch (_: Exception) { emptyList() } + } + + getTraktMisses++ + val startTime = TimeSource.Monotonic.markNow() + val headers = TraktAuthRepository.authorizedHeaders() ?: run { + cleanupTraktFlight(showLookupId) + return emptyList() + } // Trakt API: GET /shows/{id}/seasons?extended=episodes val url = "$BASE_URL/shows/$showLookupId/seasons?extended=episodes" @@ -364,17 +487,26 @@ object TraktEpisodeMappingService { }.onFailure { e -> if (e is CancellationException) throw e log.w { "getTraktEpisodes: seasons request failed id=$showLookupId: ${e.message}" } - }.getOrNull() ?: return emptyList() + }.getOrNull() ?: run { + getTraktTimeMs += startTime.elapsedNow().inWholeMilliseconds + cleanupTraktFlight(showLookupId) + return emptyList() + } val traktEpisodes = parseTraktSeasonsPayload(payload) - if (traktEpisodes.isNotEmpty()) { - cacheMutex.withLock { - traktEpisodesCache[showLookupId] = traktEpisodes - } + cacheMutex.withLock { + traktEpisodesCache[showLookupId] = traktEpisodes } + deferred.complete(traktEpisodes) + cleanupTraktFlight(showLookupId) + getTraktTimeMs += startTime.elapsedNow().inWholeMilliseconds return traktEpisodes } + private suspend fun cleanupTraktFlight(showLookupId: String) { + cacheMutex.withLock { traktEpisodesInFlight.remove(showLookupId) } + } + private fun parseTraktSeasonsPayload(payload: String): List { val seasons = runCatching { json.decodeFromString>(payload) @@ -471,13 +603,22 @@ object TraktEpisodeMappingService { .toList() } - private fun normalizeEpisodeTitle(title: String?): String { - return title - .orEmpty() - .lowercase() - .replace(Regex("[^a-z0-9]+"), " ") - .trim() - .replace(Regex("\\s+"), " ") + private suspend fun normalizeEpisodeTitle(title: String?): String { + val startTime = TimeSource.Monotonic.markNow() + normalizeTitleCount++ + try { + if (title == null) return "" + return titleMutex.withLock { + normalizedTitleCache.getOrPut(title) { + title.lowercase() + .replace(NON_ALPHANUMERIC, " ") + .trim() + .replace(COLLAPSED_SPACES, " ") + } + } + } finally { + normalizeTitleTimeNs += startTime.elapsedNow().inWholeNanoseconds + } } private fun isUsefulEpisodeTitle(normalizedTitle: String): Boolean { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.kt index 7f0ac4e20..b97757e1f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.kt @@ -3,4 +3,5 @@ package com.nuvio.app.features.trakt internal expect object TraktPlatformClock { fun nowEpochMs(): Long fun parseIsoDateTimeToEpochMs(value: String): Long? + fun availableProcessors(): Int } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index 737490910..d80d7ee43 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -66,6 +66,7 @@ object TraktProgressRepository { private val json = Json { ignoreUnknownKeys = true } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _uiState = MutableStateFlow(TraktProgressUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -323,17 +324,22 @@ object TraktProgressRepository { return } - _uiState.value = TraktProgressUiState( - entries = playbackEntries, + // Merge new playback entries into the existing state rather than replacing it wholesale. + // This prevents the CW list from briefly losing "next up" seeds (like One Piece) for the + // ~2.8s gap between fetchPlaybackEntries completing and the full sync finishing. + val existingEntries = _uiState.value.entries + val mergedWithPlayback = if (existingEntries.isEmpty()) { + playbackEntries + } else { + mergeNewestByVideoId(existingEntries + playbackEntries) + } + _uiState.value = _uiState.value.copy( + entries = mergedWithPlayback, isLoading = true, errorMessage = null, hasLoadedRemoteProgress = false, ) - if (playbackEntries.isNotEmpty()) { - launchHydration(requestId = requestId, entries = playbackEntries) - } - val completedEntries = runCatching { coroutineScope { val history = async { fetchHistoryEntries(headers) } @@ -361,15 +367,17 @@ object TraktProgressRepository { if (!isLatestRefreshRequest(requestId)) return val merged = mergeNewestByVideoId(playbackEntries + completedEntries) + val sortedMerged = merged.sortedByDescending { it.lastUpdatedEpochMs } + _uiState.value = _uiState.value.copy( - entries = merged.sortedByDescending { it.lastUpdatedEpochMs }, + entries = sortedMerged, isLoading = false, errorMessage = null, hasLoadedRemoteProgress = true, ) - - if (merged.isNotEmpty()) { - launchHydration(requestId = requestId, entries = merged) + + if (sortedMerged.isNotEmpty()) { + launchHydration(requestId = requestId, entries = sortedMerged) } } @@ -391,8 +399,9 @@ object TraktProgressRepository { current = _uiState.value.entries, hydrated = hydrated, ) + val sortedMerged = merged.sortedByDescending { it.lastUpdatedEpochMs } _uiState.value = _uiState.value.copy( - entries = merged.sortedByDescending { it.lastUpdatedEpochMs }, + entries = sortedMerged, isLoading = false, errorMessage = null, ) @@ -586,6 +595,7 @@ object TraktProgressRepository { val inProgressMovies = moviePlayback.mapIndexedNotNull { index, item -> mapPlaybackMovie(item = item, fallbackIndex = index) } + val inProgressEpisodes = episodePlayback.mapIndexedNotNull { index, item -> mapPlaybackEpisode(item = item, fallbackIndex = index) } @@ -870,35 +880,26 @@ object TraktProgressRepository { return contentId } - private suspend fun mapShowProgressEpisode( + private fun mapShowProgressEpisode( contentId: String, season: Int, episode: Int, lastWatchedAt: String?, ): WatchProgressEntry { - val resolvedEpisode = resolveAddonEpisodeProgress( - contentId = contentId, - season = season, - episode = episode, - episodeTitle = null, - ) - val resolvedSeason = resolvedEpisode?.season ?: season - val resolvedNumber = resolvedEpisode?.episode ?: episode - return WatchProgressEntry( contentType = "series", parentMetaId = contentId, parentMetaType = "series", videoId = buildPlaybackVideoId( parentMetaId = contentId, - seasonNumber = resolvedSeason, - episodeNumber = resolvedNumber, + seasonNumber = season, + episodeNumber = episode, fallbackVideoId = null, ), title = contentId, - seasonNumber = resolvedSeason, - episodeNumber = resolvedNumber, - episodeTitle = resolvedEpisode?.title, + seasonNumber = season, + episodeNumber = episode, + episodeTitle = null, lastPositionMs = 1L, durationMs = 1L, lastUpdatedEpochMs = rankedTimestamp(lastWatchedAt, fallbackIndex = 0), @@ -1074,12 +1075,17 @@ object TraktProgressRepository { if (directMatch != null) { directMatch } else { - val remapped = resolveAddonEpisodeProgress( - contentId = entry.parentMetaId, - season = resolvedSeason, - episode = resolvedEpisode, - episodeTitle = entry.episodeTitle, - ) + val remapped = runCatching { + TraktEpisodeMappingService.resolveAddonEpisodeMapping( + contentId = entry.parentMetaId, + contentType = "series", + season = resolvedSeason, + episode = resolvedEpisode, + episodeTitle = entry.episodeTitle, + ) + }.onFailure { error -> + if (error is CancellationException) throw error + }.getOrNull() if (remapped != null) { resolvedSeason = remapped.season resolvedEpisode = remapped.episode @@ -1099,8 +1105,8 @@ object TraktProgressRepository { logo = entry.logo ?: meta.logo, poster = entry.poster ?: meta.poster, background = entry.background ?: meta.background, - seasonNumber = resolvedSeason ?: entry.seasonNumber, - episodeNumber = resolvedEpisode ?: entry.episodeNumber, + seasonNumber = if (entry.isCompleted) entry.seasonNumber else (resolvedSeason ?: entry.seasonNumber), + episodeNumber = if (entry.isCompleted) entry.episodeNumber else (resolvedEpisode ?: entry.episodeNumber), episodeTitle = entry.episodeTitle ?: episode?.title, episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail, pauseDescription = entry.pauseDescription @@ -1133,7 +1139,7 @@ object TraktProgressRepository { ).normalizedCompletion() } - private suspend fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? { + private fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? { val show = item.show ?: return null val episode = item.episode ?: return null val season = episode.season ?: return null @@ -1144,14 +1150,6 @@ object TraktProgressRepository { val progressPercent = normalizeTraktProgressPercent(item.progress) ?: return null if (progressPercent <= 0f) return null - val resolvedEpisode = resolveAddonEpisodeProgress( - contentId = parentMetaId, - season = season, - episode = number, - episodeTitle = episode.title, - ) - val resolvedSeason = resolvedEpisode?.season ?: season - val resolvedNumber = resolvedEpisode?.episode ?: number return WatchProgressEntry( contentType = "series", @@ -1159,14 +1157,14 @@ object TraktProgressRepository { parentMetaType = "series", videoId = buildPlaybackVideoId( parentMetaId = parentMetaId, - seasonNumber = resolvedSeason, - episodeNumber = resolvedNumber, + seasonNumber = season, + episodeNumber = number, fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" }, ), title = show.title ?: parentMetaId, - seasonNumber = resolvedSeason, - episodeNumber = resolvedNumber, - episodeTitle = resolvedEpisode?.title ?: episode.title, + seasonNumber = season, + episodeNumber = number, + episodeTitle = episode.title, lastPositionMs = 0L, durationMs = 0L, lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), @@ -1176,7 +1174,7 @@ object TraktProgressRepository { ).normalizedCompletion() } - private suspend fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? { + private fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? { val show = item.show ?: return null val episode = item.episode ?: return null val season = episode.season ?: return null @@ -1184,14 +1182,6 @@ object TraktProgressRepository { val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title) if (parentMetaId.isBlank()) return null - val resolvedEpisode = resolveAddonEpisodeProgress( - contentId = parentMetaId, - season = season, - episode = number, - episodeTitle = episode.title, - ) - val resolvedSeason = resolvedEpisode?.season ?: season - val resolvedNumber = resolvedEpisode?.episode ?: number return WatchProgressEntry( contentType = "series", @@ -1199,14 +1189,14 @@ object TraktProgressRepository { parentMetaType = "series", videoId = buildPlaybackVideoId( parentMetaId = parentMetaId, - seasonNumber = resolvedSeason, - episodeNumber = resolvedNumber, + seasonNumber = season, + episodeNumber = number, fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" }, ), title = show.title ?: parentMetaId, - seasonNumber = resolvedSeason, - episodeNumber = resolvedNumber, - episodeTitle = resolvedEpisode?.title ?: episode.title, + seasonNumber = season, + episodeNumber = number, + episodeTitle = episode.title, lastPositionMs = 1L, durationMs = 1L, lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), @@ -1236,7 +1226,7 @@ object TraktProgressRepository { ) } - private suspend fun mapWatchedShowSeed( + private fun mapWatchedShowSeed( item: TraktWatchedShowItem, useFurthestEpisode: Boolean, ): WatchProgressEntry? { @@ -1279,14 +1269,6 @@ object TraktProgressRepository { ) }, ) ?: return null - val resolvedEpisode = resolveAddonEpisodeProgress( - contentId = parentMetaId, - season = completedEpisode.season, - episode = completedEpisode.episode, - episodeTitle = null, - ) - val resolvedSeason = resolvedEpisode?.season ?: completedEpisode.season - val resolvedNumber = resolvedEpisode?.episode ?: completedEpisode.episode return WatchProgressEntry( contentType = "series", @@ -1294,14 +1276,14 @@ object TraktProgressRepository { parentMetaType = "series", videoId = buildPlaybackVideoId( parentMetaId = parentMetaId, - seasonNumber = resolvedSeason, - episodeNumber = resolvedNumber, + seasonNumber = completedEpisode.season, + episodeNumber = completedEpisode.episode, fallbackVideoId = null, ), title = show.title ?: parentMetaId, - seasonNumber = resolvedSeason, - episodeNumber = resolvedNumber, - episodeTitle = resolvedEpisode?.title, + seasonNumber = completedEpisode.season, + episodeNumber = completedEpisode.episode, + episodeTitle = null, lastPositionMs = 1L, durationMs = 1L, lastUpdatedEpochMs = completedEpisode.watchedAt, @@ -1384,26 +1366,6 @@ object TraktProgressRepository { ?.let { return it } return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L) } - - private suspend fun resolveAddonEpisodeProgress( - contentId: String, - season: Int, - episode: Int, - episodeTitle: String?, - ): EpisodeMappingEntry? { - return runCatching { - TraktEpisodeMappingService.resolveAddonEpisodeMapping( - contentId = contentId, - contentType = "series", - season = season, - episode = episode, - episodeTitle = episodeTitle, - ) - }.onFailure { error -> - if (error is CancellationException) throw error - log.w { "resolveAddonEpisodeProgress failed for $contentId s=$season e=$episode: ${error.message}" } - }.getOrNull() - } } @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 7de51f8c7..0f46ec82b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -133,7 +133,14 @@ object WatchProgressRepository { delay(NUVIO_SYNC_PERIODIC_INTERVAL_MS) TraktAuthRepository.ensureLoaded() TraktSettingsRepository.ensureLoaded() - if (shouldUseTraktProgress()) continue + if (shouldUseTraktProgress()) { + runCatching { TraktProgressRepository.refreshNow() } + .onFailure { error -> + if (error is CancellationException) throw error + log.w { "Periodic Trakt progress refresh failed: ${error.message}" } + } + continue + } val authState = AuthRepository.state.value if (authState !is AuthState.Authenticated || authState.isAnonymous) continue diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingServiceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingServiceTest.kt index 295668b81..cebba1deb 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingServiceTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingServiceTest.kt @@ -31,7 +31,7 @@ class TraktEpisodeMappingServiceTest { } @Test - fun `forward mapping uses global sorted index for anime numbering`() { + fun `forward mapping uses global sorted index for anime numbering`() = kotlinx.coroutines.runBlocking { val addon = listOf( episode(1, 1, videoId = "show:1:1"), episode(1, 2, videoId = "show:1:2"), @@ -59,7 +59,7 @@ class TraktEpisodeMappingServiceTest { } @Test - fun `reverse mapping uses global sorted index for Trakt absolute numbering`() { + fun `reverse mapping uses global sorted index for Trakt absolute numbering`() = kotlinx.coroutines.runBlocking { val addon = listOf( episode(1, 1), episode(1, 2), @@ -86,7 +86,7 @@ class TraktEpisodeMappingServiceTest { } @Test - fun `unique normalized title wins over index`() { + fun `unique normalized title wins over index`() = kotlinx.coroutines.runBlocking { val addon = listOf( episode(1, 1, title = "The Storm"), episode(1, 2, title = "Aftermath"), @@ -110,7 +110,7 @@ class TraktEpisodeMappingServiceTest { } @Test - fun `generic title falls back to index`() { + fun `generic title falls back to index`() = kotlinx.coroutines.runBlocking { val addon = listOf( episode(1, 1, title = "Episode 1"), episode(2, 1, title = "Actual Title"), @@ -134,7 +134,7 @@ class TraktEpisodeMappingServiceTest { } @Test - fun `duplicate title falls back to index`() { + fun `duplicate title falls back to index`() = kotlinx.coroutines.runBlocking { val addon = listOf( episode(1, 1, title = "Pilot"), episode(2, 1, title = "Other"), @@ -158,7 +158,7 @@ class TraktEpisodeMappingServiceTest { } @Test - fun `video id selects source episode before season episode`() { + fun `video id selects source episode before season episode`() = kotlinx.coroutines.runBlocking { val addon = listOf( episode(1, 1, videoId = "show:1:1"), episode(2, 1, videoId = "show:2:1"), @@ -182,7 +182,7 @@ class TraktEpisodeMappingServiceTest { } @Test - fun `index outside target range returns null`() { + fun `index outside target range returns null`() = kotlinx.coroutines.runBlocking { val addon = listOf( episode(1, 1), episode(1, 2), diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt index 0094c5941..238ee286b 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt @@ -8,4 +8,6 @@ internal actual object TraktPlatformClock { actual fun parseIsoDateTimeToEpochMs(value: String): Long? = parseTraktIsoDateTimeToEpochMs(value) + + actual fun availableProcessors(): Int = kotlin.native.Platform.getAvailableProcessors() }