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.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<WatchProgressEntry>,
@ -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)
}
}

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.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) {

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.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<String, WatchProgressEntry> = 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<String>) {
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<WatchProgressEntry> {
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<WatchProgressEntry>) {
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<WatchProgressEntry> {
return if (shouldUseTraktProgress()) {
TraktProgressRepository.uiState.value.entries
} else {
entriesByVideoId.values.toList()
}
}
}