Merge Trakt TV parity

This commit is contained in:
tapframe 2026-05-25 13:50:30 +05:30
commit fbcd40a72c
8 changed files with 904 additions and 114 deletions

View file

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

View file

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

View file

@ -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<Job?>(null) }
var accumulatedSeekResetJob by remember { mutableStateOf<Job?>(null) }
var seekProgressSyncJob by remember { mutableStateOf<Job?>(null) }
var accumulatedSeekState by remember { mutableStateOf<PlayerAccumulatedSeekState?>(null) }
var initialLoadCompleted by remember(activeSourceUrl) { mutableStateOf(false) }
var speedBoostRestoreSpeed by remember(activeSourceUrl) { mutableStateOf<Float?>(null) }
@ -270,6 +274,12 @@ fun PlayerScreen(
activeSeasonNumber,
activeEpisodeNumber,
) { mutableStateOf(false) }
var currentTraktScrobbleItem by remember(
activeSourceUrl,
activeVideoId,
activeSeasonNumber,
activeEpisodeNumber,
) { mutableStateOf<TraktScrobbleItem?>(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 },

View file

@ -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<WatchProgressEntry> = emptyList(),
@ -64,6 +75,44 @@ object TraktProgressRepository {
private var refreshRequestId: Long = 0L
private val refreshJobMutex = Mutex()
private var inFlightRefresh: Deferred<Unit>? = 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<String, Long>()
private val episodeProgressLastAttemptAtMsByContentId = mutableMapOf<String, Long>()
private val inFlightEpisodeProgressContentIds = mutableSetOf<String>()
private var watchedShowEpisodesById: Map<String, Set<Pair<Int, Int>>> = emptyMap()
private var showIdToTraktPathId: Map<String, String> = emptyMap()
private var showIdSiblingsMap: Map<String, Set<String>> = 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<String, String>): Boolean {
val activities = runCatching {
json.decodeFromString<TraktLastActivitiesResponse>(
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<String, String>): List<WatchProgressEntry> = 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<List<TraktHistoryEpisodeItem>>(historyPayload)
val movieHistory = json.decodeFromString<List<TraktHistoryMovieItem>>(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<String, String>,
): List<WatchProgressEntry> {
val cutoffMs = recentWatchCutoffMs()
val maxPages = if (isAllHistoryWindow()) HISTORY_MAX_PAGES_ALL else HISTORY_MAX_PAGES
val resultsByShow = linkedMapOf<String, WatchProgressEntry>()
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<List<TraktHistoryEpisodeItem>>(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<String, String>,
): List<WatchProgressEntry> {
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<List<TraktHistoryMovieItem>>(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<List<TraktWatchedShowItem>>(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<TraktWatchedShowItem>) {
val siblingsMap = mutableMapOf<String, MutableSet<String>>()
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<String, MutableSet<Pair<Int, Int>>>()
val pathIdsByKey = mutableMapOf<String, String>()
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<Pair<Int, Int>>()
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<WatchProgressEntry>,
): List<WatchProgressEntry> {
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<String, String>,
contentId: String,
): List<WatchProgressEntry> = 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<TraktShowProgressResponse>(response.body)
}.getOrNull() ?: return@withContext emptyList()
val completed = mutableListOf<WatchProgressEntry>()
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<String, String>,
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<List<TraktSearchResult>>(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<String> {
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<WatchProgressEntry>): List<WatchProgressEntry> {
val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>()
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<TraktShowProgressSeason>? = null,
)
@Serializable
private data class TraktShowProgressSeason(
@SerialName("number") val number: Int? = null,
@SerialName("episodes") val episodes: List<TraktShowProgressEpisode>? = 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,

View file

@ -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 { "<no-status-text>" })
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 { "<empty>" })
append(response.body.ifBlank { "<empty>" })
}
}
}.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 { "<no-status-text>" })
append('\n')
append("url=")
append(response.url)
append('\n')
append("headers=")
append(response.headers.formatForLog())
append('\n')
append("body=")
append(response.body.ifBlank { "<empty>" })
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 { "<no-status-text>" }}"
}
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 { "<no-status-text>" }}"
}
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<String, String>.redactedForLogs(): Map<String, String> =
@ -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

View file

@ -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<TraktHistoryMovieRequestDto>()
val shows = mutableListOf<TraktHistoryShowRequestDto>()
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<TraktHistoryAddResponseDto>(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<TraktHistoryMovieRequestDto>()
val shows = mutableListOf<TraktHistoryShowRequestDto>()
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<TraktHistoryRemoveResponseDto>(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<String, String>): Map<String, String> =
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<TraktHistoryShowRequestDto>? = 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<TraktSyncMediaDto>? = null,
@SerialName("shows") val shows: List<TraktSyncMediaDto>? = null,
@SerialName("seasons") val seasons: List<TraktHistorySeasonRequestDto>? = null,
@SerialName("episodes") val episodes: List<TraktSyncEpisodeDto>? = 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

View file

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

View file

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