mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-01 00:59:27 +00:00
Merge Trakt TV parity
This commit is contained in:
commit
fbcd40a72c
8 changed files with 904 additions and 114 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue