mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-29 04:13:03 +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.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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue