diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 43c37847..e7da572f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -13,7 +13,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaDetailsRepository -import com.nuvio.app.features.details.nextReleasedEpisodeAfter +import com.nuvio.app.features.details.sortedPlayableEpisodes import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeContinueWatchingSection import com.nuvio.app.features.home.components.HomeEmptyStateCard @@ -21,16 +21,20 @@ import com.nuvio.app.features.home.components.HomeHeroReservedSpace import com.nuvio.app.features.home.components.HomeHeroSection import com.nuvio.app.features.home.components.HomeSkeletonHero import com.nuvio.app.features.home.components.HomeSkeletonRow +import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem +import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.domain.WatchingContentRef +import com.nuvio.app.features.watching.domain.buildPlaybackVideoId +import com.nuvio.app.features.watching.domain.isReleasedBy @Composable fun HomeScreen( @@ -55,11 +59,28 @@ fun HomeScreen( val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() + val isTraktAuthenticated by remember { + TraktAuthRepository.ensureLoaded() + TraktAuthRepository.isAuthenticated + }.collectAsStateWithLifecycle() - val latestCompletedBySeries = remember(watchProgressUiState.entries, watchedUiState.items, continueWatchingPreferences.upNextFromFurthestEpisode) { + val effectiveWatchProgressEntries = remember(watchProgressUiState.entries, isTraktAuthenticated) { + if (!isTraktAuthenticated) { + watchProgressUiState.entries + } else { + val cutoffMs = WatchProgressClock.nowEpochMs() - (TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT.toLong() * 24L * 60L * 60L * 1000L) + watchProgressUiState.entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs } + } + } + + val effectiveWatchedItems = remember(watchedUiState.items, isTraktAuthenticated) { + if (isTraktAuthenticated) emptyList() else watchedUiState.items + } + + val latestCompletedBySeries = remember(effectiveWatchProgressEntries, effectiveWatchedItems, continueWatchingPreferences.upNextFromFurthestEpisode) { WatchingState.latestCompletedBySeries( - progressEntries = watchProgressUiState.entries, - watchedItems = watchedUiState.items, + progressEntries = effectiveWatchProgressEntries, + watchedItems = effectiveWatchedItems, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, ) } @@ -74,11 +95,11 @@ fun HomeScreen( } } val visibleContinueWatchingEntries = remember( - watchProgressUiState.entries, + effectiveWatchProgressEntries, latestCompletedBySeries, ) { WatchingState.visibleContinueWatchingEntries( - progressEntries = watchProgressUiState.entries, + progressEntries = effectiveWatchProgressEntries, latestCompletedBySeries = latestCompletedBySeries, ) } @@ -145,6 +166,7 @@ fun HomeScreen( seasonNumber = completedEntry.seasonNumber, episodeNumber = completedEntry.episodeNumber, todayIsoDate = todayIsoDate, + showUnairedNextUp = isTraktAuthenticated, ) ?: return@forEach resolvedItems[completedEntry.content.id] = completedEntry.markedAtEpochMs to completedEntry.toContinueWatchingSeed(meta).toUpNextContinueWatchingItem(nextEpisode) @@ -277,6 +299,7 @@ fun HomeScreen( } private const val HOME_CATALOG_PREVIEW_LIMIT = 18 +private const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT = 60 internal fun buildHomeContinueWatchingItems( visibleEntries: List, @@ -340,3 +363,37 @@ private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app. lastUpdatedEpochMs = markedAtEpochMs, isCompleted = true, ) + +private fun com.nuvio.app.features.details.MetaDetails.nextReleasedEpisodeAfter( + seasonNumber: Int?, + episodeNumber: Int?, + todayIsoDate: String, + showUnairedNextUp: Boolean, +): com.nuvio.app.features.details.MetaVideo? { + val content = WatchingContentRef(type = type, id = id) + val watchedVideoId = buildPlaybackVideoId( + content = content, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) + + val ordered = sortedPlayableEpisodes() + .dropWhile { episode -> + buildPlaybackVideoId( + content = content, + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = episode.id, + ) != watchedVideoId + } + .drop(1) + .filter { episode -> (episode.season ?: 0) > 0 } + + if (showUnairedNextUp) { + return ordered.firstOrNull() + } + + return ordered.firstOrNull { episode -> + isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.released) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 3d6ddc2b..5bc0de6c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -36,6 +36,7 @@ import com.nuvio.app.features.details.MetaVideo 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.trakt.TraktScrobbleRepository import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -116,6 +117,18 @@ fun PlayerScreen( } var lastProgressPersistEpochMs by remember(activeSourceUrl) { mutableStateOf(0L) } var previousIsPlaying by remember(activeSourceUrl) { mutableStateOf(false) } + var hasRequestedScrobbleStartForCurrentItem by remember( + activeSourceUrl, + activeVideoId, + activeSeasonNumber, + activeEpisodeNumber, + ) { mutableStateOf(false) } + var hasSentCompletionScrobbleForCurrentItem by remember( + activeSourceUrl, + activeVideoId, + activeSeasonNumber, + activeEpisodeNumber, + ) { mutableStateOf(false) } val backdropArtwork = background ?: poster val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null @@ -179,7 +192,65 @@ fun PlayerScreen( ) } + fun currentPlaybackProgressPercent(snapshot: PlayerPlaybackSnapshot = playbackSnapshot): Float { + val duration = snapshot.durationMs.takeIf { it > 0L } ?: return 0f + return ((snapshot.positionMs.toFloat() / duration.toFloat()) * 100f) + .coerceIn(0f, 100f) + } + + fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem( + contentType = contentType ?: parentMetaType, + parentMetaId = parentMetaId, + title = title, + seasonNumber = activeSeasonNumber, + episodeNumber = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, + ) + + fun emitTraktScrobbleStart() { + val item = currentTraktScrobbleItem() ?: return + if (hasRequestedScrobbleStartForCurrentItem) return + hasRequestedScrobbleStartForCurrentItem = true + + scope.launch { + TraktScrobbleRepository.scrobbleStart( + item = item, + progressPercent = currentPlaybackProgressPercent(), + ) + } + } + + fun emitTraktScrobbleStop(progressPercent: Float? = null) { + val item = currentTraktScrobbleItem() ?: return + val provided = progressPercent + if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return + + val percent = provided ?: currentPlaybackProgressPercent() + scope.launch { + TraktScrobbleRepository.scrobbleStop( + item = item, + progressPercent = percent, + ) + } + hasRequestedScrobbleStartForCurrentItem = false + } + + fun emitStopScrobbleForCurrentProgress() { + val progressPercent = currentPlaybackProgressPercent() + if (progressPercent >= 1f && progressPercent < 80f) { + emitTraktScrobbleStop(progressPercent) + hasSentCompletionScrobbleForCurrentItem = false + return + } + + if (progressPercent >= 80f && !hasSentCompletionScrobbleForCurrentItem) { + hasSentCompletionScrobbleForCurrentItem = true + emitTraktScrobbleStop(progressPercent) + } + } + fun flushWatchProgress() { + emitStopScrobbleForCurrentProgress() WatchProgressRepository.flushPlaybackProgress( session = playbackSession, snapshot = playbackSnapshot, @@ -491,6 +562,7 @@ fun PlayerScreen( LaunchedEffect(playbackSnapshot.positionMs, playbackSnapshot.isPlaying, playbackSnapshot.isEnded, playbackSnapshot.durationMs) { if (playbackSnapshot.isEnded) { + hasSentCompletionScrobbleForCurrentItem = false flushWatchProgress() previousIsPlaying = false return@LaunchedEffect @@ -500,6 +572,10 @@ fun PlayerScreen( flushWatchProgress() } + if (!previousIsPlaying && playbackSnapshot.isPlaying) { + emitTraktScrobbleStart() + } + previousIsPlaying = playbackSnapshot.isPlaying if (!playbackSnapshot.isPlaying) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt new file mode 100644 index 00000000..b036b984 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt @@ -0,0 +1,52 @@ +package com.nuvio.app.features.trakt + +import kotlinx.serialization.Serializable + +@Serializable +internal data class TraktExternalIds( + val trakt: Int? = null, + val imdb: String? = null, + val tmdb: Int? = null, +) + +internal fun parseTraktContentIds(contentId: String?): TraktExternalIds { + if (contentId.isNullOrBlank()) return TraktExternalIds() + val raw = contentId.trim() + + if (raw.startsWith("tt")) { + return TraktExternalIds(imdb = raw.substringBefore(':')) + } + + if (raw.startsWith("tmdb:", ignoreCase = true)) { + return TraktExternalIds(tmdb = raw.substringAfter(':').toIntOrNull()) + } + + if (raw.startsWith("trakt:", ignoreCase = true)) { + return TraktExternalIds(trakt = raw.substringAfter(':').toIntOrNull()) + } + + val numeric = raw.substringBefore(':').toIntOrNull() + return if (numeric != null) { + TraktExternalIds(trakt = numeric) + } else { + TraktExternalIds() + } +} + +internal fun normalizeTraktContentId(ids: TraktExternalIds?, fallback: String? = null): String { + val imdb = ids?.imdb?.takeIf { it.isNotBlank() } + if (!imdb.isNullOrBlank()) return imdb + + val tmdb = ids?.tmdb + if (tmdb != null) return "tmdb:$tmdb" + + val trakt = ids?.trakt + if (trakt != null) return "trakt:$trakt" + + return fallback?.takeIf { it.isNotBlank() } ?: "" +} + +internal fun extractTraktYear(value: String?): Int? { + if (value.isNullOrBlank()) return null + return Regex("(\\d{4})").find(value)?.groupValues?.getOrNull(1)?.toIntOrNull() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt new file mode 100644 index 00000000..c5cea030 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -0,0 +1,368 @@ +package com.nuvio.app.features.trakt + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpGetTextWithHeaders +import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +private const val BASE_URL = "https://api.trakt.tv" +private const val PLAYBACK_SYNTHETIC_DURATION_MS = 100_000L +private const val HISTORY_LIMIT = 250 +private const val METADATA_FETCH_TIMEOUT_MS = 3_500L +private const val METADATA_FETCH_CONCURRENCY = 5 + +data class TraktProgressUiState( + val entries: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null, +) + +object TraktProgressRepository { + private val log = Logger.withTag("TraktProgress") + private val json = Json { ignoreUnknownKeys = true } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _uiState = MutableStateFlow(TraktProgressUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + + fun ensureLoaded() { + if (hasLoaded) return + hasLoaded = true + } + + fun onProfileChanged() { + hasLoaded = false + _uiState.value = TraktProgressUiState() + ensureLoaded() + } + + fun clearLocalState() { + hasLoaded = false + _uiState.value = TraktProgressUiState() + } + + fun refreshAsync() { + scope.launch { + refreshNow() + } + } + + suspend fun refreshNow() { + ensureLoaded() + val headers = TraktAuthRepository.authorizedHeaders() + if (headers == null) { + _uiState.value = TraktProgressUiState() + return + } + + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + + val snapshot = runCatching { + fetchSnapshot(headers) + }.onFailure { error -> + if (error is CancellationException) throw error + log.w { "Failed to refresh Trakt progress: ${error.message}" } + }.getOrNull() + + if (snapshot == null) { + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "Failed to load Trakt progress") + return + } + + _uiState.value = snapshot.copy(isLoading = false, errorMessage = null) + } + + fun applyOptimisticProgress(entry: WatchProgressEntry) { + if (!TraktAuthRepository.isAuthenticated.value) return + val current = _uiState.value.entries.associateBy { it.videoId }.toMutableMap() + val existing = current[entry.videoId] + if (existing == null || entry.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs) { + current[entry.videoId] = entry + } + _uiState.value = _uiState.value.copy(entries = current.values.sortedByDescending { it.lastUpdatedEpochMs }) + } + + fun applyOptimisticRemoval(videoId: String) { + if (!TraktAuthRepository.isAuthenticated.value) return + if (videoId.isBlank()) return + val filtered = _uiState.value.entries.filterNot { it.videoId == videoId } + _uiState.value = _uiState.value.copy(entries = filtered) + } + + private suspend fun fetchSnapshot(headers: Map): TraktProgressUiState = withContext(Dispatchers.Default) { + val moviesPayload = httpGetTextWithHeaders( + url = "$BASE_URL/sync/playback/movies", + headers = headers, + ) + val episodesPayload = httpGetTextWithHeaders( + url = "$BASE_URL/sync/playback/episodes", + headers = headers, + ) + val historyPayload = httpGetTextWithHeaders( + url = "$BASE_URL/sync/history/episodes?limit=$HISTORY_LIMIT", + headers = headers, + ) + val movieHistoryPayload = httpGetTextWithHeaders( + url = "$BASE_URL/sync/history/movies?limit=$HISTORY_LIMIT", + headers = headers, + ) + + val moviePlayback = json.decodeFromString>(moviesPayload) + val episodePlayback = json.decodeFromString>(episodesPayload) + val episodeHistory = json.decodeFromString>(historyPayload) + val movieHistory = json.decodeFromString>(movieHistoryPayload) + + val inProgressMovies = moviePlayback.mapIndexedNotNull { index, item -> + mapPlaybackMovie(item = item, fallbackIndex = index) + } + val inProgressEpisodes = episodePlayback.mapIndexedNotNull { index, item -> + mapPlaybackEpisode(item = item, fallbackIndex = index) + } + + val completedEpisodes = episodeHistory + .mapIndexedNotNull { index, item -> mapHistoryEpisode(item = item, fallbackIndex = index) } + .distinctBy { entry -> entry.videoId } + val completedMovies = movieHistory + .mapIndexedNotNull { index, item -> mapHistoryMovie(item = item, fallbackIndex = index) } + .distinctBy { entry -> entry.videoId } + + val mergedByVideoId = linkedMapOf() + (inProgressMovies + inProgressEpisodes + completedEpisodes + completedMovies).forEach { entry -> + val existing = mergedByVideoId[entry.videoId] + if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) { + mergedByVideoId[entry.videoId] = entry + } + } + + val hydrated = hydrateEntriesFromAddonMeta(mergedByVideoId.values.toList()) + + TraktProgressUiState( + entries = hydrated.sortedByDescending { it.lastUpdatedEpochMs }, + ) + } + + private suspend fun hydrateEntriesFromAddonMeta( + entries: List, + ): List = coroutineScope { + if (entries.isEmpty()) return@coroutineScope entries + + val uniqueContent = entries + .map { entry -> entry.parentMetaType to entry.parentMetaId } + .distinct() + + val semaphore = Semaphore(METADATA_FETCH_CONCURRENCY) + val metadataByContent = uniqueContent + .map { (metaType, metaId) -> + async { + semaphore.withPermit { + val normalizedType = when (metaType.lowercase()) { + "movie", "film" -> "movie" + else -> "series" + } + val meta = withTimeoutOrNull(METADATA_FETCH_TIMEOUT_MS) { + MetaDetailsRepository.fetch(type = normalizedType, id = metaId) + } + (metaType to metaId) to meta + } + } + } + .awaitAll() + .toMap() + + entries.map { entry -> + val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry + val episode = if (entry.seasonNumber != null && entry.episodeNumber != null) { + meta.videos.firstOrNull { video -> + video.season == entry.seasonNumber && video.episode == entry.episodeNumber + } + } else { + null + } + + entry.copy( + title = entry.title.takeIf { it.isNotBlank() } ?: meta.name, + logo = entry.logo ?: meta.logo, + poster = entry.poster ?: meta.poster, + background = entry.background ?: meta.background, + episodeTitle = entry.episodeTitle ?: episode?.title, + episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail, + pauseDescription = entry.pauseDescription + ?: episode?.overview + ?: meta.description, + ) + } + } + + private fun mapPlaybackMovie(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? { + val movie = item.movie ?: return null + val parentMetaId = normalizeTraktContentId(movie.ids, fallback = movie.title) + if (parentMetaId.isBlank()) return null + + val progressFraction = ((item.progress ?: 0f).coerceIn(0f, 100f) / 100f) + val positionMs = (PLAYBACK_SYNTHETIC_DURATION_MS * progressFraction).toLong() + + return WatchProgressEntry( + contentType = "movie", + parentMetaId = parentMetaId, + parentMetaType = "movie", + videoId = parentMetaId, + title = movie.title ?: parentMetaId, + lastPositionMs = positionMs, + durationMs = PLAYBACK_SYNTHETIC_DURATION_MS, + lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), + isCompleted = false, + ) + } + + private fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? { + val show = item.show ?: return null + val episode = item.episode ?: return null + val season = episode.season ?: return null + val number = episode.number ?: return null + + val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title) + if (parentMetaId.isBlank()) return null + + val progressFraction = ((item.progress ?: 0f).coerceIn(0f, 100f) / 100f) + val positionMs = (PLAYBACK_SYNTHETIC_DURATION_MS * progressFraction).toLong() + + return WatchProgressEntry( + contentType = "series", + parentMetaId = parentMetaId, + parentMetaType = "series", + videoId = buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = season, + episodeNumber = number, + fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" }, + ), + title = show.title ?: parentMetaId, + seasonNumber = season, + episodeNumber = number, + episodeTitle = episode.title, + lastPositionMs = positionMs, + durationMs = PLAYBACK_SYNTHETIC_DURATION_MS, + lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), + isCompleted = false, + ) + } + + private fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? { + val show = item.show ?: return null + val episode = item.episode ?: return null + val season = episode.season ?: return null + val number = episode.number ?: return null + + val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title) + if (parentMetaId.isBlank()) return null + + return WatchProgressEntry( + contentType = "series", + parentMetaId = parentMetaId, + parentMetaType = "series", + videoId = buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = season, + episodeNumber = number, + fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" }, + ), + title = show.title ?: parentMetaId, + seasonNumber = season, + episodeNumber = number, + episodeTitle = episode.title, + lastPositionMs = 1L, + durationMs = 1L, + lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), + isCompleted = true, + ) + } + + private fun mapHistoryMovie(item: TraktHistoryMovieItem, fallbackIndex: Int): WatchProgressEntry? { + val movie = item.movie ?: return null + val parentMetaId = normalizeTraktContentId(movie.ids, fallback = movie.title) + if (parentMetaId.isBlank()) return null + + return WatchProgressEntry( + contentType = "movie", + parentMetaId = parentMetaId, + parentMetaType = "movie", + videoId = parentMetaId, + title = movie.title ?: parentMetaId, + lastPositionMs = 1L, + durationMs = 1L, + lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), + isCompleted = true, + ) + } + + private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long { + val compactDigits = isoDate + ?.filter(Char::isDigit) + ?.take(14) + ?.takeIf { it.length >= 8 } + ?.padEnd(14, '0') + ?.toLongOrNull() + if (compactDigits != null) return compactDigits + + return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L) + } +} + +@Serializable +private data class TraktPlaybackItem( + @SerialName("id") val id: Long? = null, + @SerialName("progress") val progress: Float? = null, + @SerialName("paused_at") val pausedAt: String? = null, + @SerialName("movie") val movie: TraktMedia? = null, + @SerialName("show") val show: TraktMedia? = null, + @SerialName("episode") val episode: TraktEpisode? = null, +) + +@Serializable +private data class TraktHistoryEpisodeItem( + @SerialName("watched_at") val watchedAt: String? = null, + @SerialName("show") val show: TraktMedia? = null, + @SerialName("episode") val episode: TraktEpisode? = null, +) + +@Serializable +private data class TraktHistoryMovieItem( + @SerialName("watched_at") val watchedAt: String? = null, + @SerialName("movie") val movie: TraktMedia? = null, +) + +@Serializable +private data class TraktMedia( + @SerialName("title") val title: String? = null, + @SerialName("ids") val ids: TraktExternalIds? = null, +) + +@Serializable +private data class TraktEpisode( + @SerialName("title") val title: String? = null, + @SerialName("season") val season: Int? = null, + @SerialName("number") val number: Int? = null, + @SerialName("ids") val ids: TraktExternalIds? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt new file mode 100644 index 00000000..8458be05 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt @@ -0,0 +1,234 @@ +package com.nuvio.app.features.trakt + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpPostJsonWithHeaders +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.math.abs + +private const val BASE_URL = "https://api.trakt.tv" +private const val APP_VERSION = "nuvio-compose" + +internal sealed interface TraktScrobbleItem { + val itemKey: String + + data class Movie( + val title: String?, + val year: Int?, + val ids: TraktExternalIds, + ) : TraktScrobbleItem { + override val itemKey: String = + "movie:${ids.imdb ?: ids.tmdb ?: ids.trakt ?: title.orEmpty()}:${year ?: 0}" + } + + data class Episode( + val showTitle: String?, + val showYear: Int?, + val showIds: TraktExternalIds, + val season: Int, + val number: Int, + val episodeTitle: String?, + ) : TraktScrobbleItem { + override val itemKey: String = + "episode:${showIds.imdb ?: showIds.tmdb ?: showIds.trakt ?: showTitle.orEmpty()}:$season:$number" + } +} + +internal object TraktScrobbleRepository { + private data class ScrobbleStamp( + val action: String, + val itemKey: String, + val progress: Float, + val timestampMs: Long, + ) + + private val log = Logger.withTag("TraktScrobble") + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + private var lastScrobbleStamp: ScrobbleStamp? = null + private val minSendIntervalMs = 8_000L + private val progressWindow = 1.5f + + suspend fun scrobbleStart(item: TraktScrobbleItem, progressPercent: Float) { + sendScrobble(action = "start", item = item, progressPercent = progressPercent) + } + + suspend fun scrobbleStop(item: TraktScrobbleItem, progressPercent: Float) { + sendScrobble(action = "stop", item = item, progressPercent = progressPercent) + } + + fun buildItem( + contentType: String, + parentMetaId: String, + title: String?, + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, + releaseInfo: String? = null, + ): TraktScrobbleItem? { + val normalizedType = contentType.trim().lowercase() + val ids = parseTraktContentIds(parentMetaId) + val parsedYear = extractTraktYear(releaseInfo) + + return if ( + normalizedType in listOf("series", "tv", "show", "tvshow") && + seasonNumber != null && + episodeNumber != null + ) { + TraktScrobbleItem.Episode( + showTitle = title, + showYear = parsedYear, + showIds = ids, + season = seasonNumber, + number = episodeNumber, + episodeTitle = episodeTitle, + ) + } else { + TraktScrobbleItem.Movie( + title = title, + year = parsedYear, + ids = ids, + ) + } + } + + private suspend fun sendScrobble( + action: String, + item: TraktScrobbleItem, + progressPercent: Float, + ) { + val headers = TraktAuthRepository.authorizedHeaders() ?: return + val clampedProgress = progressPercent.coerceIn(0f, 100f) + if (shouldSkip(action, item.itemKey, clampedProgress)) return + + val requestBody = json.encodeToString(buildRequestBody(item, clampedProgress)) + val result = runCatching { + httpPostJsonWithHeaders( + url = "$BASE_URL/scrobble/$action", + body = requestBody, + headers = headers, + ) + true + }.recoverCatching { error -> + if (error is CancellationException) throw error + val isConflict = error.message?.contains("HTTP 409") == true + if (isConflict) { + true + } else { + throw error + } + } + + val wasSent = result.onFailure { error -> + if (error is CancellationException) throw error + log.w { "Failed Trakt scrobble $action: ${error.message}" } + }.getOrDefault(false) + + if (!wasSent) return + + lastScrobbleStamp = ScrobbleStamp( + action = action, + itemKey = item.itemKey, + progress = clampedProgress, + timestampMs = TraktPlatformClock.nowEpochMs(), + ) + + if (action == "stop") { + runCatching { TraktProgressRepository.refreshNow() } + .onFailure { error -> + if (error is CancellationException) throw error + log.w { "Failed to refresh Trakt progress after stop: ${error.message}" } + } + } + } + + private fun buildRequestBody( + item: TraktScrobbleItem, + clampedProgress: Float, + ): TraktScrobbleRequest { + return when (item) { + is TraktScrobbleItem.Movie -> TraktScrobbleRequest( + movie = TraktMovieBody( + title = item.title, + year = item.year, + ids = TraktIdsBody( + trakt = item.ids.trakt, + imdb = item.ids.imdb, + tmdb = item.ids.tmdb, + ), + ), + progress = clampedProgress, + appVersion = APP_VERSION, + ) + + is TraktScrobbleItem.Episode -> TraktScrobbleRequest( + show = TraktShowBody( + title = item.showTitle, + year = item.showYear, + ids = TraktIdsBody( + trakt = item.showIds.trakt, + imdb = item.showIds.imdb, + tmdb = item.showIds.tmdb, + ), + ), + episode = TraktEpisodeBody( + title = item.episodeTitle, + season = item.season, + number = item.number, + ), + progress = clampedProgress, + appVersion = APP_VERSION, + ) + } + } + + private fun shouldSkip(action: String, itemKey: String, progress: Float): Boolean { + val last = lastScrobbleStamp ?: return false + val now = TraktPlatformClock.nowEpochMs() + val isSameWindow = now - last.timestampMs < minSendIntervalMs + val isSameAction = last.action == action + val isSameItem = last.itemKey == itemKey + val isNearProgress = abs(last.progress - progress) <= progressWindow + return isSameWindow && isSameAction && isSameItem && isNearProgress + } +} + +@Serializable +private data class TraktScrobbleRequest( + @SerialName("movie") val movie: TraktMovieBody? = null, + @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 +private data class TraktMovieBody( + @SerialName("title") val title: String? = null, + @SerialName("year") val year: Int? = null, + @SerialName("ids") val ids: TraktIdsBody? = null, +) + +@Serializable +private data class TraktShowBody( + @SerialName("title") val title: String? = null, + @SerialName("year") val year: Int? = null, + @SerialName("ids") val ids: TraktIdsBody? = null, +) + +@Serializable +private data class TraktEpisodeBody( + @SerialName("title") val title: String? = null, + @SerialName("season") val season: Int? = null, + @SerialName("number") val number: Int? = null, +) + +@Serializable +private data class TraktIdsBody( + @SerialName("trakt") val trakt: Int? = null, + @SerialName("imdb") val imdb: String? = null, + @SerialName("tmdb") val tmdb: Int? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index fe1c0449..718ab850 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -5,12 +5,15 @@ import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktProgressRepository import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.sync.ProgressSyncAdapter import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -29,20 +32,50 @@ object WatchProgressRepository { private var entriesByVideoId: MutableMap = mutableMapOf() internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter + init { + syncScope.launch { + TraktAuthRepository.isAuthenticated.collectLatest { authenticated -> + if (authenticated) { + runCatching { TraktProgressRepository.refreshNow() } + .onFailure { error -> log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } } + } + publish() + } + } + + syncScope.launch { + TraktProgressRepository.uiState.collectLatest { + if (TraktAuthRepository.isAuthenticated.value) { + publish() + } + } + } + } + fun ensureLoaded() { + TraktAuthRepository.ensureLoaded() + TraktProgressRepository.ensureLoaded() if (hasLoaded) return loadFromDisk(ProfileRepository.activeProfileId) + if (TraktAuthRepository.isAuthenticated.value) { + TraktProgressRepository.refreshAsync() + } } fun onProfileChanged(profileId: Int) { if (profileId == currentProfileId && hasLoaded) return loadFromDisk(profileId) + TraktProgressRepository.onProfileChanged() + if (TraktAuthRepository.isAuthenticated.value) { + TraktProgressRepository.refreshAsync() + } } fun clearLocalState() { hasLoaded = false currentProfileId = 1 entriesByVideoId.clear() + TraktProgressRepository.clearLocalState() _uiState.value = WatchProgressUiState() } @@ -62,6 +95,14 @@ object WatchProgressRepository { suspend fun pullFromServer(profileId: Int) { currentProfileId = profileId + + if (TraktAuthRepository.isAuthenticated.value) { + runCatching { TraktProgressRepository.refreshNow() } + .onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } } + publish() + return + } + runCatching { val serverEntries = syncAdapter.pull(profileId = profileId) @@ -178,6 +219,13 @@ object WatchProgressRepository { fun clearProgress(videoIds: Collection) { ensureLoaded() if (videoIds.isEmpty()) return + + if (shouldUseTraktProgress()) { + videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval) + publish() + return + } + val removedEntries = videoIds.mapNotNull { videoId -> entriesByVideoId.remove(videoId) } @@ -190,17 +238,21 @@ object WatchProgressRepository { fun progressForVideo(videoId: String): WatchProgressEntry? { ensureLoaded() - return entriesByVideoId[videoId] + return if (shouldUseTraktProgress()) { + TraktProgressRepository.uiState.value.entries.firstOrNull { it.videoId == videoId } + } else { + entriesByVideoId[videoId] + } } fun resumeEntryForSeries(metaId: String): WatchProgressEntry? { ensureLoaded() - return entriesByVideoId.values.toList().resumeEntryForSeries(metaId) + return currentEntries().resumeEntryForSeries(metaId) } fun continueWatching(): List { ensureLoaded() - return entriesByVideoId.values.toList().continueWatchingEntries() + return currentEntries().continueWatchingEntries() } private fun upsert( @@ -245,6 +297,9 @@ object WatchProgressRepository { ) entriesByVideoId[session.videoId] = entry + if (shouldUseTraktProgress()) { + TraktProgressRepository.applyOptimisticProgress(entry) + } publish() if (persist) persist() pushScrobbleToServer(entry) @@ -252,6 +307,7 @@ object WatchProgressRepository { } private fun pushScrobbleToServer(entry: WatchProgressEntry) { + if (shouldUseTraktProgress()) return syncScope.launch { runCatching { val profileId = ProfileRepository.activeProfileId @@ -263,6 +319,7 @@ object WatchProgressRepository { } private fun pushDeleteToServer(entries: Collection) { + if (shouldUseTraktProgress()) return syncScope.launch { runCatching { if (entries.isEmpty()) return@runCatching @@ -275,8 +332,9 @@ object WatchProgressRepository { } private fun publish() { + val entries = currentEntries() _uiState.value = WatchProgressUiState( - entries = entriesByVideoId.values.toList().sortedByDescending { it.lastUpdatedEpochMs }, + entries = entries.sortedByDescending { it.lastUpdatedEpochMs }, ) } @@ -287,4 +345,14 @@ object WatchProgressRepository { ) } + private fun shouldUseTraktProgress(): Boolean = TraktAuthRepository.isAuthenticated.value + + private fun currentEntries(): List { + return if (shouldUseTraktProgress()) { + TraktProgressRepository.uiState.value.entries + } else { + entriesByVideoId.values.toList() + } + } + }