From 43e938f1c0d5d6efafc44216e0a3bef43d6c40c6 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 25 May 2026 13:49:42 +0530 Subject: [PATCH] Align Trakt sync with TV app --- .../app/features/details/MetaDetailsScreen.kt | 5 + .../com/nuvio/app/features/home/HomeScreen.kt | 13 +- .../nuvio/app/features/player/PlayerScreen.kt | 40 +- .../features/trakt/TraktProgressRepository.kt | 657 +++++++++++++++++- .../features/trakt/TraktScrobbleRepository.kt | 112 +-- .../watching/sync/TraktWatchedSyncAdapter.kt | 152 +++- .../watchprogress/WatchProgressRepository.kt | 33 + .../nuvio/app/features/home/HomeScreenTest.kt | 6 + 8 files changed, 904 insertions(+), 114 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 797a168b..5eb7aec4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -344,6 +344,11 @@ fun MetaDetailsScreen( val progressByVideoId = remember(watchProgressUiState.entries) { watchProgressUiState.byVideoId } + LaunchedEffect(meta.id, meta.type, watchProgressUiState.hasLoadedRemoteProgress) { + if (meta.type.lowercase() in setOf("series", "show", "tv", "tvshow")) { + WatchProgressRepository.refreshEpisodeProgress(meta.id) + } + } LaunchedEffect( meta.id, meta.type, 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 67986c8a..e7d59bf6 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 @@ -48,7 +48,6 @@ import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.CachedInProgressItem import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache -import com.nuvio.app.features.watchprogress.ContinueWatchingLimit import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem @@ -234,7 +233,7 @@ fun HomeScreen( } val visibleContinueWatchingEntries = remember(effectiveWatchProgressEntries) { - effectiveWatchProgressEntries.continueWatchingEntries() + effectiveWatchProgressEntries.continueWatchingEntries(limit = HomeContinueWatchingMaxRecentProgressItems) } LaunchedEffect(visibleContinueWatchingEntries) { @@ -476,7 +475,7 @@ fun HomeScreen( val candidatesToResolve = completedSeriesCandidates.filter { candidate -> candidate.content.id !in cachedResolvedNextUpItems } - val resolutionCandidates = candidatesToResolve.take(NEXT_UP_INITIAL_RESOLUTION_LIMIT) + val resolutionCandidates = candidatesToResolve.take(HomeNextUpInitialResolutionLimit) val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs } if (candidatesToResolve.isEmpty()) { nextUpItemsBySeries = cachedResolvedNextUpItems @@ -534,9 +533,6 @@ fun HomeScreen( ).toSet() } - if (cachedResolvedNextUpItems.size + freshResults.size >= ContinueWatchingLimit) { - break - } } val results = cachedResolvedNextUpItems + freshResults @@ -780,10 +776,11 @@ fun HomeScreen( } private const val HOME_CATALOG_PREVIEW_LIMIT = 18 +internal const val HomeContinueWatchingMaxRecentProgressItems = 300 +internal const val HomeNextUpInitialResolutionLimit = 32 private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L -private const val NEXT_UP_INITIAL_RESOLUTION_LIMIT = ContinueWatchingLimit * 2 -private const val NEXT_UP_RESOLUTION_CONCURRENCY = 8 +private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4 private const val NEXT_UP_RESOLUTION_BATCH_SIZE = NEXT_UP_RESOLUTION_CONCURRENCY internal fun filterEntriesForTraktContinueWatchingWindow( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index b87bfbf6..4cc99511 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -66,6 +66,7 @@ import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamsUiState import com.nuvio.app.features.tmdb.TmdbService +import com.nuvio.app.features.trakt.TraktScrobbleItem import com.nuvio.app.features.trakt.TraktScrobbleRepository import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.WatchProgressClock @@ -75,6 +76,7 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.isIos import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -92,6 +94,7 @@ private const val PlayerLockedOverlayDurationMs = 2_000L private const val PlayerLeftGestureBoundary = 0.4f private const val PlayerRightGestureBoundary = 0.6f private const val PlayerVerticalGestureSensitivity = 1f +private const val PlayerSeekProgressSyncDebounceMs = 700L /** Hard ceiling for next-episode stream search to prevent hanging forever. */ private const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L private val PlayerSliderOverlayGap = 12.dp @@ -246,6 +249,7 @@ fun PlayerScreen( var lockedOverlayVisible by remember { mutableStateOf(false) } var gestureMessageJob by remember { mutableStateOf(null) } var accumulatedSeekResetJob by remember { mutableStateOf(null) } + var seekProgressSyncJob by remember { mutableStateOf(null) } var accumulatedSeekState by remember { mutableStateOf(null) } var initialLoadCompleted by remember(activeSourceUrl) { mutableStateOf(false) } var speedBoostRestoreSpeed by remember(activeSourceUrl) { mutableStateOf(null) } @@ -270,6 +274,12 @@ fun PlayerScreen( activeSeasonNumber, activeEpisodeNumber, ) { mutableStateOf(false) } + var currentTraktScrobbleItem by remember( + activeSourceUrl, + activeVideoId, + activeSeasonNumber, + activeEpisodeNumber, + ) { mutableStateOf(null) } val backdropArtwork = background ?: poster val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null @@ -419,6 +429,7 @@ fun PlayerScreen( hasRequestedScrobbleStartForCurrentItem = false return@launch } + currentTraktScrobbleItem = item TraktScrobbleRepository.scrobbleStart( item = item, progressPercent = currentPlaybackProgressPercent(), @@ -431,13 +442,15 @@ fun PlayerScreen( if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return val percent = provided ?: currentPlaybackProgressPercent() - scope.launch { - val item = currentTraktScrobbleItem() ?: return@launch + val itemSnapshot = currentTraktScrobbleItem + scope.launch(NonCancellable) { + val item = itemSnapshot ?: currentTraktScrobbleItem() ?: return@launch TraktScrobbleRepository.scrobbleStop( item = item, progressPercent = percent, ) } + currentTraktScrobbleItem = null hasRequestedScrobbleStartForCurrentItem = false } @@ -481,6 +494,25 @@ fun PlayerScreen( ) } + fun scheduleProgressSyncAfterSeek() { + seekProgressSyncJob?.cancel() + seekProgressSyncJob = scope.launch { + delay(PlayerSeekProgressSyncDebounceMs) + WatchProgressRepository.upsertPlaybackProgress( + session = playbackSession, + snapshot = playbackSnapshot, + ) + + val progressPercent = currentPlaybackProgressPercent() + if (progressPercent >= 1f && progressPercent < 80f) { + emitTraktScrobbleStop(progressPercent) + if (playbackSnapshot.isPlaying) { + emitTraktScrobbleStart() + } + } + } + } + val onBackWithProgress = remember(onBack, playbackSession, playbackSnapshot) { { flushWatchProgress() @@ -728,6 +760,7 @@ fun PlayerScreen( fun seekBy(offsetMs: Long) { playerController?.seekBy(offsetMs) + scheduleProgressSyncAfterSeek() controlsVisible = true when { offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs) @@ -760,6 +793,7 @@ fun PlayerScreen( } } playerController?.seekTo(targetPositionMs) + scheduleProgressSyncAfterSeek() showSeekFeedback(direction, nextState.amountMs) accumulatedSeekResetJob?.cancel() @@ -1997,6 +2031,7 @@ fun PlayerScreen( isScrubbingTimeline = false scrubbingPositionMs = null playerController?.seekTo(positionMs) + scheduleProgressSyncAfterSeek() }, horizontalSafePadding = horizontalSafePadding, modifier = Modifier.fillMaxSize(), @@ -2063,6 +2098,7 @@ fun PlayerScreen( onSkip = { val interval = activeSkipInterval ?: return@SkipIntroButton playerController?.seekTo((interval.endTime * 1000).toLong()) + scheduleProgressSyncAfterSeek() skipIntervalDismissed = true }, onDismiss = { skipIntervalDismissed = true }, 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 7aa83580..5afd6d1a 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 @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory @@ -19,6 +20,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -38,10 +40,19 @@ import kotlinx.serialization.json.Json private const val BASE_URL = "https://api.trakt.tv" private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 90f -private const val HISTORY_LIMIT = 250 +private const val HISTORY_PAGE_LIMIT = 1000 +private const val HISTORY_MAX_PAGES = 5 +private const val HISTORY_MAX_PAGES_ALL = 20 +private const val MAX_RECENT_EPISODE_HISTORY_ENTRIES = 300 private const val METADATA_FETCH_TIMEOUT_MS = 3_500L private const val METADATA_FETCH_CONCURRENCY = 5 -private const val METADATA_HYDRATION_LIMIT = 30 +private const val METADATA_HYDRATION_LIMIT = 110 +private const val REFRESH_BASE_INTERVAL_MS = 60L * 1000L +private const val REFRESH_MAX_INTERVAL_MS = 15L * 60L * 1000L +private const val EPISODE_PROGRESS_CACHE_TTL_MS = 30L * 60L * 1000L +private const val EPISODE_PROGRESS_FETCH_THROTTLE_MS = 60L * 1000L +private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L +private const val AMBIGUOUS_ID_MARKER = "__ambiguous__" data class TraktProgressUiState( val entries: List = emptyList(), @@ -64,6 +75,44 @@ object TraktProgressRepository { private var refreshRequestId: Long = 0L private val refreshJobMutex = Mutex() private var inFlightRefresh: Deferred? = null + private var refreshIntervalMs = REFRESH_BASE_INTERVAL_MS + private var consecutiveRefreshFailures = 0 + private var lastKnownMoviesWatchedAt: String? = null + private var lastKnownEpisodeActivityFingerprint: String? = null + private var lastKnownActivityFingerprint: String? = null + private val episodeProgressMutex = Mutex() + private val episodeProgressFetchedAtMsByContentId = mutableMapOf() + private val episodeProgressLastAttemptAtMsByContentId = mutableMapOf() + private val inFlightEpisodeProgressContentIds = mutableSetOf() + private var watchedShowEpisodesById: Map>> = emptyMap() + private var showIdToTraktPathId: Map = emptyMap() + private var showIdSiblingsMap: Map> = emptyMap() + + init { + scope.launch { + while (true) { + delay(refreshIntervalMs) + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + if (!shouldUseTraktProgress( + isAuthenticated = TraktAuthRepository.isAuthenticated.value, + source = TraktSettingsRepository.uiState.value.watchProgressSource, + ) + ) { + updateRefreshBackoff(success = true) + continue + } + + val success = runCatching { + refreshIfActivityChanged() + }.onFailure { error -> + if (error is CancellationException) throw error + log.w { "Periodic Trakt activity refresh failed: ${error.message}" } + }.isSuccess + updateRefreshBackoff(success = success) + } + } + } fun ensureLoaded() { if (hasLoaded) return @@ -79,6 +128,7 @@ object TraktProgressRepository { parsed.imdb?.takeIf { it.isNotBlank() }?.let { add(it) } parsed.tmdb?.let { add("tmdb:$it") } parsed.trakt?.let { add("trakt:$it") } + showIdSiblingsMap[contentId]?.forEach { add(it) } } return keys.any { ids.contains(it) } } @@ -87,6 +137,8 @@ object TraktProgressRepository { invalidateInFlightRefreshes() hasLoaded = false hiddenProgressShowIds.value = emptySet() + resetActivitySnapshot() + resetShowProgressCaches() _uiState.value = TraktProgressUiState() ensureLoaded() } @@ -95,6 +147,8 @@ object TraktProgressRepository { invalidateInFlightRefreshes() hasLoaded = false hiddenProgressShowIds.value = emptySet() + resetActivitySnapshot() + resetShowProgressCaches() _uiState.value = TraktProgressUiState() } @@ -123,6 +177,126 @@ object TraktProgressRepository { } } + suspend fun refreshEpisodeProgress( + contentId: String, + forceRefresh: Boolean = false, + ) { + ensureLoaded() + val normalizedContentId = contentId.trim() + if (normalizedContentId.isBlank()) return + val headers = TraktAuthRepository.authorizedHeaders() ?: return + val cacheKey = canonicalLookupKey(normalizedContentId) + val now = TraktPlatformClock.nowEpochMs() + + var shouldFetch = forceRefresh + episodeProgressMutex.withLock { + val lastFetchedAt = episodeProgressFetchedAtMsByContentId[cacheKey] ?: 0L + val isFresh = lastFetchedAt > 0L && now - lastFetchedAt <= EPISODE_PROGRESS_CACHE_TTL_MS + if (!forceRefresh && isFresh) return + + val lastAttemptAt = episodeProgressLastAttemptAtMsByContentId[cacheKey] ?: 0L + if (!forceRefresh && now - lastAttemptAt < EPISODE_PROGRESS_FETCH_THROTTLE_MS) return + + if (!inFlightEpisodeProgressContentIds.add(cacheKey)) return + episodeProgressLastAttemptAtMsByContentId[cacheKey] = now + shouldFetch = true + } + + if (!shouldFetch) return + + try { + val entries = fetchEpisodeProgressEntries(headers = headers, contentId = normalizedContentId) + val existingEntries = _uiState.value.entries + val merged = mergeNewestByVideoId(existingEntries + entries) + _uiState.value = _uiState.value.copy(entries = merged.sortedByDescending { it.lastUpdatedEpochMs }) + episodeProgressMutex.withLock { + episodeProgressFetchedAtMsByContentId[cacheKey] = TraktPlatformClock.nowEpochMs() + } + if (entries.isNotEmpty()) { + launchHydration(requestId = refreshRequestId, entries = entries) + } + } finally { + episodeProgressMutex.withLock { + inFlightEpisodeProgressContentIds.remove(cacheKey) + } + } + } + + private suspend fun refreshIfActivityChanged() { + val headers = TraktAuthRepository.authorizedHeaders() ?: return + if (hasActivityChanged(headers) || !_uiState.value.hasLoadedRemoteProgress) { + refreshNow() + } + } + + private suspend fun hasActivityChanged(headers: Map): Boolean { + val activities = runCatching { + json.decodeFromString( + httpGetTextWithHeaders( + url = "$BASE_URL/sync/last_activities", + headers = headers, + ), + ) + }.onFailure { error -> + if (error is CancellationException) throw error + }.getOrNull() ?: return !_uiState.value.hasLoadedRemoteProgress + + val moviesWatchedAt = activities.movies?.watchedAt + if (moviesWatchedAt != lastKnownMoviesWatchedAt) { + lastKnownMoviesWatchedAt = moviesWatchedAt + } + + val episodeFingerprint = listOfNotNull( + activities.episodes?.pausedAt, + activities.episodes?.watchedAt, + ).joinToString("|") + if (episodeFingerprint != lastKnownEpisodeActivityFingerprint) { + lastKnownEpisodeActivityFingerprint = episodeFingerprint + episodeProgressMutex.withLock { + episodeProgressFetchedAtMsByContentId.clear() + } + } + + val fingerprint = listOfNotNull( + activities.movies?.pausedAt, + activities.movies?.watchedAt, + activities.episodes?.pausedAt, + activities.episodes?.watchedAt, + ).joinToString("|") + val changed = fingerprint != lastKnownActivityFingerprint + lastKnownActivityFingerprint = fingerprint + return changed + } + + private fun updateRefreshBackoff(success: Boolean) { + if (success) { + consecutiveRefreshFailures = 0 + refreshIntervalMs = REFRESH_BASE_INTERVAL_MS + return + } + + consecutiveRefreshFailures += 1 + refreshIntervalMs = (REFRESH_BASE_INTERVAL_MS shl (consecutiveRefreshFailures - 1)) + .coerceAtMost(REFRESH_MAX_INTERVAL_MS) + } + + private fun resetActivitySnapshot() { + lastKnownMoviesWatchedAt = null + lastKnownEpisodeActivityFingerprint = null + lastKnownActivityFingerprint = null + consecutiveRefreshFailures = 0 + refreshIntervalMs = REFRESH_BASE_INTERVAL_MS + } + + private fun resetShowProgressCaches() { + watchedShowEpisodesById = emptyMap() + showIdToTraktPathId = emptyMap() + showIdSiblingsMap = emptyMap() + episodeProgressFetchedAtMsByContentId.clear() + episodeProgressLastAttemptAtMsByContentId.clear() + inFlightEpisodeProgressContentIds.clear() + } + private suspend fun refreshNowInternal() { ensureLoaded() val requestId = nextRefreshRequestId() @@ -233,6 +407,13 @@ object TraktProgressRepository { if (existing == null || normalizedEntry.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs) { current[normalizedEntry.videoId] = normalizedEntry } + if (normalizedEntry.isCompleted && normalizedEntry.seasonNumber != null && normalizedEntry.episodeNumber != null) { + optimisticallyAddWatchedEpisode( + contentId = normalizedEntry.parentMetaId, + season = normalizedEntry.seasonNumber, + episode = normalizedEntry.episodeNumber, + ) + } _uiState.value = _uiState.value.copy(entries = current.values.sortedByDescending { it.lastUpdatedEpochMs }) } @@ -260,6 +441,13 @@ object TraktProgressRepository { true } } + if (seasonNumber != null && episodeNumber != null) { + optimisticallyRemoveWatchedEpisode( + contentId = normalizedContentId, + season = seasonNumber, + episode = episodeNumber, + ) + } _uiState.value = _uiState.value.copy(entries = filtered) } @@ -407,37 +595,77 @@ object TraktProgressRepository { } private suspend fun fetchHistoryEntries(headers: Map): List = withContext(Dispatchers.Default) { - val payloads = coroutineScope { - val historyPayload = async { - httpGetTextWithHeaders( - url = "$BASE_URL/sync/history/episodes?limit=$HISTORY_LIMIT", - headers = headers, - ) - } - val movieHistoryPayload = async { - httpGetTextWithHeaders( - url = "$BASE_URL/sync/history/movies?limit=$HISTORY_LIMIT", - headers = headers, - ) - } - - awaitAll(historyPayload, movieHistoryPayload) + val (episodeHistory, movieHistory) = coroutineScope { + val episodeHistory = async { fetchRecentEpisodeHistoryEntries(headers) } + val movieHistory = async { fetchRecentMovieHistoryEntries(headers) } + episodeHistory.await() to movieHistory.await() } - val historyPayload = payloads[0] - val movieHistoryPayload = payloads[1] - val episodeHistory = json.decodeFromString>(historyPayload) - val movieHistory = json.decodeFromString>(movieHistoryPayload) + mergeNewestByVideoId(episodeHistory + movieHistory) + } - val completedEpisodes = episodeHistory - .mapIndexedNotNull { index, item -> mapHistoryEpisode(item = item, fallbackIndex = index) } - .distinctBy { entry -> entry.videoId } - val completedMovies = movieHistory + private suspend fun fetchRecentEpisodeHistoryEntries( + headers: Map, + ): List { + val cutoffMs = recentWatchCutoffMs() + val maxPages = if (isAllHistoryWindow()) HISTORY_MAX_PAGES_ALL else HISTORY_MAX_PAGES + val resultsByShow = linkedMapOf() + var fallbackIndex = 0 + var page = 1 + + while (page <= maxPages && resultsByShow.size < MAX_RECENT_EPISODE_HISTORY_ENTRIES) { + val url = buildString { + append("$BASE_URL/sync/history/episodes?page=$page&limit=$HISTORY_PAGE_LIMIT") + cutoffMs?.let { append("&start_at=").append(epochMsToTraktIso(it)) } + } + val response = httpRequestRaw( + method = "GET", + url = url, + headers = headers, + body = "", + ) + if (response.status !in 200..299) break + + val items = runCatching { + json.decodeFromString>(response.body) + }.getOrDefault(emptyList()) + if (items.isEmpty()) break + + var shouldStop = false + for (item in items) { + val entry = mapHistoryEpisode(item = item, fallbackIndex = fallbackIndex++) ?: continue + if (cutoffMs != null && entry.lastUpdatedEpochMs < cutoffMs) { + shouldStop = true + continue + } + resultsByShow.putIfAbsent(entry.parentMetaId, entry) + if (resultsByShow.size >= MAX_RECENT_EPISODE_HISTORY_ENTRIES) { + shouldStop = true + break + } + } + + val pageCount = response.headerInt("x-pagination-page-count") + if (items.size < HISTORY_PAGE_LIMIT || shouldStop || (pageCount != null && page >= pageCount)) break + page += 1 + } + + return resultsByShow.values.toList() + } + + private suspend fun fetchRecentMovieHistoryEntries( + headers: Map, + ): List { + val cutoffMs = recentWatchCutoffMs() + val url = buildString { + append("$BASE_URL/sync/history/movies?limit=$HISTORY_PAGE_LIMIT") + cutoffMs?.let { append("&start_at=").append(epochMsToTraktIso(it)) } + } + val payload = httpGetTextWithHeaders(url = url, headers = headers) + return json.decodeFromString>(payload) .mapIndexedNotNull { index, item -> mapHistoryMovie(item = item, fallbackIndex = index) } + .filter { entry -> cutoffMs == null || entry.lastUpdatedEpochMs >= cutoffMs } .distinctBy { entry -> entry.videoId } - - val merged = mergeNewestByVideoId(completedEpisodes + completedMovies) - merged } private suspend fun fetchWatchedShowSeedEntries( @@ -450,17 +678,287 @@ object TraktProgressRepository { headers = headers, ) val watchedShows = json.decodeFromString>(payload) - val mapped = watchedShows - .mapNotNull { item -> + updateWatchedShowCaches(watchedShows) + val mapped = fixAmbiguousWatchedShowSeeds( + watchedShows.mapNotNull { item -> mapWatchedShowSeed( item = item, useFurthestEpisode = useFurthestEpisode, ) - } + }, + ) .sortedByDescending { entry -> entry.lastUpdatedEpochMs } mapped } + private fun updateWatchedShowCaches(items: List) { + val siblingsMap = mutableMapOf>() + + items.forEach { item -> + val keys = watchedShowLookupKeys(item.show?.ids) + if (keys.size <= 1) return@forEach + for (key in keys) { + val existing = siblingsMap[key] + if (existing != null) { + existing.clear() + existing.add(AMBIGUOUS_ID_MARKER) + } else { + siblingsMap[key] = (keys - key).toMutableSet() + } + } + } + + val ambiguousIds = siblingsMap.entries + .filter { (_, siblings) -> AMBIGUOUS_ID_MARKER in siblings } + .mapTo(mutableSetOf()) { (key, _) -> key } + + val episodesByKey = mutableMapOf>>() + val pathIdsByKey = mutableMapOf() + + items.forEach { item -> + val ids = item.show?.ids ?: return@forEach + val keys = watchedShowLookupKeys(ids).filter { it !in ambiguousIds } + if (keys.isEmpty()) return@forEach + + val traktPathId = ids.slug?.takeIf { it.isNotBlank() } ?: ids.trakt?.toString() + if (traktPathId != null) { + keys.forEach { key -> pathIdsByKey[key] = traktPathId } + } + + val episodes = mutableSetOf>() + item.seasons.orEmpty() + .filter { (it.number ?: 0) > 0 } + .forEach { season -> + val seasonNumber = season.number ?: return@forEach + season.episodes.orEmpty() + .filter { episode -> (episode.number ?: 0) > 0 && (episode.plays ?: 1) > 0 } + .forEach { episode -> + val episodeNumber = episode.number ?: return@forEach + episodes.add(seasonNumber to episodeNumber) + } + } + + if (episodes.isNotEmpty()) { + keys.forEach { key -> + episodesByKey.getOrPut(key) { mutableSetOf() }.addAll(episodes) + } + } + } + + watchedShowEpisodesById = episodesByKey.mapValues { (_, episodes) -> episodes.toSet() } + showIdToTraktPathId = pathIdsByKey + showIdSiblingsMap = siblingsMap.mapValues { (_, siblings) -> siblings.toSet() } + } + + private fun fixAmbiguousWatchedShowSeeds( + seeds: List, + ): List { + val ambiguousIds = showIdSiblingsMap.entries + .filter { (_, siblings) -> AMBIGUOUS_ID_MARKER in siblings } + .mapTo(mutableSetOf()) { (key, _) -> key } + if (ambiguousIds.isEmpty()) return seeds + + return seeds.map { seed -> + if (!seed.parentMetaId.startsWith("tt") || seed.parentMetaId !in ambiguousIds) { + seed + } else { + val tmdbSibling = showIdSiblingsMap[seed.parentMetaId] + ?.firstOrNull { it.startsWith("tmdb:") } + if (tmdbSibling == null) { + seed + } else { + val remappedVideoId = if (seed.seasonNumber != null && seed.episodeNumber != null) { + buildPlaybackVideoId( + parentMetaId = tmdbSibling, + seasonNumber = seed.seasonNumber, + episodeNumber = seed.episodeNumber, + fallbackVideoId = null, + ) + } else { + tmdbSibling + } + seed.copy(parentMetaId = tmdbSibling, videoId = remappedVideoId) + } + } + } + } + + private suspend fun fetchEpisodeProgressEntries( + headers: Map, + contentId: String, + ): List = withContext(Dispatchers.Default) { + val pathId = resolveToTraktAcceptedId(headers = headers, contentId = contentId) + val response = httpRequestRaw( + method = "GET", + url = "$BASE_URL/shows/$pathId/progress/watched?hidden=false&specials=false&count_specials=false", + headers = headers, + body = "", + ) + if (response.status !in 200..299) return@withContext emptyList() + + val progress = runCatching { + json.decodeFromString(response.body) + }.getOrNull() ?: return@withContext emptyList() + + val completed = mutableListOf() + progress.seasons.orEmpty() + .filter { season -> (season.number ?: 0) > 0 } + .forEach { season -> + val seasonNumber = season.number ?: return@forEach + season.episodes.orEmpty() + .filter { episode -> episode.completed == true && (episode.number ?: 0) > 0 } + .forEach { episode -> + val episodeNumber = episode.number ?: return@forEach + completed += mapShowProgressEpisode( + contentId = contentId, + season = seasonNumber, + episode = episodeNumber, + lastWatchedAt = episode.lastWatchedAt, + ) + } + } + + val inProgress = fetchPlaybackEntries(headers) + .filter { entry -> + entry.parentMetaId == contentId && + entry.seasonNumber != null && + entry.episodeNumber != null + } + + mergeNewestByVideoId(completed + inProgress) + } + + private suspend fun resolveToTraktAcceptedId( + headers: Map, + contentId: String, + ): String { + val parsed = parseTraktContentIds(contentId) + parsed.imdb?.takeIf { it.isNotBlank() }?.let { return it } + parsed.trakt?.let { return it.toString() } + + val tmdb = parsed.tmdb + if (tmdb != null) { + showIdToTraktPathId["tmdb:$tmdb"]?.let { return it } + runCatching { + TmdbService.tmdbToImdb(tmdbId = tmdb, mediaType = "series") + ?: TmdbService.tmdbToImdb(tmdbId = tmdb, mediaType = "movie") + }.getOrNull()?.takeIf { it.isNotBlank() }?.let { return it } + + val response = runCatching { + httpRequestRaw( + method = "GET", + url = "$BASE_URL/search/tmdb/$tmdb?type=show", + headers = headers, + body = "", + ) + }.getOrNull() + if (response != null && response.status in 200..299) { + val result = runCatching { + json.decodeFromString>(response.body) + }.getOrDefault(emptyList()).firstOrNull() + result?.show?.ids?.let { ids -> + ids.imdb?.takeIf { it.isNotBlank() }?.let { return it } + ids.slug?.takeIf { it.isNotBlank() }?.let { return it } + ids.trakt?.let { return it.toString() } + } + } + return "tmdb:$tmdb" + } + + return contentId + } + + private suspend 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, + fallbackVideoId = null, + ), + title = contentId, + seasonNumber = resolvedSeason, + episodeNumber = resolvedNumber, + episodeTitle = resolvedEpisode?.title, + lastPositionMs = 1L, + durationMs = 1L, + lastUpdatedEpochMs = rankedTimestamp(lastWatchedAt, fallbackIndex = 0), + isCompleted = true, + progressPercent = 100f, + source = WatchProgressSourceTraktShowProgress, + ) + } + + private fun watchedShowLookupKeys(ids: TraktExternalIds?): List { + if (ids == null) return emptyList() + return buildList { + ids.imdb?.takeIf { it.isNotBlank() }?.let { add(it) } + ids.tmdb?.let { add("tmdb:$it") } + ids.trakt?.let { add("trakt:$it") } + ids.slug?.takeIf { it.isNotBlank() }?.let { add(it) } + } + } + + private fun canonicalLookupKey(contentId: String): String { + val canonical = normalizeTraktContentId( + ids = parseTraktContentIds(contentId), + fallback = contentId.trim(), + ) + return canonical.takeIf { it.isNotBlank() } ?: contentId.trim() + } + + private fun optimisticallyAddWatchedEpisode(contentId: String, season: Int, episode: Int) { + updateWatchedEpisodeCache(contentId = contentId, season = season, episode = episode, add = true) + } + + private fun optimisticallyRemoveWatchedEpisode(contentId: String, season: Int, episode: Int) { + updateWatchedEpisodeCache(contentId = contentId, season = season, episode = episode, add = false) + } + + private fun updateWatchedEpisodeCache( + contentId: String, + season: Int, + episode: Int, + add: Boolean, + ) { + val key = contentId.trim() + if (key.isBlank()) return + val keysToUpdate = showIdSiblingsMap[key] + ?.let { siblings -> (siblings + key).filter { it != AMBIGUOUS_ID_MARKER && !it.startsWith("trakt:") } } + ?: listOf(key) + val updated = watchedShowEpisodesById.toMutableMap() + var changed = false + keysToUpdate.forEach { lookupKey -> + val current = updated[lookupKey].orEmpty() + val pair = season to episode + val next = if (add) current + pair else current - pair + if (next != current) { + updated[lookupKey] = next + changed = true + } + } + if (changed) { + watchedShowEpisodesById = updated + } + } + private fun mergeNewestByVideoId(entries: List): List { val mergedByVideoId = linkedMapOf() entries.forEach { rawEntry -> @@ -821,6 +1319,62 @@ object TraktProgressRepository { return normalized.coerceIn(0f, 100f) } + private fun recentWatchCutoffMs(): Long? { + val daysCap = normalizeTraktContinueWatchingDaysCap( + TraktSettingsRepository.uiState.value.continueWatchingDaysCap, + ) + if (daysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return null + return TraktPlatformClock.nowEpochMs() - (daysCap.toLong() * MILLIS_PER_DAY) + } + + private fun isAllHistoryWindow(): Boolean = + normalizeTraktContinueWatchingDaysCap( + TraktSettingsRepository.uiState.value.continueWatchingDaysCap, + ) == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL + + private fun epochMsToTraktIso(epochMs: Long): String { + val totalSeconds = epochMs.coerceAtLeast(0L) / 1000L + val second = (totalSeconds % 60).toInt() + val minute = ((totalSeconds / 60) % 60).toInt() + val hour = ((totalSeconds / 3600) % 24).toInt() + var days = (totalSeconds / 86400).toInt() + + var year = 1970 + while (true) { + val daysInYear = if (isLeapYear(year)) 366 else 365 + if (days < daysInYear) break + days -= daysInYear + year += 1 + } + + val monthDays = if (isLeapYear(year)) { + intArrayOf(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + } else { + intArrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + } + var monthIndex = 0 + while (monthIndex < monthDays.size && days >= monthDays[monthIndex]) { + days -= monthDays[monthIndex] + monthIndex += 1 + } + val month = monthIndex + 1 + val day = days + 1 + + return "${year.pad4()}-${month.pad2()}-${day.pad2()}T${hour.pad2()}:${minute.pad2()}:${second.pad2()}.000Z" + } + + private fun isLeapYear(year: Int): Boolean = + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) + + private fun Int.pad2(): String = if (this < 10) "0$this" else "$this" + private fun Int.pad4(): String = toString().padStart(4, '0') + + private fun com.nuvio.app.features.addons.RawHttpResponse.headerInt(name: String): Int? = + headers[name.lowercase()] + ?.substringBefore(',') + ?.trim() + ?.toIntOrNull() + private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long { isoDate ?.takeIf { it.isNotBlank() } @@ -860,6 +1414,19 @@ private data class TraktPlaybackItem( @SerialName("episode") val episode: TraktEpisode? = null, ) +@Serializable +private data class TraktLastActivitiesResponse( + @SerialName("all") val all: String? = null, + @SerialName("movies") val movies: TraktLastActivitiesMedia? = null, + @SerialName("episodes") val episodes: TraktLastActivitiesMedia? = null, +) + +@Serializable +private data class TraktLastActivitiesMedia( + @SerialName("watched_at") val watchedAt: String? = null, + @SerialName("paused_at") val pausedAt: String? = null, +) + @Serializable private data class TraktHistoryEpisodeItem( @SerialName("watched_at") val watchedAt: String? = null, @@ -913,6 +1480,34 @@ private data class TraktEpisode( @SerialName("ids") val ids: TraktExternalIds? = null, ) +@Serializable +private data class TraktShowProgressResponse( + @SerialName("aired") val aired: Int? = null, + @SerialName("completed") val completed: Int? = null, + @SerialName("seasons") val seasons: List? = null, +) + +@Serializable +private data class TraktShowProgressSeason( + @SerialName("number") val number: Int? = null, + @SerialName("episodes") val episodes: List? = null, +) + +@Serializable +private data class TraktShowProgressEpisode( + @SerialName("number") val number: Int? = null, + @SerialName("completed") val completed: Boolean? = null, + @SerialName("last_watched_at") val lastWatchedAt: String? = null, +) + +@Serializable +private data class TraktSearchResult( + @SerialName("type") val type: String? = null, + @SerialName("score") val score: Float? = null, + @SerialName("show") val show: TraktMedia? = null, + @SerialName("movie") val movie: TraktMedia? = null, +) + @Serializable private data class TraktHiddenItem( @SerialName("hidden_at") val hiddenAt: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt index 69445d7d..6130d1ae 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt @@ -1,8 +1,11 @@ package com.nuvio.app.features.trakt import co.touchlab.kermit.Logger +import com.nuvio.app.core.build.AppVersionConfig import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.profiles.ProfileRepository import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -38,6 +41,7 @@ internal sealed interface TraktScrobbleItem { internal object TraktScrobbleRepository { private data class ScrobbleStamp( + val profileId: Int, val action: String, val itemKey: String, val progress: Float, @@ -54,6 +58,9 @@ internal object TraktScrobbleRepository { private var lastScrobbleStamp: ScrobbleStamp? = null private val minSendIntervalMs = 8_000L private val progressWindow = 1.5f + private val maxStopRetries = 2 + private val retryDelayMs = 1_500L + private val serverOverloadedRetryDelayMs = 5_000L suspend fun scrobbleStart(item: TraktScrobbleItem, progressPercent: Float) { sendScrobble(action = "start", item = item, progressPercent = progressPercent) @@ -113,8 +120,9 @@ internal object TraktScrobbleRepository { progressPercent: Float, ) { val headers = TraktAuthRepository.authorizedHeaders() ?: return + val activeProfileId = ProfileRepository.activeProfileId val clampedProgress = progressPercent.coerceIn(0f, 100f) - if (shouldSkip(action, item.itemKey, clampedProgress)) return + if (shouldSkip(activeProfileId, action, item.itemKey, clampedProgress)) return val url = "$BASE_URL/scrobble/$action" val requestBody = json.encodeToString(buildRequestBody(item, clampedProgress)) @@ -140,70 +148,74 @@ internal object TraktScrobbleRepository { } } - val response = runCatching { - httpRequestRaw( - method = "POST", - url = url, - body = requestBody, - headers = requestHeaders, - ) - }.onFailure { error -> - if (error is CancellationException) throw error - log.w(error) { + val attempts = if (action == "stop") maxStopRetries + 1 else 1 + var wasSent = false + for (attempt in 1..attempts) { + val response = runCatching { + httpRequestRaw( + method = "POST", + url = url, + body = requestBody, + headers = requestHeaders, + ) + }.onFailure { error -> + if (error is CancellationException) throw error + log.w(error) { + "Trakt scrobble $action transport failure on attempt $attempt/$attempts" + } + }.getOrNull() + + if (response == null) { + if (attempt < attempts) { + delay(retryDelayMs * attempt) + continue + } + return + } + + log.d { buildString { append("Trakt scrobble ") append(action) - append(" transport failure") + append(" response") + append('\n') + append("status=") + append(response.status) + append(' ') + append(response.statusText.ifBlank { "" }) append('\n') append("url=") - append(url) + append(response.url) append('\n') append("headers=") - append(requestHeaders.redactedForLogs().formatForLog()) + append(response.headers.formatForLog()) append('\n') append("body=") - append(requestBody.ifBlank { "" }) + append(response.body.ifBlank { "" }) } } - }.getOrNull() - if (response == null) return - - log.d { - buildString { - append("Trakt scrobble ") - append(action) - append(" response") - append('\n') - append("status=") - append(response.status) - append(' ') - append(response.statusText.ifBlank { "" }) - append('\n') - append("url=") - append(response.url) - append('\n') - append("headers=") - append(response.headers.formatForLog()) - append('\n') - append("body=") - append(response.body.ifBlank { "" }) + if (response.status in 200..299 || response.status == 409) { + wasSent = true + break } - } - val wasSent = when (response.status) { - in 200..299, 409 -> true - else -> { - log.w { - "Failed Trakt scrobble $action: HTTP ${response.status} ${response.statusText.ifBlank { "" }}" - } - false + if (response.status in 500..599 && attempt < attempts) { + val delayMs = if (response.status in 502..504) serverOverloadedRetryDelayMs else retryDelayMs * attempt + delay(delayMs) + continue } + + log.w { + "Failed Trakt scrobble $action: HTTP ${response.status} ${response.statusText.ifBlank { "" }}" + } + return } if (!wasSent) return lastScrobbleStamp = ScrobbleStamp( + profileId = activeProfileId, action = action, itemKey = item.itemKey, progress = clampedProgress, @@ -231,6 +243,7 @@ internal object TraktScrobbleRepository { ids = item.ids.toRequestBodyOrNull(), ), progress = clampedProgress, + appVersion = AppVersionConfig.VERSION_NAME, ) is TraktScrobbleItem.Episode -> TraktScrobbleRequest( @@ -245,21 +258,23 @@ internal object TraktScrobbleRepository { number = item.number, ), progress = clampedProgress, + appVersion = AppVersionConfig.VERSION_NAME, ) } } - private fun shouldSkip(action: String, itemKey: String, progress: Float): Boolean { + private fun shouldSkip(profileId: Int, action: String, itemKey: String, progress: Float): Boolean { val last = lastScrobbleStamp ?: return false val now = TraktPlatformClock.nowEpochMs() val isSameWindow = now - last.timestampMs < minSendIntervalMs + val isSameProfile = last.profileId == profileId val isSameAction = last.action == action val isSameItem = last.itemKey == itemKey val isNearProgress = abs(last.progress - progress) <= progressWindow - if (action == "stop" && last.action == "start" && isSameItem) { + if (action == "stop" && last.action == "start" && isSameItem && isSameProfile) { return false } - return isSameWindow && isSameAction && isSameItem && isNearProgress + return isSameWindow && isSameProfile && isSameAction && isSameItem && isNearProgress } private fun Map.redactedForLogs(): Map = @@ -302,6 +317,7 @@ private data class TraktScrobbleRequest( @SerialName("show") val show: TraktShowBody? = null, @SerialName("episode") val episode: TraktEpisodeBody? = null, @SerialName("progress") val progress: Float, + @SerialName("app_version") val appVersion: String? = null, ) @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt index ac647c89..f3c82591 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt @@ -2,7 +2,8 @@ package com.nuvio.app.features.watching.sync import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders -import com.nuvio.app.features.addons.httpPostJsonWithHeaders +import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktEpisodeMappingService import com.nuvio.app.features.trakt.TraktPlatformClock @@ -132,10 +133,10 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { val movies = mutableListOf() val shows = mutableListOf() - items.forEach { item -> - if (!item.shouldSyncToTraktHistory()) return@forEach + for (item in items) { + if (!item.shouldSyncToTraktHistory()) continue - val ids = parseIds(item.id) ?: return@forEach + val ids = resolveHistoryIds(item) ?: continue val normalizedType = item.type.trim().lowercase() if (normalizedType == "movie" || normalizedType == "film") { @@ -201,20 +202,28 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ), ) - val responseText = runCatching { - httpPostJsonWithHeaders( + val response = runCatching { + httpRequestRaw( + method = "POST", url = "$BASE_URL/sync/history", body = body, - headers = headers, + headers = jsonHeaders(headers), ) }.onFailure { e -> if (e is CancellationException) throw e log.w { "Failed to push watched items to Trakt: ${e.message}" } }.getOrNull() - // Retry with remapped numbering for episodes that Trakt didn't recognize - // (anime with different season structures between addon and Trakt). - if (responseText != null && shows.isNotEmpty()) { + val responseBody = response?.body?.takeIf { it.isNotBlank() }?.let { payload -> + runCatching { json.decodeFromString(payload) }.getOrNull() + } + val shouldRetryRemap = shows.isNotEmpty() && ( + response == null || + response.status !in 200..299 || + hasHistoryAddNotFound(responseBody) || + !hasSuccessfulHistoryAdd(responseBody) + ) + if (shouldRetryRemap) { val episodeItems = items.filter { it.season != null && it.episode != null && it.type.trim().lowercase() !in listOf("movie", "film") @@ -243,7 +252,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ) ?: continue if (mapped.season == season && mapped.episode == episode) continue - val ids = parseIds(item.id) ?: continue + val ids = resolveHistoryIds(item) ?: continue val existing = remappedShows.firstOrNull { it.ids == ids } if (existing != null) { val seasonDto = existing.seasons?.firstOrNull { it.number == mapped.season } @@ -297,10 +306,11 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ) runCatching { - httpPostJsonWithHeaders( + httpRequestRaw( + method = "POST", url = "$BASE_URL/sync/history", body = retryBody, - headers = headers, + headers = jsonHeaders(headers), ) }.onFailure { e -> if (e is CancellationException) throw e @@ -319,10 +329,10 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { val movies = mutableListOf() val shows = mutableListOf() - items.forEach { item -> - if (!item.shouldSyncToTraktHistory()) return@forEach + for (item in items) { + if (!item.shouldSyncToTraktHistory()) continue - val ids = parseIds(item.id) ?: return@forEach + val ids = resolveHistoryIds(item) ?: continue val normalizedType = item.type.trim().lowercase() if (normalizedType == "movie" || normalizedType == "film") { @@ -357,23 +367,32 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ), ) - runCatching { - httpPostJsonWithHeaders( + val response = runCatching { + httpRequestRaw( + method = "POST", url = "$BASE_URL/sync/history/remove", body = body, - headers = headers, + headers = jsonHeaders(headers), ) }.onFailure { e -> if (e is CancellationException) throw e log.w { "Failed to remove watched items from Trakt: ${e.message}" } - } + }.getOrNull() - // Retry removal with remapped numbering for anime cases val episodeItems = items.filter { it.season != null && it.episode != null && it.type.trim().lowercase() !in listOf("movie", "film") } - if (episodeItems.isNotEmpty()) { + val responseBody = response?.body?.takeIf { it.isNotBlank() }?.let { payload -> + runCatching { json.decodeFromString(payload) }.getOrNull() + } + val shouldRetryRemap = episodeItems.isNotEmpty() && ( + response == null || + response.status !in 200..299 || + hasHistoryRemoveNotFound(responseBody) || + (responseBody?.deleted?.episodes ?: 0) == 0 + ) + if (shouldRetryRemap) { retryDeleteWithRemappedEpisodes(headers, episodeItems) } } @@ -396,7 +415,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ) ?: continue if (mapped.season == season && mapped.episode == episode) continue - val ids = parseIds(item.id) ?: continue + val ids = resolveHistoryIds(item) ?: continue remappedShowDtos += TraktHistoryShowRequestDto( title = item.name.takeIf { it.isNotBlank() }, year = parseYear(item.releaseInfo), @@ -422,10 +441,11 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ) runCatching { - httpPostJsonWithHeaders( + httpRequestRaw( + method = "POST", url = "$BASE_URL/sync/history/remove", body = retryBody, - headers = headers, + headers = jsonHeaders(headers), ) }.onFailure { e -> if (e is CancellationException) throw e @@ -467,6 +487,53 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { return null } + private suspend fun resolveHistoryIds(item: WatchedItem): TraktSyncIdsDto? { + val ids = parseIds(item.id) ?: return null + return enrichWithImdb(ids = ids, contentType = item.type) + } + + private suspend fun enrichWithImdb( + ids: TraktSyncIdsDto, + contentType: String, + ): TraktSyncIdsDto { + if (ids.tmdb == null || !ids.imdb.isNullOrBlank()) return ids + val imdb = runCatching { + TmdbService.tmdbToImdb(tmdbId = ids.tmdb, mediaType = contentType) + }.getOrNull() ?: return ids + return ids.copy(imdb = imdb) + } + + private fun jsonHeaders(headers: Map): Map = + mapOf( + "Accept" to "application/json", + "Content-Type" to "application/json", + ) + headers + + private fun hasSuccessfulHistoryAdd(body: TraktHistoryAddResponseDto?): Boolean { + val added = body?.added ?: return false + val addedCount = (added.movies ?: 0) + + (added.episodes ?: 0) + + (added.shows ?: 0) + + (added.seasons ?: 0) + return addedCount > 0 + } + + private fun hasHistoryAddNotFound(body: TraktHistoryAddResponseDto?): Boolean { + val notFound = body?.notFound ?: return false + return !notFound.movies.isNullOrEmpty() || + !notFound.shows.isNullOrEmpty() || + !notFound.seasons.isNullOrEmpty() || + !notFound.episodes.isNullOrEmpty() + } + + private fun hasHistoryRemoveNotFound(body: TraktHistoryRemoveResponseDto?): Boolean { + val notFound = body?.notFound ?: return false + return !notFound.movies.isNullOrEmpty() || + !notFound.shows.isNullOrEmpty() || + !notFound.seasons.isNullOrEmpty() || + !notFound.episodes.isNullOrEmpty() + } + private val yearRegex = Regex("(19|20)\\d{2}") private fun parseYear(value: String?): Int? { if (value.isNullOrBlank()) return null @@ -581,6 +648,34 @@ private data class TraktHistoryAddRequestDto( @SerialName("shows") val shows: List? = null, ) +@Serializable +private data class TraktHistoryAddResponseDto( + @SerialName("added") val added: TraktHistoryMutationCountDto? = null, + @SerialName("not_found") val notFound: TraktHistoryNotFoundDto? = null, +) + +@Serializable +private data class TraktHistoryRemoveResponseDto( + @SerialName("deleted") val deleted: TraktHistoryMutationCountDto? = null, + @SerialName("not_found") val notFound: TraktHistoryNotFoundDto? = null, +) + +@Serializable +private data class TraktHistoryMutationCountDto( + @SerialName("movies") val movies: Int? = null, + @SerialName("episodes") val episodes: Int? = null, + @SerialName("shows") val shows: Int? = null, + @SerialName("seasons") val seasons: Int? = null, +) + +@Serializable +private data class TraktHistoryNotFoundDto( + @SerialName("movies") val movies: List? = null, + @SerialName("shows") val shows: List? = null, + @SerialName("seasons") val seasons: List? = null, + @SerialName("episodes") val episodes: List? = null, +) + @Serializable private data class TraktHistoryMovieRequestDto( @SerialName("title") val title: String? = null, @@ -609,6 +704,13 @@ private data class TraktHistoryEpisodeRequestDto( @SerialName("watched_at") val watchedAt: String? = null, ) +@Serializable +private data class TraktSyncEpisodeDto( + @SerialName("season") val season: Int? = null, + @SerialName("number") val number: Int? = null, + @SerialName("ids") val ids: TraktSyncIdsDto? = null, +) + // ── DTOs for delete (POST /sync/history/remove) ───────────────────────── @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 52274d18..fba8ae33 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 @@ -381,8 +381,25 @@ object WatchProgressRepository { if (videoIds.isEmpty()) return if (shouldUseTraktProgress()) { + val entriesToRemove = currentEntries().filter { entry -> entry.videoId in videoIds } videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval) publish() + if (entriesToRemove.isNotEmpty()) { + syncScope.launch { + entriesToRemove.forEach { entry -> + runCatching { + TraktProgressRepository.removeProgress( + contentId = entry.parentMetaId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + ) + }.onFailure { error -> + if (error is CancellationException) throw error + log.e(error) { "Failed to clear Trakt playback progress for ${entry.videoId}" } + } + } + } + } return } @@ -464,6 +481,22 @@ object WatchProgressRepository { return currentEntries().continueWatchingEntries() } + fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean = false) { + ensureLoaded() + if (!shouldUseTraktProgress()) return + syncScope.launch { + runCatching { + TraktProgressRepository.refreshEpisodeProgress( + contentId = contentId, + forceRefresh = forceRefresh, + ) + }.onFailure { error -> + if (error is CancellationException) throw error + log.w { "Failed to refresh Trakt episode progress for $contentId: ${error.message}" } + } + } + } + private fun upsert( session: WatchProgressPlaybackSession, snapshot: PlayerPlaybackSnapshot, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index b7226664..2ebf8447 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -17,6 +17,12 @@ import kotlin.test.assertTrue class HomeScreenTest { + @Test + fun `home trakt continue watching candidate limits match TV`() { + assertEquals(300, HomeContinueWatchingMaxRecentProgressItems) + assertEquals(32, HomeNextUpInitialResolutionLimit) + } + @Test fun `build home continue watching items removes duplicate video ids`() { val inProgress = progressEntry(