init trakt scrobble and watch history

This commit is contained in:
tapframe 2026-04-01 15:31:51 +05:30
parent 14e03a7f4b
commit fff3a6eac4
6 changed files with 865 additions and 10 deletions

View file

@ -13,7 +13,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository 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.HomeCatalogRowSection
import com.nuvio.app.features.home.components.HomeContinueWatchingSection import com.nuvio.app.features.home.components.HomeContinueWatchingSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard 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.HomeHeroSection
import com.nuvio.app.features.home.components.HomeSkeletonHero import com.nuvio.app.features.home.components.HomeSkeletonHero
import com.nuvio.app.features.home.components.HomeSkeletonRow 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.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem 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.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watchprogress.toContinueWatchingItem
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.application.WatchingState
import com.nuvio.app.features.watching.domain.WatchingContentRef 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 @Composable
fun HomeScreen( fun HomeScreen(
@ -55,11 +59,28 @@ fun HomeScreen(
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState by WatchProgressRepository.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( WatchingState.latestCompletedBySeries(
progressEntries = watchProgressUiState.entries, progressEntries = effectiveWatchProgressEntries,
watchedItems = watchedUiState.items, watchedItems = effectiveWatchedItems,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
) )
} }
@ -74,11 +95,11 @@ fun HomeScreen(
} }
} }
val visibleContinueWatchingEntries = remember( val visibleContinueWatchingEntries = remember(
watchProgressUiState.entries, effectiveWatchProgressEntries,
latestCompletedBySeries, latestCompletedBySeries,
) { ) {
WatchingState.visibleContinueWatchingEntries( WatchingState.visibleContinueWatchingEntries(
progressEntries = watchProgressUiState.entries, progressEntries = effectiveWatchProgressEntries,
latestCompletedBySeries = latestCompletedBySeries, latestCompletedBySeries = latestCompletedBySeries,
) )
} }
@ -145,6 +166,7 @@ fun HomeScreen(
seasonNumber = completedEntry.seasonNumber, seasonNumber = completedEntry.seasonNumber,
episodeNumber = completedEntry.episodeNumber, episodeNumber = completedEntry.episodeNumber,
todayIsoDate = todayIsoDate, todayIsoDate = todayIsoDate,
showUnairedNextUp = isTraktAuthenticated,
) ?: return@forEach ) ?: return@forEach
resolvedItems[completedEntry.content.id] = resolvedItems[completedEntry.content.id] =
completedEntry.markedAtEpochMs to completedEntry.toContinueWatchingSeed(meta).toUpNextContinueWatchingItem(nextEpisode) 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 HOME_CATALOG_PREVIEW_LIMIT = 18
private const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT = 60
internal fun buildHomeContinueWatchingItems( internal fun buildHomeContinueWatchingItems(
visibleEntries: List<WatchProgressEntry>, visibleEntries: List<WatchProgressEntry>,
@ -340,3 +363,37 @@ private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.
lastUpdatedEpochMs = markedAtEpochMs, lastUpdatedEpochMs = markedAtEpochMs,
isCompleted = true, 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)
}
}

View file

@ -36,6 +36,7 @@ import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamLinkCacheRepository
import com.nuvio.app.features.streams.StreamsUiState 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.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository
@ -116,6 +117,18 @@ fun PlayerScreen(
} }
var lastProgressPersistEpochMs by remember(activeSourceUrl) { mutableStateOf(0L) } var lastProgressPersistEpochMs by remember(activeSourceUrl) { mutableStateOf(0L) }
var previousIsPlaying by remember(activeSourceUrl) { mutableStateOf(false) } 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 backdropArtwork = background ?: poster
val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs
val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null 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() { fun flushWatchProgress() {
emitStopScrobbleForCurrentProgress()
WatchProgressRepository.flushPlaybackProgress( WatchProgressRepository.flushPlaybackProgress(
session = playbackSession, session = playbackSession,
snapshot = playbackSnapshot, snapshot = playbackSnapshot,
@ -491,6 +562,7 @@ fun PlayerScreen(
LaunchedEffect(playbackSnapshot.positionMs, playbackSnapshot.isPlaying, playbackSnapshot.isEnded, playbackSnapshot.durationMs) { LaunchedEffect(playbackSnapshot.positionMs, playbackSnapshot.isPlaying, playbackSnapshot.isEnded, playbackSnapshot.durationMs) {
if (playbackSnapshot.isEnded) { if (playbackSnapshot.isEnded) {
hasSentCompletionScrobbleForCurrentItem = false
flushWatchProgress() flushWatchProgress()
previousIsPlaying = false previousIsPlaying = false
return@LaunchedEffect return@LaunchedEffect
@ -500,6 +572,10 @@ fun PlayerScreen(
flushWatchProgress() flushWatchProgress()
} }
if (!previousIsPlaying && playbackSnapshot.isPlaying) {
emitTraktScrobbleStart()
}
previousIsPlaying = playbackSnapshot.isPlaying previousIsPlaying = playbackSnapshot.isPlaying
if (!playbackSnapshot.isPlaying) { if (!playbackSnapshot.isPlaying) {

View file

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

View file

@ -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<WatchProgressEntry> = 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<TraktProgressUiState> = _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<String, String>): 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<List<TraktPlaybackItem>>(moviesPayload)
val episodePlayback = json.decodeFromString<List<TraktPlaybackItem>>(episodesPayload)
val episodeHistory = json.decodeFromString<List<TraktHistoryEpisodeItem>>(historyPayload)
val movieHistory = json.decodeFromString<List<TraktHistoryMovieItem>>(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<String, WatchProgressEntry>()
(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<WatchProgressEntry>,
): List<WatchProgressEntry> = 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,
)

View file

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

View file

@ -5,12 +5,15 @@ import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.player.PlayerPlaybackSnapshot
import com.nuvio.app.features.profiles.ProfileRepository 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.application.WatchingActions
import com.nuvio.app.features.watching.sync.ProgressSyncAdapter import com.nuvio.app.features.watching.sync.ProgressSyncAdapter
import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -29,20 +32,50 @@ object WatchProgressRepository {
private var entriesByVideoId: MutableMap<String, WatchProgressEntry> = mutableMapOf() private var entriesByVideoId: MutableMap<String, WatchProgressEntry> = mutableMapOf()
internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter 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() { fun ensureLoaded() {
TraktAuthRepository.ensureLoaded()
TraktProgressRepository.ensureLoaded()
if (hasLoaded) return if (hasLoaded) return
loadFromDisk(ProfileRepository.activeProfileId) loadFromDisk(ProfileRepository.activeProfileId)
if (TraktAuthRepository.isAuthenticated.value) {
TraktProgressRepository.refreshAsync()
}
} }
fun onProfileChanged(profileId: Int) { fun onProfileChanged(profileId: Int) {
if (profileId == currentProfileId && hasLoaded) return if (profileId == currentProfileId && hasLoaded) return
loadFromDisk(profileId) loadFromDisk(profileId)
TraktProgressRepository.onProfileChanged()
if (TraktAuthRepository.isAuthenticated.value) {
TraktProgressRepository.refreshAsync()
}
} }
fun clearLocalState() { fun clearLocalState() {
hasLoaded = false hasLoaded = false
currentProfileId = 1 currentProfileId = 1
entriesByVideoId.clear() entriesByVideoId.clear()
TraktProgressRepository.clearLocalState()
_uiState.value = WatchProgressUiState() _uiState.value = WatchProgressUiState()
} }
@ -62,6 +95,14 @@ object WatchProgressRepository {
suspend fun pullFromServer(profileId: Int) { suspend fun pullFromServer(profileId: Int) {
currentProfileId = profileId currentProfileId = profileId
if (TraktAuthRepository.isAuthenticated.value) {
runCatching { TraktProgressRepository.refreshNow() }
.onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } }
publish()
return
}
runCatching { runCatching {
val serverEntries = syncAdapter.pull(profileId = profileId) val serverEntries = syncAdapter.pull(profileId = profileId)
@ -178,6 +219,13 @@ object WatchProgressRepository {
fun clearProgress(videoIds: Collection<String>) { fun clearProgress(videoIds: Collection<String>) {
ensureLoaded() ensureLoaded()
if (videoIds.isEmpty()) return if (videoIds.isEmpty()) return
if (shouldUseTraktProgress()) {
videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval)
publish()
return
}
val removedEntries = videoIds.mapNotNull { videoId -> val removedEntries = videoIds.mapNotNull { videoId ->
entriesByVideoId.remove(videoId) entriesByVideoId.remove(videoId)
} }
@ -190,17 +238,21 @@ object WatchProgressRepository {
fun progressForVideo(videoId: String): WatchProgressEntry? { fun progressForVideo(videoId: String): WatchProgressEntry? {
ensureLoaded() ensureLoaded()
return entriesByVideoId[videoId] return if (shouldUseTraktProgress()) {
TraktProgressRepository.uiState.value.entries.firstOrNull { it.videoId == videoId }
} else {
entriesByVideoId[videoId]
}
} }
fun resumeEntryForSeries(metaId: String): WatchProgressEntry? { fun resumeEntryForSeries(metaId: String): WatchProgressEntry? {
ensureLoaded() ensureLoaded()
return entriesByVideoId.values.toList().resumeEntryForSeries(metaId) return currentEntries().resumeEntryForSeries(metaId)
} }
fun continueWatching(): List<WatchProgressEntry> { fun continueWatching(): List<WatchProgressEntry> {
ensureLoaded() ensureLoaded()
return entriesByVideoId.values.toList().continueWatchingEntries() return currentEntries().continueWatchingEntries()
} }
private fun upsert( private fun upsert(
@ -245,6 +297,9 @@ object WatchProgressRepository {
) )
entriesByVideoId[session.videoId] = entry entriesByVideoId[session.videoId] = entry
if (shouldUseTraktProgress()) {
TraktProgressRepository.applyOptimisticProgress(entry)
}
publish() publish()
if (persist) persist() if (persist) persist()
pushScrobbleToServer(entry) pushScrobbleToServer(entry)
@ -252,6 +307,7 @@ object WatchProgressRepository {
} }
private fun pushScrobbleToServer(entry: WatchProgressEntry) { private fun pushScrobbleToServer(entry: WatchProgressEntry) {
if (shouldUseTraktProgress()) return
syncScope.launch { syncScope.launch {
runCatching { runCatching {
val profileId = ProfileRepository.activeProfileId val profileId = ProfileRepository.activeProfileId
@ -263,6 +319,7 @@ object WatchProgressRepository {
} }
private fun pushDeleteToServer(entries: Collection<WatchProgressEntry>) { private fun pushDeleteToServer(entries: Collection<WatchProgressEntry>) {
if (shouldUseTraktProgress()) return
syncScope.launch { syncScope.launch {
runCatching { runCatching {
if (entries.isEmpty()) return@runCatching if (entries.isEmpty()) return@runCatching
@ -275,8 +332,9 @@ object WatchProgressRepository {
} }
private fun publish() { private fun publish() {
val entries = currentEntries()
_uiState.value = WatchProgressUiState( _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<WatchProgressEntry> {
return if (shouldUseTraktProgress()) {
TraktProgressRepository.uiState.value.entries
} else {
entriesByVideoId.values.toList()
}
}
} }