refactor(tracking): register progress read providers

This commit is contained in:
tapframe 2026-07-22 03:38:21 +05:30
parent 617bfe298e
commit eb5bf78c37
11 changed files with 298 additions and 240 deletions

View file

@ -5,12 +5,14 @@ import com.nuvio.app.features.simkl.SimklMutationRepository
import com.nuvio.app.features.simkl.SimklLibraryRepository
import com.nuvio.app.features.simkl.SimklProgressRepository
import com.nuvio.app.features.simkl.SimklTrackingLibraryProvider
import com.nuvio.app.features.simkl.SimklTrackingProgressProvider
import com.nuvio.app.features.simkl.SimklWatchedSyncAdapter
import com.nuvio.app.features.simkl.SimklSyncRepository
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktScrobbleRepository
import com.nuvio.app.features.trakt.TraktTrackingLibraryProvider
import com.nuvio.app.features.trakt.TraktTrackingProgressProvider
import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter
fun ensureTrackingProvidersRegistered() {
@ -25,4 +27,6 @@ fun ensureTrackingProvidersRegistered() {
TrackingProviderRegistry.registerLibraryProvider(SimklTrackingLibraryProvider)
TrackingProviderRegistry.registerWatchedProvider(TraktWatchedSyncAdapter)
TrackingProviderRegistry.registerWatchedProvider(SimklWatchedSyncAdapter)
TrackingProviderRegistry.registerProgressProvider(TraktTrackingProgressProvider)
TrackingProviderRegistry.registerProgressProvider(SimklTrackingProgressProvider)
}

View file

@ -594,7 +594,10 @@ object LibraryRepository {
private fun effectiveLibrarySourceMode(): LibrarySourceMode =
resolveEffectiveLibrarySourceMode(
requestedSource = selectedLibrarySourceMode(),
isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated,
isProviderAuthenticated = { providerId ->
TrackingProviderRegistry.libraryProvider(providerId) != null &&
TrackingProviderRegistry.isAuthenticated(providerId)
},
)
private fun activeLibraryProvider(

View file

@ -11,6 +11,8 @@ import com.nuvio.app.features.tracking.TrackingLibraryTab
import com.nuvio.app.features.tracking.TrackingLibraryTabKind
import com.nuvio.app.features.tracking.TrackingListStatus
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProgressProvider
import com.nuvio.app.features.tracking.TrackingProgressSnapshot
import com.nuvio.app.features.tracking.TrackingWatchedProvider
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
@ -110,6 +112,8 @@ object SimklTrackingLibraryProvider : TrackingLibraryProvider {
override fun ensureLoaded() = SimklLibraryRepository.ensureLoaded()
override fun onProfileChanged() = SimklLibraryRepository.ensureLoaded()
override suspend fun refresh() = SimklLibraryRepository.refreshNow()
override fun snapshot(): TrackingLibrarySnapshot {
@ -299,6 +303,30 @@ object SimklProgressRepository {
}
}
object SimklTrackingProgressProvider : TrackingProgressProvider {
override val providerId: TrackingProviderId = TrackingProviderId.SIMKL
override val changes: Flow<Unit> = SimklProgressRepository.uiState.map { Unit }
override fun ensureLoaded() = SimklProgressRepository.ensureLoaded()
override fun onProfileChanged() = SimklProgressRepository.ensureLoaded()
override suspend fun refresh(force: Boolean, sourceChanged: Boolean) =
SimklProgressRepository.refreshNow()
override fun snapshot(): TrackingProgressSnapshot {
val state = SimklProgressRepository.uiState.value
return TrackingProgressSnapshot(
entries = state.entries,
hasLoadedRemoteProgress = state.hasLoadedRemoteProgress,
errorMessage = state.errorMessage,
)
}
override suspend fun removeProgress(entries: Collection<WatchProgressEntry>) =
SimklProgressRepository.removeProgress(entries)
}
private fun SimklSyncSnapshot.canSafelyRemoveFromSimklWatchlist(contentId: String): Boolean =
entries.firstOrNull { entry -> entry.media?.canonicalContentId().equals(contentId, ignoreCase = true) }
?.let { entry ->

View file

@ -76,6 +76,7 @@ object TrackingProviderRegistry {
private val scrobblers = mutableMapOf<TrackingProviderId, TrackingScrobbler>()
private val libraryProviders = mutableMapOf<TrackingProviderId, TrackingLibraryProvider>()
private val watchedProviders = mutableMapOf<TrackingProviderId, TrackingWatchedProvider>()
private val progressProviders = mutableMapOf<TrackingProviderId, TrackingProgressProvider>()
private val _connectedProviderIds = MutableStateFlow<Set<TrackingProviderId>>(emptySet())
val connectedProviderIds: StateFlow<Set<TrackingProviderId>> = _connectedProviderIds.asStateFlow()
@ -123,6 +124,10 @@ object TrackingProviderRegistry {
watchedProviders[provider.providerId] = provider
}
fun registerProgressProvider(provider: TrackingProgressProvider) = synchronized(lock) {
progressProviders[provider.providerId] = provider
}
fun authProvider(id: TrackingProviderId): TrackingAuthProvider? = synchronized(lock) {
authProviders[id]
}
@ -168,6 +173,16 @@ object TrackingProviderRegistry {
watchedProviders[id]
}
fun progressProvider(id: TrackingProviderId): TrackingProgressProvider? = synchronized(lock) {
progressProviders[id]
}
fun progressProviders(): List<TrackingProgressProvider> = synchronized(lock) {
progressProviders.entries
.sortedBy { (id, _) -> id.ordinal }
.map { (_, provider) -> provider }
}
fun connectedListWriters(): List<TrackingListWriter> =
connectedPorts(listWriters, TrackingCapability.LIBRARY_WRITE)
@ -183,6 +198,9 @@ object TrackingProviderRegistry {
fun connectedWatchedProviders(): List<TrackingWatchedProvider> =
connectedPorts(watchedProviders, TrackingCapability.WATCHED_READ)
fun connectedProgressProviders(): List<TrackingProgressProvider> =
connectedPorts(progressProviders, TrackingCapability.PROGRESS_READ)
fun handleAuthCallback(url: String): Boolean =
providersWith(TrackingCapability.AUTHENTICATION)
.any { provider -> provider.handleAuthCallback(url) }

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.tracking
import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.library.LibrarySection
import com.nuvio.app.features.watching.sync.WatchedSyncAdapter
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import kotlinx.coroutines.flow.Flow
enum class TrackingLibraryTabKind {
@ -57,3 +58,37 @@ interface TrackingLibraryProvider {
interface TrackingWatchedProvider : WatchedSyncAdapter {
val providerId: TrackingProviderId
}
data class TrackingProgressSnapshot(
val entries: List<WatchProgressEntry> = emptyList(),
val hasLoadedRemoteProgress: Boolean = false,
val errorMessage: String? = null,
)
/** Provider-owned projection and policy for remote playback progress. */
interface TrackingProgressProvider {
val providerId: TrackingProviderId
val changes: Flow<Unit>
/** True when the provider projection already contains display-ready metadata. */
val providesCompleteMetadata: Boolean
get() = false
/** True when completed progress is already reconciled into watched history by the provider. */
val ownsCompletedHistoryProjection: Boolean
get() = false
fun ensureLoaded()
fun onProfileChanged() = Unit
fun clearLocalState() = Unit
fun onActivated() = Unit
suspend fun refresh(force: Boolean, sourceChanged: Boolean)
fun snapshot(): TrackingProgressSnapshot
suspend fun removeProgress(entries: Collection<WatchProgressEntry>)
fun applyOptimisticRemoval(entries: Collection<WatchProgressEntry>) = Unit
fun applyOptimisticProgress(entry: WatchProgressEntry) = Unit
fun normalizeParentContentId(parentContentId: String, videoId: String?): String = parentContentId
fun shouldRetainLocalEntry(entry: WatchProgressEntry): Boolean = true
suspend fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean) = Unit
fun isHiddenFromProgress(contentId: String): Boolean = false
}

View file

@ -0,0 +1,90 @@
package com.nuvio.app.features.trakt
import co.touchlab.kermit.Logger
import com.nuvio.app.features.tracking.TrackingProgressProvider
import com.nuvio.app.features.tracking.TrackingProgressSnapshot
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
object TraktTrackingProgressProvider : TrackingProgressProvider {
private val log = Logger.withTag("TraktProgressPort")
override val providerId: TrackingProviderId = TrackingProviderId.TRAKT
override val changes: Flow<Unit> = TraktProgressRepository.uiState.map { Unit }
override val providesCompleteMetadata: Boolean = true
override val ownsCompletedHistoryProjection: Boolean = true
override fun ensureLoaded() = TraktProgressRepository.ensureLoaded()
override fun onProfileChanged() = TraktProgressRepository.onProfileChanged()
override fun clearLocalState() = TraktProgressRepository.clearLocalState()
override fun onActivated() = TraktProgressRepository.clearLocalState()
override suspend fun refresh(force: Boolean, sourceChanged: Boolean) {
if (force || sourceChanged) {
TraktProgressRepository.invalidateAndRefresh()
} else {
TraktProgressRepository.refreshNow()
}
}
override fun snapshot(): TrackingProgressSnapshot {
val state = TraktProgressRepository.uiState.value
return TrackingProgressSnapshot(
entries = state.entries,
hasLoadedRemoteProgress = state.hasLoadedRemoteProgress,
errorMessage = state.errorMessage,
)
}
override suspend fun removeProgress(entries: Collection<WatchProgressEntry>) {
entries
.filter { entry -> isTraktCompatibleId(entry.parentMetaId) }
.distinctBy { entry -> Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) }
.forEach { entry ->
try {
TraktProgressRepository.removeProgress(
contentId = entry.parentMetaId,
seasonNumber = entry.seasonNumber,
episodeNumber = entry.episodeNumber,
)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to remove Trakt progress for ${entry.parentMetaId}" }
}
}
}
override fun applyOptimisticRemoval(entries: Collection<WatchProgressEntry>) {
entries
.distinctBy { entry -> Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) }
.forEach { entry ->
TraktProgressRepository.applyOptimisticRemoval(
contentId = entry.parentMetaId,
seasonNumber = entry.seasonNumber,
episodeNumber = entry.episodeNumber,
)
}
}
override fun applyOptimisticProgress(entry: WatchProgressEntry) =
TraktProgressRepository.applyOptimisticProgress(entry)
override fun normalizeParentContentId(parentContentId: String, videoId: String?): String =
resolveEffectiveContentId(parentContentId, videoId)
override fun shouldRetainLocalEntry(entry: WatchProgressEntry): Boolean =
!isTraktCompatibleId(entry.parentMetaId)
override suspend fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean) =
TraktProgressRepository.refreshEpisodeProgress(contentId, forceRefresh)
override fun isHiddenFromProgress(contentId: String): Boolean =
TraktProgressRepository.isShowHiddenFromProgress(contentId)
}

View file

@ -141,7 +141,7 @@ object WatchedRepository {
activateEffectiveSource(
effectiveWatchedSource(
requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource,
connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(),
connectedProviderIds = connectedWatchedProviderIds(),
),
)
}
@ -276,7 +276,7 @@ object WatchedRepository {
profileId = profileId,
source = effectiveWatchedSource(
requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource,
connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(),
connectedProviderIds = connectedWatchedProviderIds(),
),
forceSnapshot = false,
)
@ -289,7 +289,7 @@ object WatchedRepository {
profileId = profileId,
source = effectiveWatchedSource(
requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource,
connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(),
connectedProviderIds = connectedWatchedProviderIds(),
),
forceSnapshot = true,
)
@ -1000,6 +1000,10 @@ object WatchedRepository {
synchronized(accountScopeLock) {
accountScope
}
private fun connectedWatchedProviderIds(): Set<TrackingProviderId> =
TrackingProviderRegistry.connectedWatchedProviders()
.mapTo(linkedSetOf()) { provider -> provider.providerId }
}
internal data class WatchedSnapshotMerge(

View file

@ -12,16 +12,13 @@ import com.nuvio.app.features.details.MetaDetails
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.simkl.SimklProgressRepository
import com.nuvio.app.features.tracking.TrackingProgressProvider
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingSettingsRepository
import com.nuvio.app.features.tracking.WatchProgressSource
import com.nuvio.app.features.tracking.effectiveWatchProgressSource
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktProgressRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.isTraktCompatibleId
import com.nuvio.app.features.trakt.resolveEffectiveContentId
import com.nuvio.app.features.tracking.providerId
import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.sync.ProgressDeltaEvent
import com.nuvio.app.features.watching.sync.ProgressSyncRecord
@ -267,18 +264,13 @@ object WatchProgressRepository {
internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter
init {
syncScope.launch {
TraktProgressRepository.uiState.collectLatest {
if (shouldUseTraktProgress()) {
publish()
}
}
}
syncScope.launch {
SimklProgressRepository.uiState.collectLatest {
if (activeSource == WatchProgressSource.SIMKL) {
publish()
ensureTrackingProvidersRegistered()
TrackingProviderRegistry.progressProviders().forEach { provider ->
syncScope.launch {
provider.changes.collectLatest {
if (activeSource.providerId == provider.providerId) {
publish()
}
}
}
}
@ -294,14 +286,13 @@ object WatchProgressRepository {
fun ensureLoaded() {
ensureTrackingProvidersRegistered()
TrackingProviderRegistry.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktProgressRepository.ensureLoaded()
SimklProgressRepository.ensureLoaded()
TrackingSettingsRepository.ensureLoaded()
TrackingProviderRegistry.progressProviders().forEach(TrackingProgressProvider::ensureLoaded)
if (!hasLoaded) {
updateActiveSource(
effectiveWatchProgressSource(
requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource,
isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated,
requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource,
isProviderAuthenticated = ::isProgressProviderAvailable,
),
)
loadFromDisk(ProfileRepository.activeProfileId)
@ -310,15 +301,14 @@ object WatchProgressRepository {
fun onProfileChanged(profileId: Int) {
if (profileId == currentProfileId && hasLoaded) return
TraktSettingsRepository.onProfileChanged()
updateActiveSource(
effectiveWatchProgressSource(
requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource,
isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated,
requestedSource = TrackingSettingsRepository.uiState.value.watchProgressSource,
isProviderAuthenticated = ::isProgressProviderAvailable,
),
)
loadFromDisk(profileId)
TraktProgressRepository.onProfileChanged()
TrackingProviderRegistry.progressProviders().forEach(TrackingProgressProvider::onProfileChanged)
}
fun clearLocalState() {
@ -339,8 +329,7 @@ object WatchProgressRepository {
lastSuccessfulPushEpochMs = 0L
deltaCursorEventId = 0L
deltaInitialized = false
TraktProgressRepository.clearLocalState()
TraktSettingsRepository.clearLocalState()
TrackingProviderRegistry.progressProviders().forEach(TrackingProgressProvider::clearLocalState)
_uiState.value = WatchProgressUiState()
}
@ -415,9 +404,8 @@ object WatchProgressRepository {
internal fun activateSource(source: WatchProgressSource) {
TrackingProviderRegistry.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktProgressRepository.ensureLoaded()
SimklProgressRepository.ensureLoaded()
TrackingSettingsRepository.ensureLoaded()
TrackingProviderRegistry.progressProviders().forEach(TrackingProgressProvider::ensureLoaded)
if (!hasLoaded) {
loadFromDisk(ProfileRepository.activeProfileId)
}
@ -428,13 +416,9 @@ object WatchProgressRepository {
updateActiveSource(source)
cancelMetadataResolution(resetProviderHistory = false)
when (source) {
WatchProgressSource.TRAKT -> TraktProgressRepository.clearLocalState()
WatchProgressSource.NUVIO_SYNC -> hasLoadedNuvioRemoteProgress = false
WatchProgressSource.SIMKL -> Unit
}
activeProgressProvider()?.onActivated() ?: run { hasLoadedNuvioRemoteProgress = false }
publish()
if (source == WatchProgressSource.NUVIO_SYNC) {
if (activeProgressProvider()?.providesCompleteMetadata != true) {
resolveRemoteMetadata()
}
}
@ -455,80 +439,74 @@ object WatchProgressRepository {
}
activateSource(source)
return when (source) {
WatchProgressSource.TRAKT -> refreshTraktSource(
activeProgressProvider()?.let { provider ->
return refreshProviderSource(
provider = provider,
profileId = profileId,
operationGeneration = operationGeneration,
sourceChanged = sourceChanged,
force = force,
)
WatchProgressSource.NUVIO_SYNC -> refreshNuvioSource(
profileId = profileId,
operationGeneration = operationGeneration,
force = force,
)
WatchProgressSource.SIMKL -> refreshSimklSource(
profileId = profileId,
operationGeneration = operationGeneration,
)
}
return refreshNuvioSource(
profileId = profileId,
operationGeneration = operationGeneration,
force = force,
)
}
private suspend fun refreshSimklSource(
profileId: Int,
operationGeneration: Long,
): Boolean {
if (!TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) {
log.d { "Skipping Simkl progress refresh because Simkl is not authenticated" }
return false
}
return try {
SimklProgressRepository.refreshNow()
if (isActiveOperation(profileId, operationGeneration) && activeSource == WatchProgressSource.SIMKL) {
publish()
}
val state = SimklProgressRepository.uiState.value
state.hasLoadedRemoteProgress && state.errorMessage == null
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to refresh Simkl watch progress" }
false
}
}
private suspend fun refreshTraktSource(
private suspend fun refreshProviderSource(
provider: TrackingProgressProvider,
profileId: Int,
operationGeneration: Long,
sourceChanged: Boolean,
force: Boolean,
): Boolean {
if (!TraktAuthRepository.isAuthenticated.value) {
log.d { "Skipping Trakt progress refresh because Trakt is not authenticated" }
if (!isProgressProviderAvailable(provider.providerId)) {
log.d { "Skipping ${provider.providerId.storageId} progress refresh because it is unavailable" }
return false
}
return try {
if (force || sourceChanged) {
TraktProgressRepository.invalidateAndRefresh()
} else {
TraktProgressRepository.refreshNow()
}
if (isActiveOperation(profileId, operationGeneration) && activeSource == WatchProgressSource.TRAKT) {
provider.refresh(force = force, sourceChanged = sourceChanged)
if (
isActiveOperation(profileId, operationGeneration) &&
activeSource.providerId == provider.providerId
) {
publish()
}
val state = TraktProgressRepository.uiState.value
val state = provider.snapshot()
state.hasLoadedRemoteProgress && state.errorMessage == null
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to refresh Trakt watch progress" }
log.e(error) { "Failed to refresh ${provider.providerId.storageId} watch progress" }
false
}
}
private fun activeProgressProvider(): TrackingProgressProvider? =
activeSource.providerId?.let(TrackingProviderRegistry::progressProvider)
private fun isProgressProviderAvailable(providerId: TrackingProviderId): Boolean =
TrackingProviderRegistry.progressProvider(providerId) != null &&
TrackingProviderRegistry.isAuthenticated(providerId)
private suspend fun removeProviderProgress(
provider: TrackingProgressProvider,
entries: Collection<WatchProgressEntry>,
reason: String,
) {
try {
provider.removeProgress(entries)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) {
"Failed to $reason from ${provider.providerId.storageId}"
}
}
}
private suspend fun refreshNuvioSource(
profileId: Int,
operationGeneration: Long,
@ -958,7 +936,7 @@ object WatchProgressRepository {
}
private fun retryMetadataResolutionWhenAddonMetaProvidersReady(state: AddonsUiState) {
if (!hasLoaded || shouldUseTraktProgress()) return
if (!hasLoaded || activeProgressProvider()?.providesCompleteMetadata == true) return
val readiness = state.metadataProviderReadiness()
if (!readiness.isReady) return
@ -1054,7 +1032,7 @@ object WatchProgressRepository {
resolutionGeneration = resolutionGeneration,
currentProviderFingerprint = currentReadiness.fingerprint.takeIf { currentReadiness.isReady },
)
if (shouldRetry && hasLoaded && !shouldUseTraktProgress()) {
if (shouldRetry && hasLoaded && activeProgressProvider()?.providesCompleteMetadata != true) {
resolveRemoteMetadata()
}
}
@ -1118,62 +1096,18 @@ object WatchProgressRepository {
ensureLoaded()
if (videoIds.isEmpty()) return
val useTraktProgress = shouldUseTraktProgress()
if (shouldUseSimklProgress()) {
activeProgressProvider()?.let { provider ->
val entriesToRemove = currentEntries().filter { entry ->
entry.videoId in videoIds &&
(parentMetaId == null || entry.parentMetaId == parentMetaId)
}
val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove)
provider.applyOptimisticRemoval(entriesToRemove)
if (locallyRemovedEntries.isNotEmpty()) persist()
publish()
if (entriesToRemove.isNotEmpty()) {
syncScope.launch {
SimklProgressRepository.removeProgress(entriesToRemove)
}
}
return
}
if (useTraktProgress) {
val entriesToRemove = currentEntries().filter { entry ->
entry.videoId in videoIds &&
(parentMetaId == null || entry.parentMetaId == parentMetaId)
}
val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove)
if (parentMetaId == null) {
videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval)
} else {
entriesToRemove
.distinctBy { entry ->
Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber)
}
.forEach { entry ->
TraktProgressRepository.applyOptimisticRemoval(
contentId = entry.parentMetaId,
seasonNumber = entry.seasonNumber,
episodeNumber = entry.episodeNumber,
)
}
}
if (locallyRemovedEntries.isNotEmpty()) {
persist()
}
publish()
val traktEntriesToRemove = entriesToRemove.filter { entry -> entry.shouldAttemptTraktPlaybackDelete() }
if (traktEntriesToRemove.isNotEmpty()) {
syncScope.launch {
traktEntriesToRemove.forEach { entry ->
runCatching {
TraktProgressRepository.removeProgress(
contentId = entry.parentMetaId,
seasonNumber = entry.seasonNumber,
episodeNumber = entry.episodeNumber,
)
}.onFailure { error ->
if (error is CancellationException) throw error
log.e(error) { "Failed to clear Trakt playback progress for ${entry.videoId}" }
}
}
removeProviderProgress(provider, entriesToRemove, "clear playback progress")
}
}
return
@ -1199,7 +1133,6 @@ object WatchProgressRepository {
val normalizedContentId = contentId.trim()
if (normalizedContentId.isBlank()) return
val useTraktProgress = shouldUseTraktProgress()
val entriesToRemove = currentEntries().filter { entry ->
if (entry.parentMetaId != normalizedContentId) {
false
@ -1211,42 +1144,13 @@ object WatchProgressRepository {
}
if (entriesToRemove.isEmpty()) return
if (shouldUseSimklProgress()) {
activeProgressProvider()?.let { provider ->
val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove)
provider.applyOptimisticRemoval(entriesToRemove)
if (locallyRemovedEntries.isNotEmpty()) persist()
publish()
syncScope.launch {
SimklProgressRepository.removeProgress(entriesToRemove)
}
return
}
if (useTraktProgress) {
val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove)
TraktProgressRepository.applyOptimisticRemoval(
contentId = normalizedContentId,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
)
if (locallyRemovedEntries.isNotEmpty()) {
persist()
}
publish()
val shouldAttemptTraktDelete = entriesToRemove.any { entry -> entry.shouldAttemptTraktPlaybackDelete() }
if (!shouldAttemptTraktDelete) {
return
}
syncScope.launch {
runCatching {
TraktProgressRepository.removeProgress(
contentId = normalizedContentId,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
)
}.onFailure { error ->
if (error is CancellationException) throw error
log.e(error) { "Failed to remove Trakt watch progress" }
}
removeProviderProgress(provider, entriesToRemove, "remove playback progress")
}
return
}
@ -1286,16 +1190,19 @@ object WatchProgressRepository {
fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean = false) {
ensureLoaded()
if (!shouldUseTraktProgress()) return
val provider = activeProgressProvider() ?: return
syncScope.launch {
runCatching {
TraktProgressRepository.refreshEpisodeProgress(
provider.refreshEpisodeProgress(
contentId = contentId,
forceRefresh = forceRefresh,
)
}.onFailure { error ->
if (error is CancellationException) throw error
log.w { "Failed to refresh Trakt episode progress for $contentId: ${error.message}" }
log.w {
"Failed to refresh ${provider.providerId.storageId} episode progress " +
"for $contentId: ${error.message}"
}
}
}
}
@ -1318,16 +1225,11 @@ object WatchProgressRepository {
return
}
val useTraktProgress = shouldUseTraktProgress()
// If Trakt is the active CW source and parentMetaId is not Trakt-resolvable
// but videoId contains a valid IMDB/TMDB, use the resolved ID to avoid
// duplicate CW entries (one local with garbage ID, one from Trakt with real ID).
val effectiveParentMetaId = if (useTraktProgress) {
resolveEffectiveContentId(session.parentMetaId, session.videoId)
} else {
session.parentMetaId
}
val progressProvider = activeProgressProvider()
val effectiveParentMetaId = progressProvider?.normalizeParentContentId(
parentContentId = session.parentMetaId,
videoId = session.videoId,
) ?: session.parentMetaId
val candidateEntry = WatchProgressEntry(
contentType = session.contentType,
@ -1374,9 +1276,7 @@ object WatchProgressRepository {
upsertLocalEntry(entry)
markProgressDirty(entry)
if (useTraktProgress) {
TraktProgressRepository.applyOptimisticProgress(entry)
}
progressProvider?.applyOptimisticProgress(entry)
publish()
if (persist) persist()
if (entry.needsRemoteMetadataEnrichment()) {
@ -1385,7 +1285,12 @@ object WatchProgressRepository {
if (syncRemote) {
pushScrobbleToServer(entry = entry, profileId = targetProfileId)
}
if (shouldCascadeCompletedProgressToWatchedHistory(entry, useTraktProgress)) {
if (
shouldCascadeCompletedProgressToWatchedHistory(
entry = entry,
providerOwnsCompletedHistory = progressProvider?.ownsCompletedHistoryProjection == true,
)
) {
WatchingActions.onProgressEntryUpdated(entry, syncRemote = syncRemote)
}
}
@ -1447,7 +1352,7 @@ object WatchProgressRepository {
}
private fun pushDeleteToServer(entries: Collection<WatchProgressEntry>) {
if (activeSource != WatchProgressSource.NUVIO_SYNC) return
if (activeSource.providerId != null) return
val profileId = currentProfileId
accountScopeSnapshot().launch {
runCatching {
@ -1462,11 +1367,10 @@ object WatchProgressRepository {
private fun publish() {
val entries = currentEntries()
val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs }
val hasLoadedRemoteProgress = when (activeSource) {
WatchProgressSource.TRAKT -> TraktProgressRepository.uiState.value.hasLoadedRemoteProgress
WatchProgressSource.SIMKL -> SimklProgressRepository.uiState.value.hasLoadedRemoteProgress
WatchProgressSource.NUVIO_SYNC -> hasLoadedNuvioRemoteProgress
}
val hasLoadedRemoteProgress = activeProgressProvider()
?.snapshot()
?.hasLoadedRemoteProgress
?: hasLoadedNuvioRemoteProgress
_uiState.value = WatchProgressUiState(
entries = sortedEntries,
hasLoadedRemoteProgress = hasLoadedRemoteProgress,
@ -1558,12 +1462,6 @@ object WatchProgressRepository {
)
}
private fun shouldUseTraktProgress(): Boolean =
activeSource == WatchProgressSource.TRAKT
private fun shouldUseSimklProgress(): Boolean =
activeSource == WatchProgressSource.SIMKL
private fun accountScopeSnapshot(): CoroutineScope = synchronized(accountScopeLock) {
accountScope
}
@ -1573,9 +1471,6 @@ object WatchProgressRepository {
_activeSourceState.value = source
}
private fun WatchProgressEntry.shouldAttemptTraktPlaybackDelete(): Boolean =
isTraktCompatibleId(parentMetaId)
private fun removeStoredLocalEntries(entries: Collection<WatchProgressEntry>): List<WatchProgressEntry> =
synchronized(entriesLock) {
val targetKeys = entries.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() }
@ -1594,34 +1489,11 @@ object WatchProgressRepository {
}
private fun currentEntries(): List<WatchProgressEntry> {
return when (activeSource) {
WatchProgressSource.TRAKT -> {
// Merge Trakt remote progress with local-only entries that use
// non-Trakt-compatible IDs (kitsu:, mal:, anilist:, etc.).
// Trakt will never return these IDs, so they must come from local storage.
val traktItems = TraktProgressRepository.uiState.value.entries
val localNonTraktItems = localEntriesSnapshot().filter {
!isTraktCompatibleId(it.parentMetaId)
}
if (localNonTraktItems.isEmpty()) {
traktItems
} else {
val traktKeys = traktItems.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() }
val merged = traktItems.toMutableList()
localNonTraktItems.forEach { localItem ->
if (localItem.resolvedProgressKey() !in traktKeys) {
merged.add(localItem)
}
}
merged
}
}
WatchProgressSource.SIMKL -> mergeTrackerProgressEntries(
remoteEntries = SimklProgressRepository.uiState.value.entries,
localEntries = localEntriesSnapshot(),
)
WatchProgressSource.NUVIO_SYNC -> localEntriesSnapshot()
}
val provider = activeProgressProvider() ?: return localEntriesSnapshot()
return mergeTrackerProgressEntries(
remoteEntries = provider.snapshot().entries,
localEntries = localEntriesSnapshot().filter(provider::shouldRetainLocalEntry),
)
}
private fun localEntriesSnapshot(): List<WatchProgressEntry> =
@ -1752,7 +1624,7 @@ object WatchProgressRepository {
}
fun isDroppedShow(contentId: String): Boolean {
return shouldUseTraktProgress() && TraktProgressRepository.isShowHiddenFromProgress(contentId)
return activeProgressProvider()?.isHiddenFromProgress(contentId) == true
}
private fun AddonsUiState.metadataProviderReadiness(): MetadataProviderReadiness {

View file

@ -175,8 +175,8 @@ internal fun shouldReplaceProgressSnapshotEntry(
internal fun shouldCascadeCompletedProgressToWatchedHistory(
entry: WatchProgressEntry,
isUsingTraktProgress: Boolean,
): Boolean = !isUsingTraktProgress && entry.normalizedCompletion().isCompleted
providerOwnsCompletedHistory: Boolean,
): Boolean = !providerOwnsCompletedHistory && entry.normalizedCompletion().isCompleted
internal fun String?.isSeriesTypeForContinueWatching(): Boolean =
equals("series", ignoreCase = true) || equals("tv", ignoreCase = true)

View file

@ -474,7 +474,11 @@ object WatchProgressSourceCoordinator {
requestedSource = requestedSource,
effectiveSource = effectiveWatchProgressSource(
requestedSource = requestedSource,
isProviderAuthenticated = { providerId -> providerId in connectedProviderIds },
isProviderAuthenticated = { providerId ->
providerId in connectedProviderIds &&
TrackingProviderRegistry.progressProvider(providerId) != null &&
TrackingProviderRegistry.watchedProvider(providerId) != null
},
),
isNuvioAuthenticated = authState is AuthState.Authenticated && !authState.isAnonymous,
)

View file

@ -403,7 +403,7 @@ class WatchProgressRulesTest {
}
@Test
fun `completed progress does not cascade to watched history while Trakt progress is active`() {
fun `completed progress does not cascade when provider owns watched projection`() {
val completed = entry(
videoId = "movie-complete",
isCompleted = true,
@ -413,19 +413,19 @@ class WatchProgressRulesTest {
assertFalse(
shouldCascadeCompletedProgressToWatchedHistory(
entry = completed,
isUsingTraktProgress = true,
providerOwnsCompletedHistory = true,
),
)
assertTrue(
shouldCascadeCompletedProgressToWatchedHistory(
entry = completed,
isUsingTraktProgress = false,
providerOwnsCompletedHistory = false,
),
)
assertFalse(
shouldCascadeCompletedProgressToWatchedHistory(
entry = inProgress,
isUsingTraktProgress = false,
providerOwnsCompletedHistory = false,
),
)
}