mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-03 08:49:07 +00:00
init trakt scrobble and watch history
This commit is contained in:
parent
14e03a7f4b
commit
fff3a6eac4
6 changed files with 865 additions and 10 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue