feat(simkl): wire tracker state into app repositories

This commit is contained in:
tapframe 2026-07-22 02:54:08 +05:30
parent caf124e1b4
commit 0f12e61430
10 changed files with 757 additions and 160 deletions

View file

@ -2,6 +2,8 @@ package com.nuvio.app.core.tracking
import com.nuvio.app.features.simkl.SimklAuthRepository
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.SimklSyncRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
@ -9,5 +11,7 @@ fun ensureTrackingProvidersRegistered() {
TraktAuthRepository.descriptor
SimklAuthRepository.descriptor
SimklSyncRepository.state
SimklLibraryRepository.uiState
SimklProgressRepository.uiState
SimklMutationRepository.ensureRegistered()
}

View file

@ -2,20 +2,25 @@ package com.nuvio.app.features.library
import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.core.sync.putSyncOriginClientId
import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.simkl.SIMKL_WATCHLIST_KEY
import com.nuvio.app.features.simkl.SIMKL_WATCHLIST_TITLE
import com.nuvio.app.features.simkl.SimklLibraryRepository
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktLibraryRepository
import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.trakt.TraktListType
import com.nuvio.app.features.trakt.TraktMembershipChanges
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode
import com.nuvio.app.features.trakt.shouldUseTraktLibrary
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.rpc
import kotlinx.atomicfu.locks.SynchronizedObject
@ -93,14 +98,21 @@ object LibraryRepository {
init {
syncScope.launch {
TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
if (authenticated) {
TrackingProviderRegistry.connectedProviderIds.collectLatest { connectedProviderIds ->
if (TrackingProviderId.TRAKT in connectedProviderIds) {
TraktLibraryRepository.preloadListTabsAsync()
if (shouldUseTraktLibrary(authenticated, selectedLibrarySourceMode())) {
if (effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT) {
runCatching { TraktLibraryRepository.refreshNow() }
.onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
}
}
if (
TrackingProviderId.SIMKL in connectedProviderIds &&
effectiveLibrarySourceMode() == LibrarySourceMode.SIMKL
) {
runCatching { SimklLibraryRepository.refreshNow() }
.onFailure { log.e(it) { "Failed to refresh Simkl library after auth change" } }
}
publish()
}
}
@ -109,12 +121,17 @@ object LibraryRepository {
.map { it.librarySourceMode }
.distinctUntilChanged()
.collectLatest { source ->
if (shouldUseTraktLibrary(TraktAuthRepository.isAuthenticated.value, source)) {
TraktLibraryRepository.preloadListTabsAsync()
publish()
refreshTraktLibraryAsync()
} else {
publish()
when (resolveEffectiveLibrarySourceMode(source, TrackingProviderRegistry::isAuthenticated)) {
LibrarySourceMode.TRAKT -> {
TraktLibraryRepository.preloadListTabsAsync()
publish()
refreshTraktLibraryAsync()
}
LibrarySourceMode.SIMKL -> {
publish()
refreshSimklLibraryAsync()
}
LibrarySourceMode.LOCAL -> publish()
}
}
}
@ -125,24 +142,34 @@ object LibraryRepository {
}
}
}
syncScope.launch {
SimklLibraryRepository.uiState.collectLatest {
if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) {
publish()
}
}
}
}
fun ensureLoaded() {
TraktAuthRepository.ensureLoaded()
ensureTrackingProvidersRegistered()
TrackingProviderRegistry.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktLibraryRepository.ensureLoaded()
SimklLibraryRepository.ensureLoaded()
while (true) {
val activeProfileId = ProfileRepository.activeProfileId
val snapshot = localState.snapshot()
if (snapshot.hasLoaded && snapshot.token.profileId == activeProfileId) break
loadFromDisk(activeProfileId)
}
if (TraktAuthRepository.isAuthenticated.value) {
if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) {
TraktLibraryRepository.preloadListTabsAsync()
if (isTraktLibrarySourceActive()) {
refreshTraktLibraryAsync()
}
}
if (isSimklLibrarySourceActive()) refreshSimklLibraryAsync()
}
fun onProfileChanged(profileId: Int) {
@ -159,6 +186,8 @@ object LibraryRepository {
refreshTraktLibraryAsync()
}
}
SimklLibraryRepository.ensureLoaded()
if (isSimklLibrarySourceActive()) refreshSimklLibraryAsync()
}
fun clearLocalState() {
@ -224,17 +253,32 @@ object LibraryRepository {
return
}
if (isTraktLibrarySourceActive()) {
try {
TraktLibraryRepository.refreshNow()
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to pull Trakt library" }
when (effectiveLibrarySourceMode()) {
LibrarySourceMode.TRAKT -> {
try {
TraktLibraryRepository.refreshNow()
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to pull Trakt library" }
}
if (!isActiveOperation(operationToken)) return
publish()
return
}
if (!isActiveOperation(operationToken)) return
publish()
return
LibrarySourceMode.SIMKL -> {
try {
SimklLibraryRepository.refreshNow()
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to pull Simkl library" }
}
if (!isActiveOperation(operationToken)) return
publish()
return
}
LibrarySourceMode.LOCAL -> Unit
}
nuvioPullMutex.withLock {
@ -295,6 +339,29 @@ object LibraryRepository {
return
}
if (isSimklLibrarySourceActive()) {
val profileId = localState.snapshot().token.profileId
val isCurrentlySaved = SimklLibraryRepository.isInWatchlist(item.id, item.type)
log.i {
"toggleSaved routed to Simkl library source item=${item.id} type=${item.type} profile=$profileId"
}
syncScope.launch {
runCatching {
SimklLibraryRepository.setWatchlistMembership(
profileId = profileId,
item = item,
isMember = !isCurrentlySaved,
)
}.onFailure { error ->
if (error is CancellationException) throw error
log.e(error) { "Failed to toggle Simkl watchlist" }
NuvioToastController.show(error.message ?: "Unable to update Simkl Watchlist")
}
publish()
}
return
}
val result = localState.toggle(
item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()),
)
@ -366,6 +433,10 @@ object LibraryRepository {
return false
}
if (isSimklLibrarySourceActive()) {
return SimklLibraryRepository.isInWatchlist(id, type)
}
return if (type != null) {
localState.contains(id, type)
} else {
@ -380,16 +451,25 @@ object LibraryRepository {
return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id }
}
if (isSimklLibrarySourceActive()) {
return SimklLibraryRepository.uiState.value.items.firstOrNull { it.id == id }
}
return localState.findById(id)
}
fun libraryListTabs(): List<TraktListTab> {
val traktTabs = if (TraktAuthRepository.isAuthenticated.value) {
val traktTabs = if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) {
TraktLibraryRepository.currentListTabs()
} else {
emptyList()
}
return libraryTabsWithLocal(traktTabs)
val simklTabs = if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) {
listOf(simklLibraryListTab())
} else {
emptyList()
}
return libraryTabsWithLocal(traktTabs + simklTabs)
}
fun traktListTabs(): List<TraktListTab> = libraryListTabs()
@ -397,14 +477,14 @@ object LibraryRepository {
suspend fun getMembershipSnapshot(item: LibraryItem): Map<String, Boolean> {
ensureLoaded()
val inLocal = localState.contains(item.id, item.type)
if (TraktAuthRepository.isAuthenticated.value) {
val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership
return libraryMembershipWithLocal(
inLocal = inLocal,
traktMembership = traktMembership,
)
val memberships = linkedMapOf<String, Boolean>()
if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) {
memberships += TraktLibraryRepository.getMembershipSnapshot(item).listMembership
}
return libraryMembershipWithLocal(inLocal = inLocal)
if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) {
memberships[SIMKL_WATCHLIST_KEY] = SimklLibraryRepository.isInWatchlist(item.id, item.type)
}
return libraryMembershipWithLocal(inLocal = inLocal, traktMembership = memberships)
}
suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map<String, Boolean>) {
@ -415,7 +495,7 @@ object LibraryRepository {
log.i {
"Applying library membership item=${item.id} type=${item.type} profile=$profileId " +
"localDesired=$localDesired currentlyInLocal=$currentlyInLocal " +
"traktAuthenticated=${TraktAuthRepository.isAuthenticated.value}"
"connectedProviders=${TrackingProviderRegistry.connectedProviderIdsSnapshot()}"
}
if (localDesired != currentlyInLocal) {
if (localDesired) {
@ -425,18 +505,44 @@ object LibraryRepository {
}
}
if (TraktAuthRepository.isAuthenticated.value) {
val traktMembership = desiredMembership.filterKeys { it != LOCAL_LIBRARY_LIST_KEY }
var firstFailure: Throwable? = null
if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) {
val traktListKeys = TraktLibraryRepository.currentListTabs().mapTo(mutableSetOf(), TraktListTab::key)
val traktMembership = desiredMembership.filterKeys { key -> key in traktListKeys }
if (traktMembership.isNotEmpty()) {
TraktLibraryRepository.applyMembershipChanges(
item = item,
changes = TraktMembershipChanges(desiredMembership = traktMembership),
)
try {
TraktLibraryRepository.applyMembershipChanges(
item = item,
changes = TraktMembershipChanges(desiredMembership = traktMembership),
)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
firstFailure = error
log.e(error) { "Failed to update Trakt library membership" }
}
}
publish()
} else {
publish()
}
if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) {
val desired = desiredMembership[SIMKL_WATCHLIST_KEY] == true
val current = SimklLibraryRepository.isInWatchlist(item.id, item.type)
if (desired != current) {
try {
SimklLibraryRepository.setWatchlistMembership(
profileId = profileId,
item = item,
isMember = desired,
)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
if (firstFailure == null) firstFailure = error
log.e(error) { "Failed to update Simkl library membership" }
}
}
}
publish()
firstFailure?.let { throw it }
}
suspend fun removeFromList(item: LibraryItem, listKey: String) {
@ -528,33 +634,59 @@ object LibraryRepository {
private fun publish() {
val localSnapshot = localState.snapshot()
if (isTraktLibrarySourceActive()) {
val traktState = TraktLibraryRepository.uiState.value
val sections = traktState.listTabs.mapNotNull { tab ->
val listItems = traktState.entriesByList[tab.key].orEmpty()
if (listItems.isEmpty()) {
null
} else {
LibrarySection(
type = tab.key,
displayTitle = tab.title,
items = listItems,
)
when (effectiveLibrarySourceMode()) {
LibrarySourceMode.TRAKT -> {
val traktState = TraktLibraryRepository.uiState.value
val sections = traktState.listTabs.mapNotNull { tab ->
val listItems = traktState.entriesByList[tab.key].orEmpty()
if (listItems.isEmpty()) {
null
} else {
LibrarySection(
type = tab.key,
displayTitle = tab.title,
items = listItems,
)
}
}
}
val newUiState = LibraryUiState(
sourceMode = LibrarySourceMode.TRAKT,
items = traktState.allItems,
sections = sections,
isLoaded = traktState.hasLoaded,
isLoading = traktState.isLoading,
errorMessage = traktState.errorMessage,
)
localState.runIfTokenCurrent(localSnapshot.token) {
_uiState.value = newUiState
val newUiState = LibraryUiState(
sourceMode = LibrarySourceMode.TRAKT,
items = traktState.allItems,
sections = sections,
isLoaded = traktState.hasLoaded,
isLoading = traktState.isLoading,
errorMessage = traktState.errorMessage,
)
localState.runIfTokenCurrent(localSnapshot.token) {
_uiState.value = newUiState
}
return
}
return
LibrarySourceMode.SIMKL -> {
val simklState = SimklLibraryRepository.uiState.value
val items = simklState.items.sortedByDescending(LibraryItem::savedAtEpochMs)
val sections = listOf(
LibrarySection(
type = SIMKL_WATCHLIST_KEY,
displayTitle = SIMKL_WATCHLIST_TITLE,
items = items,
),
)
val newUiState = LibraryUiState(
sourceMode = LibrarySourceMode.SIMKL,
items = items,
sections = sections,
isLoaded = simklState.hasLoaded,
isLoading = simklState.isLoading,
errorMessage = simklState.errorMessage,
)
localState.runIfTokenCurrent(localSnapshot.token) {
_uiState.value = newUiState
}
return
}
LibrarySourceMode.LOCAL -> Unit
}
val items = localSnapshot.items
@ -608,6 +740,17 @@ object LibraryRepository {
}
}
private fun refreshSimklLibraryAsync() {
syncScope.launch {
runCatching { SimklLibraryRepository.refreshNow() }
.onFailure { error ->
if (error is CancellationException) throw error
log.e(error) { "Failed to refresh Simkl library" }
}
publish()
}
}
private fun selectedLibrarySourceMode(): LibrarySourceMode {
TraktSettingsRepository.ensureLoaded()
return TraktSettingsRepository.uiState.value.librarySourceMode
@ -615,12 +758,15 @@ object LibraryRepository {
private fun effectiveLibrarySourceMode(): LibrarySourceMode =
resolveEffectiveLibrarySourceMode(
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
source = selectedLibrarySourceMode(),
requestedSource = selectedLibrarySourceMode(),
isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated,
)
private fun isTraktLibrarySourceActive(): Boolean =
effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT
private fun isSimklLibrarySourceActive(): Boolean =
effectiveLibrarySourceMode() == LibrarySourceMode.SIMKL
}
internal const val LOCAL_LIBRARY_LIST_KEY = "local"
@ -637,6 +783,13 @@ internal fun localLibraryListTab(): TraktListTab =
type = TraktListType.WATCHLIST,
)
internal fun simklLibraryListTab(): TraktListTab =
TraktListTab(
key = SIMKL_WATCHLIST_KEY,
title = SIMKL_WATCHLIST_TITLE,
type = TraktListType.WATCHLIST,
)
internal fun libraryTabsWithLocal(traktTabs: List<TraktListTab>): List<TraktListTab> =
listOf(localLibraryListTab()) + traktTabs

View file

@ -0,0 +1,236 @@
package com.nuvio.app.features.simkl
import co.touchlab.kermit.Logger
import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tracking.TrackingHistoryItem
import com.nuvio.app.features.tracking.TrackingListStatus
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watching.sync.WatchedSyncAdapter
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
data class SimklLibraryUiState(
val items: List<LibraryItem> = emptyList(),
val isLoading: Boolean = false,
val hasLoaded: Boolean = false,
val errorMessage: String? = null,
)
object SimklLibraryRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(SimklLibraryUiState())
val uiState: StateFlow<SimklLibraryUiState> = _uiState.asStateFlow()
init {
scope.launch {
SimklSyncRepository.state.collectLatest(::publish)
}
}
fun ensureLoaded() {
SimklAuthRepository.ensureLoaded()
SimklSyncRepository.ensureLoaded()
publish(SimklSyncRepository.state.value)
}
suspend fun refreshNow() {
SimklSyncRepository.refreshNow()
publish(SimklSyncRepository.state.value)
}
fun isInWatchlist(contentId: String, contentType: String? = null): Boolean {
ensureLoaded()
return uiState.value.items.any { item ->
item.id == contentId && (contentType == null || item.type.equals(contentType, ignoreCase = true))
}
}
suspend fun setWatchlistMembership(
profileId: Int,
item: LibraryItem,
isMember: Boolean,
) {
if (profileId != ProfileRepository.activeProfileId) return
ensureLoaded()
val snapshot = SimklSyncRepository.state.value.snapshot
val media = snapshot.mediaReference(
contentId = item.id,
contentType = item.type,
title = item.name,
releaseInfo = item.releaseInfo,
)
val result = if (isMember) {
SimklMutationRepository.moveToList(
profileId = profileId,
items = listOf(media),
destination = TrackingListStatus.PLAN_TO_WATCH,
)
} else {
require(snapshot.canSafelyRemoveFromSimklWatchlist(item.id)) {
"Removing this item from Simkl would also clear watched history or a rating"
}
SimklMutationRepository.removeFromList(profileId = profileId, items = listOf(media))
}
check(result.isComplete) {
"Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} library items"
}
refreshNow()
}
private fun publish(syncState: SimklSyncUiState) {
_uiState.value = SimklLibraryUiState(
items = syncState.snapshot.toSimklLibraryItems(),
isLoading = syncState.isLoading,
hasLoaded = syncState.hasLoaded,
errorMessage = syncState.errorMessage,
)
}
}
object SimklWatchedSyncAdapter : WatchedSyncAdapter {
override suspend fun pull(profileId: Int, pageSize: Int): List<WatchedItem> {
if (profileId != ProfileRepository.activeProfileId) return emptyList()
SimklSyncRepository.ensureFresh()
return SimklSyncRepository.state.value.snapshot.toSimklWatchedProjection().items
}
override suspend fun pullFullyWatchedSeriesKeys(profileId: Int): Set<String>? {
if (profileId != ProfileRepository.activeProfileId) return null
SimklSyncRepository.ensureFresh()
return SimklSyncRepository.state.value.snapshot.toSimklWatchedProjection().fullyWatchedSeriesKeys
}
override suspend fun push(profileId: Int, items: Collection<WatchedItem>) {
if (profileId != ProfileRepository.activeProfileId || items.isEmpty()) return
SimklSyncRepository.ensureLoaded()
val snapshot = SimklSyncRepository.state.value.snapshot
val historyItems = items.map { item ->
TrackingHistoryItem(
media = snapshot.mediaReference(
contentId = item.id,
contentType = item.type,
title = item.name,
releaseInfo = item.releaseInfo,
season = item.season,
episode = item.episode,
),
watchedAtEpochMs = item.markedAtEpochMs,
)
}
val result = SimklMutationRepository.addToHistory(profileId = profileId, items = historyItems)
check(result.isComplete) {
"Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} watched items"
}
}
override suspend fun delete(profileId: Int, items: Collection<WatchedItem>) {
if (profileId != ProfileRepository.activeProfileId || items.isEmpty()) return
SimklSyncRepository.ensureLoaded()
val snapshot = SimklSyncRepository.state.value.snapshot
val media = items.map { item ->
snapshot.mediaReference(
contentId = item.id,
contentType = item.type,
title = item.name,
releaseInfo = item.releaseInfo,
season = item.season,
episode = item.episode,
)
}
val result = SimklMutationRepository.removeFromHistory(profileId = profileId, items = media)
check(result.isComplete) {
"Simkl could not match ${result.notFoundCount} of ${result.attemptedCount} watched items"
}
}
}
data class SimklProgressUiState(
val entries: List<WatchProgressEntry> = emptyList(),
val isLoading: Boolean = false,
val hasLoadedRemoteProgress: Boolean = false,
val errorMessage: String? = null,
)
object SimklProgressRepository {
private val log = Logger.withTag("SimklProgress")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(SimklProgressUiState())
val uiState: StateFlow<SimklProgressUiState> = _uiState.asStateFlow()
init {
scope.launch {
SimklSyncRepository.state.collectLatest(::publish)
}
}
fun ensureLoaded() {
SimklAuthRepository.ensureLoaded()
SimklSyncRepository.ensureLoaded()
publish(SimklSyncRepository.state.value)
}
suspend fun refreshNow() {
SimklSyncRepository.refreshNow()
publish(SimklSyncRepository.state.value)
}
suspend fun removeProgress(entries: Collection<WatchProgressEntry>) {
val sessionIds = entries.mapNotNullTo(linkedSetOf()) { entry ->
entry.progressKey
?.removePrefix(SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX)
?.takeIf { entry.progressKey.startsWith(SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX) }
?.toLongOrNull()
?.takeIf { it > 0L }
}
if (sessionIds.isEmpty()) return
val removed = linkedSetOf<Long>()
for (sessionId in sessionIds) {
try {
SimklApi.client.execute(
SimklApiRequest(
method = SimklHttpMethod.DELETE,
path = "/sync/playback/$sessionId",
),
)
removed += sessionId
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.w { "Failed to remove Simkl playback $sessionId: ${error.message}" }
}
}
SimklSyncRepository.commitPlaybackRemoval(removed)
if (removed.isNotEmpty()) SimklSyncRepository.refreshAsync()
}
private fun publish(syncState: SimklSyncUiState) {
_uiState.value = SimklProgressUiState(
entries = syncState.snapshot.toSimklProgressEntries(),
isLoading = syncState.isLoading,
hasLoadedRemoteProgress = syncState.hasLoaded && syncState.errorMessage == null,
errorMessage = syncState.errorMessage,
)
}
}
private fun SimklSyncSnapshot.canSafelyRemoveFromSimklWatchlist(contentId: String): Boolean =
entries.firstOrNull { entry -> entry.media?.canonicalContentId().equals(contentId, ignoreCase = true) }
?.let { entry ->
entry.status == SimklListStatus.PLAN_TO_WATCH &&
entry.lastWatchedAt == null &&
entry.userRating == null &&
entry.seasons.none { season -> season.episodes.any { episode -> episode.watchedAt != null } }
}
?: true
private const val SIMKL_PLAYBACK_PROGRESS_KEY_PREFIX = "simkl-playback:"

View file

@ -105,6 +105,17 @@ object SimklSyncRepository : TrackingProfileStore {
}
}
internal fun commitPlaybackRemoval(sessionIds: Set<Long>) {
if (sessionIds.isEmpty()) return
ensureLoaded()
val current = _state.value
val updatedPlayback = current.snapshot.playback.filterNot { session -> session.id in sessionIds }
if (updatedPlayback.size == current.snapshot.playback.size) return
val updatedSnapshot = current.snapshot.copy(playback = updatedPlayback)
SimklSyncStorage.savePayload(json.encodeToString(updatedSnapshot))
_state.value = current.copy(snapshot = updatedSnapshot)
}
override fun onProfileChanged() {
profileGeneration += 1L
hasLoaded = false

View file

@ -3,13 +3,16 @@ package com.nuvio.app.features.watched
import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.simkl.SimklWatchedSyncAdapter
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.WatchProgressSource
import com.nuvio.app.features.trakt.shouldUseTraktProgress
import com.nuvio.app.features.tracking.effectiveWatchProgressSource
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter
import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter
import com.nuvio.app.features.watching.sync.WatchedDeltaEvent
@ -140,16 +143,18 @@ object WatchedRepository {
private var deltaInitialized: Boolean = false
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
internal var traktSyncAdapter: WatchedSyncAdapter = TraktWatchedSyncAdapter
internal var simklSyncAdapter: WatchedSyncAdapter = SimklWatchedSyncAdapter
fun ensureLoaded() {
TraktAuthRepository.ensureLoaded()
ensureTrackingProvidersRegistered()
TrackingProviderRegistry.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
if (!hasLoaded) {
loadFromDisk(ProfileRepository.activeProfileId)
activateEffectiveSource(
effectiveWatchedSource(
requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource,
isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value,
connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(),
),
)
}
@ -293,26 +298,26 @@ object WatchedRepository {
)
suspend fun pullFromServer(profileId: Int) {
TraktAuthRepository.ensureLoaded()
TrackingProviderRegistry.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
refreshForSource(
profileId = profileId,
source = effectiveWatchedSource(
requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource,
isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value,
connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(),
),
forceSnapshot = false,
)
}
suspend fun forceSnapshotRefreshFromServer(profileId: Int) {
TraktAuthRepository.ensureLoaded()
TrackingProviderRegistry.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
refreshForSource(
profileId = profileId,
source = effectiveWatchedSource(
requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource,
isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value,
connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(),
),
forceSnapshot = true,
)
@ -323,7 +328,7 @@ object WatchedRepository {
source: WatchProgressSource,
forceSnapshot: Boolean = true,
): Boolean {
TraktAuthRepository.ensureLoaded()
TrackingProviderRegistry.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
if (ProfileRepository.activeProfileId != profileId) {
log.d { "Skipping watched refresh for inactive profile $profileId" }
@ -353,7 +358,12 @@ object WatchedRepository {
profileId = profileId,
resetDeltaState = true,
)
WatchProgressSource.SIMKL -> false
WatchProgressSource.SIMKL -> pullSnapshotFromAdapter(
adapter = simklSyncAdapter,
operation = operation,
profileId = profileId,
resetDeltaState = true,
)
WatchProgressSource.NUVIO_SYNC -> if (forceSnapshot) {
refreshNuvioSnapshot(
operation = operation,
@ -412,6 +422,7 @@ object WatchedRepository {
profileId = profileId,
pageSize = watchedItemsPageSize,
)
val fullyWatchedSeriesKeys = adapter.pullFullyWatchedSeriesKeys(profileId)
if (!isActiveOperation(operation)) return false
val localAtApply = itemsForSource(operation.sourceOperation.source).values.toList()
@ -431,6 +442,9 @@ object WatchedRepository {
simklItems = simklItemsByKey,
replacement = mergedSnapshot.items,
)
fullyWatchedSeriesKeys?.let { keys ->
setFullyWatchedSeriesKeysForSource(operation.sourceOperation.source, keys)
}
when (operation.sourceOperation.source) {
WatchProgressSource.NUVIO_SYNC -> {
nuvioDirtyWatchedKeys = mergedSnapshot.dirtyKeys.toMutableSet()
@ -977,22 +991,31 @@ object WatchedRepository {
traktHistorySync: WatchedTraktHistorySync,
source: WatchProgressSource,
): Boolean {
val shouldMirrorToTrakt = shouldMirrorWatchedMarkToTraktHistory(
sync = traktHistorySync,
isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value,
)
if (source == WatchProgressSource.TRAKT) {
if (!shouldMirrorToTrakt) return false
traktSyncAdapter.push(profileId = profileId, items = items)
return true
var anySucceeded = false
if (source == WatchProgressSource.NUVIO_SYNC) {
try {
syncAdapter.push(profileId = profileId, items = items)
anySucceeded = true
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to push watched items to Nuvio Sync" }
}
}
syncAdapter.push(profileId = profileId, items = items)
if (shouldMirrorToTrakt) {
traktSyncAdapter.push(profileId = profileId, items = items)
if (traktHistorySync == WatchedTraktHistorySync.Mirror) {
connectedTrackerSyncAdapters().forEach { (providerId, adapter) ->
try {
adapter.push(profileId = profileId, items = items)
anySucceeded = true
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to push watched items to ${providerId.storageId}" }
}
}
}
return true
return anySucceeded
}
private suspend fun deleteFromTargetsForSource(
@ -1000,17 +1023,33 @@ object WatchedRepository {
items: Collection<WatchedItem>,
source: WatchProgressSource,
) {
if (source == WatchProgressSource.TRAKT) {
traktSyncAdapter.delete(profileId = profileId, items = items)
return
if (source == WatchProgressSource.NUVIO_SYNC) {
try {
syncAdapter.delete(profileId = profileId, items = items)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to delete watched items from Nuvio Sync" }
}
}
syncAdapter.delete(profileId = profileId, items = items)
if (TraktAuthRepository.isAuthenticated.value) {
traktSyncAdapter.delete(profileId = profileId, items = items)
connectedTrackerSyncAdapters().forEach { (providerId, adapter) ->
try {
adapter.delete(profileId = profileId, items = items)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.e(error) { "Failed to delete watched items from ${providerId.storageId}" }
}
}
}
private fun connectedTrackerSyncAdapters(): List<Pair<TrackingProviderId, WatchedSyncAdapter>> =
listOf(
TrackingProviderId.TRAKT to traktSyncAdapter,
TrackingProviderId.SIMKL to simklSyncAdapter,
).filter { (providerId, _) -> TrackingProviderRegistry.isAuthenticated(providerId) }
private fun accountScopeSnapshot(): CoroutineScope =
synchronized(accountScopeLock) {
accountScope
@ -1079,20 +1118,23 @@ internal fun acknowledgeSuccessfulWatchedPush(
internal fun shouldUseTraktWatchedSync(
isAuthenticated: Boolean,
source: WatchProgressSource,
): Boolean = shouldUseTraktProgress(
isAuthenticated = isAuthenticated,
source = source,
)
): Boolean = isAuthenticated && source == WatchProgressSource.TRAKT
internal fun effectiveWatchedSource(
requestedSource: WatchProgressSource,
isTraktAuthenticated: Boolean,
): WatchProgressSource =
if (shouldUseTraktWatchedSync(isAuthenticated = isTraktAuthenticated, source = requestedSource)) {
WatchProgressSource.TRAKT
} else {
WatchProgressSource.NUVIO_SYNC
}
): WatchProgressSource = effectiveWatchedSource(
requestedSource = requestedSource,
connectedProviderIds = if (isTraktAuthenticated) setOf(TrackingProviderId.TRAKT) else emptySet(),
)
internal fun effectiveWatchedSource(
requestedSource: WatchProgressSource,
connectedProviderIds: Set<TrackingProviderId>,
): WatchProgressSource = effectiveWatchProgressSource(
requestedSource = requestedSource,
isProviderAuthenticated = { providerId -> providerId in connectedProviderIds },
)
private fun String.isSeriesLikeWatchedType(): Boolean =
trim().lowercase() in setOf("series", "show", "tv", "tvshow")

View file

@ -19,6 +19,8 @@ interface WatchedSyncAdapter {
pageSize: Int,
): List<WatchedItem>
suspend fun pullFullyWatchedSeriesKeys(profileId: Int): Set<String>? = null
suspend fun getDeltaCursor(profileId: Int): Long? = null
suspend fun pullDelta(

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.watchprogress
import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered
import com.nuvio.app.features.addons.AddonManifest
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.AddonsUiState
@ -11,11 +12,14 @@ 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.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
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.tracking.WatchProgressSource
import com.nuvio.app.features.trakt.effectiveWatchProgressSource
import com.nuvio.app.features.trakt.isTraktCompatibleId
import com.nuvio.app.features.trakt.resolveEffectiveContentId
import com.nuvio.app.features.watching.application.WatchingActions
@ -67,6 +71,34 @@ private data class MetadataProviderReadiness(
get() = providers.isNotEmpty()
}
internal fun mergeTrackerProgressEntries(
remoteEntries: Collection<WatchProgressEntry>,
localEntries: Collection<WatchProgressEntry>,
): List<WatchProgressEntry> {
val newestByMedia = linkedMapOf<String, WatchProgressEntry>()
remoteEntries.forEach { entry ->
newestByMedia[entry.trackerMediaIdentity()] = entry
}
localEntries.forEach { entry ->
val key = entry.trackerMediaIdentity()
val existing = newestByMedia[key]
if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) {
newestByMedia[key] = entry
}
}
return newestByMedia.values.toList()
}
private fun WatchProgressEntry.trackerMediaIdentity(): String = buildString {
append(parentMetaType.trim().lowercase())
append(':')
append(parentMetaId.trim().lowercase())
append(':')
append(seasonNumber ?: -1)
append(':')
append(episodeNumber ?: -1)
}
internal class MetadataResolutionRetryCoordinator {
private val lock = SynchronizedObject()
private var generation = 0L
@ -243,6 +275,14 @@ object WatchProgressRepository {
}
}
syncScope.launch {
SimklProgressRepository.uiState.collectLatest {
if (activeSource == WatchProgressSource.SIMKL) {
publish()
}
}
}
syncScope.launch {
AddonRepository.uiState.collectLatest { state ->
retryMetadataResolutionWhenAddonMetaProvidersReady(state)
@ -252,14 +292,16 @@ object WatchProgressRepository {
}
fun ensureLoaded() {
TraktAuthRepository.ensureLoaded()
ensureTrackingProvidersRegistered()
TrackingProviderRegistry.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktProgressRepository.ensureLoaded()
SimklProgressRepository.ensureLoaded()
if (!hasLoaded) {
updateActiveSource(
effectiveWatchProgressSource(
isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value,
requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource,
isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated,
),
)
loadFromDisk(ProfileRepository.activeProfileId)
@ -271,8 +313,8 @@ object WatchProgressRepository {
TraktSettingsRepository.onProfileChanged()
updateActiveSource(
effectiveWatchProgressSource(
isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value,
requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource,
isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated,
),
)
loadFromDisk(profileId)
@ -372,9 +414,10 @@ object WatchProgressRepository {
}
internal fun activateSource(source: WatchProgressSource) {
TraktAuthRepository.ensureLoaded()
TrackingProviderRegistry.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktProgressRepository.ensureLoaded()
SimklProgressRepository.ensureLoaded()
if (!hasLoaded) {
loadFromDisk(ProfileRepository.activeProfileId)
}
@ -385,10 +428,10 @@ object WatchProgressRepository {
updateActiveSource(source)
cancelMetadataResolution(resetProviderHistory = false)
if (source == WatchProgressSource.TRAKT) {
TraktProgressRepository.clearLocalState()
} else {
hasLoadedNuvioRemoteProgress = false
when (source) {
WatchProgressSource.TRAKT -> TraktProgressRepository.clearLocalState()
WatchProgressSource.NUVIO_SYNC -> hasLoadedNuvioRemoteProgress = false
WatchProgressSource.SIMKL -> Unit
}
publish()
if (source == WatchProgressSource.NUVIO_SYNC) {
@ -426,7 +469,33 @@ object WatchProgressRepository {
force = force,
)
WatchProgressSource.SIMKL -> false
WatchProgressSource.SIMKL -> refreshSimklSource(
profileId = profileId,
operationGeneration = operationGeneration,
)
}
}
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
}
}
@ -1050,6 +1119,21 @@ object WatchProgressRepository {
if (videoIds.isEmpty()) return
val useTraktProgress = shouldUseTraktProgress()
if (shouldUseSimklProgress()) {
val entriesToRemove = currentEntries().filter { entry ->
entry.videoId in videoIds &&
(parentMetaId == null || entry.parentMetaId == parentMetaId)
}
val locallyRemovedEntries = removeStoredLocalEntries(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 &&
@ -1127,6 +1211,16 @@ object WatchProgressRepository {
}
if (entriesToRemove.isEmpty()) return
if (shouldUseSimklProgress()) {
val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove)
if (locallyRemovedEntries.isNotEmpty()) persist()
publish()
syncScope.launch {
SimklProgressRepository.removeProgress(entriesToRemove)
}
return
}
if (useTraktProgress) {
val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove)
TraktProgressRepository.applyOptimisticRemoval(
@ -1172,11 +1266,7 @@ object WatchProgressRepository {
episodeNumber: Int? = null,
): WatchProgressEntry? {
ensureLoaded()
return if (shouldUseTraktProgress()) {
TraktProgressRepository.uiState.value.entries
} else {
localEntriesSnapshot()
}.resolveProgressForVideo(
return currentEntries().resolveProgressForVideo(
videoId = videoId,
parentMetaId = parentMetaId,
seasonNumber = seasonNumber,
@ -1357,7 +1447,7 @@ object WatchProgressRepository {
}
private fun pushDeleteToServer(entries: Collection<WatchProgressEntry>) {
if (shouldUseTraktProgress()) return
if (activeSource != WatchProgressSource.NUVIO_SYNC) return
val profileId = currentProfileId
accountScopeSnapshot().launch {
runCatching {
@ -1372,10 +1462,10 @@ object WatchProgressRepository {
private fun publish() {
val entries = currentEntries()
val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs }
val hasLoadedRemoteProgress = if (shouldUseTraktProgress()) {
TraktProgressRepository.uiState.value.hasLoadedRemoteProgress
} else {
hasLoadedNuvioRemoteProgress
val hasLoadedRemoteProgress = when (activeSource) {
WatchProgressSource.TRAKT -> TraktProgressRepository.uiState.value.hasLoadedRemoteProgress
WatchProgressSource.SIMKL -> SimklProgressRepository.uiState.value.hasLoadedRemoteProgress
WatchProgressSource.NUVIO_SYNC -> hasLoadedNuvioRemoteProgress
}
_uiState.value = WatchProgressUiState(
entries = sortedEntries,
@ -1471,6 +1561,9 @@ object WatchProgressRepository {
private fun shouldUseTraktProgress(): Boolean =
activeSource == WatchProgressSource.TRAKT
private fun shouldUseSimklProgress(): Boolean =
activeSource == WatchProgressSource.SIMKL
private fun accountScopeSnapshot(): CoroutineScope = synchronized(accountScopeLock) {
accountScope
}
@ -1501,28 +1594,33 @@ object WatchProgressRepository {
}
private fun currentEntries(): List<WatchProgressEntry> {
return if (shouldUseTraktProgress()) {
// 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)
}
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
}
merged
}
} else {
localEntriesSnapshot()
WatchProgressSource.SIMKL -> mergeTrackerProgressEntries(
remoteEntries = SimklProgressRepository.uiState.value.entries,
localEntries = localEntriesSnapshot(),
)
WatchProgressSource.NUVIO_SYNC -> localEntriesSnapshot()
}
}

View file

@ -3,12 +3,14 @@ package com.nuvio.app.features.watchprogress
import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tracking.DEFAULT_WATCH_PROGRESS_SOURCE
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.WatchProgressSource
import com.nuvio.app.features.trakt.effectiveWatchProgressSource
import com.nuvio.app.features.tracking.effectiveWatchProgressSource
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.watched.WatchedRepository
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.locks.SynchronizedObject
@ -234,15 +236,15 @@ object WatchProgressSourceCoordinator {
observeJob = scope.launch {
combine(
TraktSettingsRepository.uiState,
TraktAuthRepository.isAuthenticated,
TrackingProviderRegistry.connectedProviderIds,
AuthRepository.state,
ProfileRepository.state,
) { settings, isTraktAuthenticated, authState, profileState ->
) { settings, connectedProviderIds, authState, profileState ->
buildContext(
profileId = profileState.activeProfile?.profileIndex
?: ProfileRepository.activeProfileId,
requestedSource = settings.watchProgressSource,
isTraktAuthenticated = isTraktAuthenticated,
connectedProviderIds = connectedProviderIds,
authState = authState,
)
}
@ -260,7 +262,8 @@ object WatchProgressSourceCoordinator {
}
private fun ensureSourceStateLoaded() {
TraktAuthRepository.ensureLoaded()
ensureTrackingProvidersRegistered()
TrackingProviderRegistry.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
}
@ -464,14 +467,14 @@ object WatchProgressSourceCoordinator {
private fun buildContext(
profileId: Int,
requestedSource: WatchProgressSource,
isTraktAuthenticated: Boolean,
connectedProviderIds: Set<TrackingProviderId>,
authState: AuthState,
): WatchProgressSourceContext = WatchProgressSourceContext(
profileId = profileId,
requestedSource = requestedSource,
effectiveSource = effectiveWatchProgressSource(
isTraktAuthenticated = isTraktAuthenticated,
requestedSource = requestedSource,
isProviderAuthenticated = { providerId -> providerId in connectedProviderIds },
),
isNuvioAuthenticated = authState is AuthState.Authenticated && !authState.isAnonymous,
)
@ -479,7 +482,7 @@ object WatchProgressSourceCoordinator {
private fun currentContext(profileId: Int): WatchProgressSourceContext = buildContext(
profileId = profileId,
requestedSource = TraktSettingsRepository.uiState.value.watchProgressSource,
isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value,
connectedProviderIds = TrackingProviderRegistry.connectedProviderIdsSnapshot(),
authState = AuthRepository.state.value,
)
}

View file

@ -238,6 +238,24 @@ class WatchedRepositoryTest {
)
}
@Test
fun effectiveWatchedSource_selectsConnectedSimkl() {
assertEquals(
WatchProgressSource.SIMKL,
effectiveWatchedSource(
requestedSource = WatchProgressSource.SIMKL,
connectedProviderIds = setOf(com.nuvio.app.features.tracking.TrackingProviderId.SIMKL),
),
)
assertEquals(
WatchProgressSource.NUVIO_SYNC,
effectiveWatchedSource(
requestedSource = WatchProgressSource.SIMKL,
connectedProviderIds = emptySet(),
),
)
}
@Test
fun sourceOperationGuard_rejectsResultFromSourceActiveBeforeSwitch() {
val traktOperation = WatchedSourceOperation(

View file

@ -9,6 +9,36 @@ import kotlin.test.assertTrue
class WatchProgressIdentityTest {
@Test
fun `tracker progress merge keeps one entry per media and favors fresher local playback`() {
val remote = entry(
progressKey = "simkl-playback:42",
lastUpdatedEpochMs = 100L,
lastPositionMs = 400L,
)
val staleLocal = entry(lastUpdatedEpochMs = 90L, lastPositionMs = 300L)
val otherLocal = entry(
parentMetaId = "other",
videoId = "other:1:2",
lastUpdatedEpochMs = 80L,
)
val firstMerge = mergeTrackerProgressEntries(
remoteEntries = listOf(remote),
localEntries = listOf(staleLocal, otherLocal),
)
assertEquals(2, firstMerge.size)
assertEquals("simkl-playback:42", firstMerge.single { it.parentMetaId == "show" }.progressKey)
val freshLocal = staleLocal.copy(lastUpdatedEpochMs = 110L, lastPositionMs = 500L)
val secondMerge = mergeTrackerProgressEntries(
remoteEntries = listOf(remote),
localEntries = listOf(freshLocal),
)
assertEquals(500L, secondMerge.single().lastPositionMs)
}
@Test
fun `provider change during metadata batch schedules one follow up`() {
val coordinator = MetadataResolutionRetryCoordinator()