mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-27 15:02:25 +00:00
feat(simkl): wire tracker state into app repositories
This commit is contained in:
parent
caf124e1b4
commit
0f12e61430
10 changed files with 757 additions and 160 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue