From 410a9d4c4bd16dfebbcf9de2ec3e9869ed2fb14a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:25:32 +0530 Subject: [PATCH] refactor(tracking): register library read providers --- .../composeResources/values/strings.xml | 3 + .../commonMain/kotlin/com/nuvio/app/App.kt | 14 +- .../tracking/TrackingProviderBootstrap.kt | 5 + ...rDialog.kt => TrackingListPickerDialog.kt} | 14 +- .../app/features/details/MetaDetailsScreen.kt | 10 +- .../app/features/library/LibraryRepository.kt | 384 +++++------------- .../simkl/SimklApplicationAdapters.kt | 75 ++++ .../app/features/tracking/TrackingProvider.kt | 18 + .../app/features/tracking/TrackingReads.kt | 53 +++ .../trakt/TraktTrackingLibraryProvider.kt | 86 ++++ .../features/library/LibraryRepositoryTest.kt | 12 +- 11 files changed, 372 insertions(+), 302 deletions(-) rename composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/{TraktListPickerDialog.kt => TrackingListPickerDialog.kt} (95%) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 3d6b89d90..b170f2b4b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -515,6 +515,8 @@ Licensed under the Apache License, Version 2.0. Loading your Trakt lists… Choose where to save this title on Trakt + Loading lists… + Choose where to save this title Donate Go to details Remove @@ -1442,6 +1444,7 @@ Unable to play trailer Failed to load Trakt lists Failed to update Trakt lists + Failed to update tracking lists %1$s • %2$s Update check failed Download failed diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index a50dee88a..38f74df32 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -117,7 +117,7 @@ import com.nuvio.app.core.ui.NuvioToastHost import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.NuvioFloatingPrompt import com.nuvio.app.core.ui.ProfileMeshBackground -import com.nuvio.app.core.ui.TraktListPickerDialog +import com.nuvio.app.core.ui.TrackingListPickerDialog import com.nuvio.app.core.ui.NuvioTheme import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding @@ -224,7 +224,7 @@ import com.nuvio.app.features.tracking.TrackingScrobbleAction import com.nuvio.app.features.tracking.TrackingScrobbleCoordinator import com.nuvio.app.features.tracking.TrackingScrobbleEvent import com.nuvio.app.features.tracking.buildTrackingMediaReference -import com.nuvio.app.features.trakt.TraktListTab +import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.AppUpdaterPlatform import com.nuvio.app.features.updater.rememberAppUpdaterController @@ -814,7 +814,7 @@ private fun MainAppContent( var showLibraryListPicker by remember { mutableStateOf(false) } var pickerItem by remember { mutableStateOf(null) } var pickerTitle by remember { mutableStateOf("") } - var pickerTabs by remember { mutableStateOf>(emptyList()) } + var pickerTabs by remember { mutableStateOf>(emptyList()) } var pickerMembership by remember { mutableStateOf>(emptyMap()) } var pickerPending by remember { mutableStateOf(false) } var pickerError by remember { mutableStateOf(null) } @@ -3341,7 +3341,7 @@ private fun MainAppContent( } }.onFailure { error -> NuvioToastController.show( - error.message ?: getString(Res.string.trakt_lists_update_failed), + error.message ?: getString(Res.string.tracking_lists_update_failed), ) } } @@ -3491,7 +3491,7 @@ private fun MainAppContent( }, ) - TraktListPickerDialog( + TrackingListPickerDialog( visible = showLibraryListPicker, title = pickerTitle, tabs = pickerTabs, @@ -3511,7 +3511,7 @@ private fun MainAppContent( } }, onSave = { - val item = pickerItem ?: return@TraktListPickerDialog + val item = pickerItem ?: return@TrackingListPickerDialog coroutineScope.launch { pickerPending = true pickerError = null @@ -3525,7 +3525,7 @@ private fun MainAppContent( pickerItem = null pickerError = null }.onFailure { error -> - pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed) + pickerError = error.message ?: getString(Res.string.tracking_lists_update_failed) } pickerPending = false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt index 34a9f27af..7a9a39f3b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt @@ -4,9 +4,12 @@ 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.SimklTrackingLibraryProvider import com.nuvio.app.features.simkl.SimklSyncRepository +import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktScrobbleRepository +import com.nuvio.app.features.trakt.TraktTrackingLibraryProvider fun ensureTrackingProvidersRegistered() { TraktAuthRepository.descriptor @@ -16,4 +19,6 @@ fun ensureTrackingProvidersRegistered() { SimklLibraryRepository.uiState SimklProgressRepository.uiState SimklMutationRepository.ensureRegistered() + TrackingProviderRegistry.registerLibraryProvider(TraktTrackingLibraryProvider) + TrackingProviderRegistry.registerLibraryProvider(SimklTrackingLibraryProvider) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TrackingListPickerDialog.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TrackingListPickerDialog.kt index 155661350..135ee9516 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TrackingListPickerDialog.kt @@ -25,20 +25,20 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.nuvio.app.features.trakt.TraktListTab +import com.nuvio.app.features.tracking.TrackingLibraryTab import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.action_save -import nuvio.composeapp.generated.resources.compose_trakt_list_picker_loading -import nuvio.composeapp.generated.resources.compose_trakt_list_picker_subtitle +import nuvio.composeapp.generated.resources.compose_tracking_list_picker_loading +import nuvio.composeapp.generated.resources.compose_tracking_list_picker_subtitle import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TraktListPickerDialog( +fun TrackingListPickerDialog( visible: Boolean, title: String, - tabs: List, + tabs: List, membership: Map, isPending: Boolean, errorMessage: String?, @@ -67,7 +67,7 @@ fun TraktListPickerDialog( color = tokens.colors.textPrimary, ) Text( - text = stringResource(Res.string.compose_trakt_list_picker_subtitle), + text = stringResource(Res.string.compose_tracking_list_picker_subtitle), style = MaterialTheme.typography.bodyMedium, color = tokens.colors.textMuted, ) @@ -95,7 +95,7 @@ fun TraktListPickerDialog( modifier = Modifier.size(tokens.icons.lg), ) Text( - text = stringResource(Res.string.compose_trakt_list_picker_loading), + text = stringResource(Res.string.compose_tracking_list_picker_loading), style = MaterialTheme.typography.bodyMedium, color = tokens.colors.textMuted, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 87380ec34..c3b269788 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -82,7 +82,7 @@ import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay import com.nuvio.app.core.ui.PosterZoomAnchor import com.nuvio.app.core.ui.PosterZoomAnchorHolder import com.nuvio.app.core.ui.PosterZoomOverlayAction -import com.nuvio.app.core.ui.TraktListPickerDialog +import com.nuvio.app.core.ui.TrackingListPickerDialog import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.rememberHeroStretchState import dev.chrisbanes.haze.hazeSource @@ -115,7 +115,7 @@ import com.nuvio.app.features.trakt.TraktCommentReview import com.nuvio.app.features.trakt.TraktCommentsRepository import com.nuvio.app.features.trakt.TraktCommentsSettings import com.nuvio.app.features.trakt.TraktConnectionMode -import com.nuvio.app.features.trakt.TraktListTab +import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.trailer.TrailerPlaybackResolver @@ -213,7 +213,7 @@ fun MetaDetailsScreen( var selectedComment by remember(type, id) { mutableStateOf(null) } val detailsScope = rememberCoroutineScope() var showLibraryListPicker by remember(type, id) { mutableStateOf(false) } - var pickerTabs by remember(type, id) { mutableStateOf>(emptyList()) } + var pickerTabs by remember(type, id) { mutableStateOf>(emptyList()) } var pickerMembership by remember(type, id) { mutableStateOf>(emptyMap()) } var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerError by remember(type, id) { mutableStateOf(null) } @@ -1212,7 +1212,7 @@ fun MetaDetailsScreen( ) } - TraktListPickerDialog( + TrackingListPickerDialog( visible = showLibraryListPicker, title = meta.name, tabs = pickerTabs, @@ -1241,7 +1241,7 @@ fun MetaDetailsScreen( }.onSuccess { showLibraryListPicker = false }.onFailure { error -> - pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed) + pickerError = error.message ?: getString(Res.string.tracking_lists_update_failed) } pickerPending = false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 3aa17583c..441c21f32 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -9,18 +9,13 @@ 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.TrackingLibraryProvider +import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingLibraryTabKind import com.nuvio.app.features.tracking.TrackingProviderRegistry +import com.nuvio.app.features.tracking.TrackingSettingsRepository 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.tracking.providerId import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlinx.atomicfu.locks.SynchronizedObject @@ -52,7 +47,7 @@ import kotlinx.serialization.json.put import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.library_local_tab_title import nuvio.composeapp.generated.resources.library_other -import nuvio.composeapp.generated.resources.trakt_lists_update_failed +import nuvio.composeapp.generated.resources.tracking_lists_update_failed import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString @@ -97,55 +92,34 @@ object LibraryRepository { private val lastPersistedContentRevisionByProfile = mutableMapOf() init { + ensureTrackingProvidersRegistered() syncScope.launch { - TrackingProviderRegistry.connectedProviderIds.collectLatest { connectedProviderIds -> - if (TrackingProviderId.TRAKT in connectedProviderIds) { - TraktLibraryRepository.preloadListTabsAsync() - 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" } } + TrackingProviderRegistry.connectedProviderIds.collectLatest { + TrackingProviderRegistry.connectedLibraryProviders().forEach(TrackingLibraryProvider::prepare) + activeLibraryProvider()?.let { provider -> + refreshLibraryProvider(provider, "authentication change") } publish() } } syncScope.launch { - TraktSettingsRepository.uiState + TrackingSettingsRepository.uiState .map { it.librarySourceMode } .distinctUntilChanged() - .collectLatest { source -> - when (resolveEffectiveLibrarySourceMode(source, TrackingProviderRegistry::isAuthenticated)) { - LibrarySourceMode.TRAKT -> { - TraktLibraryRepository.preloadListTabsAsync() - publish() - refreshTraktLibraryAsync() - } - LibrarySourceMode.SIMKL -> { - publish() - refreshSimklLibraryAsync() - } - LibrarySourceMode.LOCAL -> publish() + .collectLatest { + publish() + activeLibraryProvider()?.let { provider -> + provider.prepare() + refreshLibraryProviderAsync(provider) } } } - syncScope.launch { - TraktLibraryRepository.uiState.collectLatest { - if (TraktAuthRepository.isAuthenticated.value) { - publish() - } - } - } - syncScope.launch { - SimklLibraryRepository.uiState.collectLatest { - if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) { - publish() + TrackingProviderRegistry.libraryProviders().forEach { provider -> + syncScope.launch { + provider.changes.collectLatest { + if (TrackingProviderRegistry.isAuthenticated(provider.providerId)) { + publish() + } } } } @@ -154,47 +128,32 @@ object LibraryRepository { fun ensureLoaded() { ensureTrackingProvidersRegistered() TrackingProviderRegistry.ensureLoaded() - TraktSettingsRepository.ensureLoaded() - TraktLibraryRepository.ensureLoaded() - SimklLibraryRepository.ensureLoaded() + TrackingSettingsRepository.ensureLoaded() + TrackingProviderRegistry.libraryProviders().forEach(TrackingLibraryProvider::ensureLoaded) while (true) { val activeProfileId = ProfileRepository.activeProfileId val snapshot = localState.snapshot() if (snapshot.hasLoaded && snapshot.token.profileId == activeProfileId) break loadFromDisk(activeProfileId) } - if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) { - TraktLibraryRepository.preloadListTabsAsync() - if (isTraktLibrarySourceActive()) { - refreshTraktLibraryAsync() - } - } - if (isSimklLibrarySourceActive()) refreshSimklLibraryAsync() + TrackingProviderRegistry.connectedLibraryProviders().forEach(TrackingLibraryProvider::prepare) + activeLibraryProvider()?.let(::refreshLibraryProviderAsync) } fun onProfileChanged(profileId: Int) { val current = localState.snapshot() if (profileId == current.token.profileId && current.hasLoaded) return - TraktSettingsRepository.onProfileChanged() if (!loadFromDisk(profileId)) return - TraktAuthRepository.onProfileChanged() - TraktLibraryRepository.onProfileChanged() - if (TraktAuthRepository.isAuthenticated.value) { - TraktLibraryRepository.preloadListTabsAsync() - if (isTraktLibrarySourceActive()) { - refreshTraktLibraryAsync() - } - } - SimklLibraryRepository.ensureLoaded() - if (isSimklLibrarySourceActive()) refreshSimklLibraryAsync() + TrackingProviderRegistry.libraryProviders().forEach(TrackingLibraryProvider::onProfileChanged) + TrackingProviderRegistry.connectedLibraryProviders().forEach(TrackingLibraryProvider::prepare) + activeLibraryProvider()?.let(::refreshLibraryProviderAsync) } fun clearLocalState() { val transition = synchronized(loadLock) { localState.reset() } transition.detachedPushJob?.cancel() - TraktAuthRepository.clearLocalState() - TraktLibraryRepository.clearLocalState() + TrackingProviderRegistry.libraryProviders().forEach(TrackingLibraryProvider::clearLocalState) _uiState.value = LibraryUiState() } @@ -253,32 +212,11 @@ object LibraryRepository { return } - 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 - } - 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 + activeLibraryProvider()?.let { provider -> + refreshLibraryProvider(provider, "explicit pull") + if (!isActiveOperation(operationToken)) return + publish() + return } nuvioPullMutex.withLock { @@ -322,16 +260,20 @@ object LibraryRepository { fun toggleSaved(item: LibraryItem) { ensureLoaded() - if (isTraktLibrarySourceActive()) { + activeLibraryProvider()?.let { provider -> val profileId = localState.snapshot().token.profileId - log.i { "toggleSaved routed to Trakt library source item=${item.id} type=${item.type} profile=$profileId" } + log.i { + "toggleSaved routed to ${provider.providerId.storageId} library source " + + "item=${item.id} type=${item.type} profile=$profileId" + } syncScope.launch { - runCatching { TraktLibraryRepository.toggleWatchlist(item) } - .onFailure { e -> - log.e(e) { "Failed to toggle Trakt watchlist" } + runCatching { provider.toggleDefaultMembership(profileId, item) } + .onFailure { error -> + if (error is CancellationException) throw error + log.e(error) { "Failed to toggle ${provider.providerId.storageId} default library membership" } NuvioToastController.show( - e.message?.takeIf { it.isNotBlank() } - ?: getString(Res.string.trakt_lists_update_failed), + error.message?.takeIf(String::isNotBlank) + ?: getString(Res.string.tracking_lists_update_failed), ) } publish() @@ -339,29 +281,6 @@ 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()), ) @@ -422,20 +341,7 @@ object LibraryRepository { fun isSaved(id: String, type: String? = null): Boolean { ensureLoaded() - if (isTraktLibrarySourceActive()) { - if (type != null) { - return TraktLibraryRepository.isInAnyList(id, type) - } - val entry = TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } - if (entry != null) { - return TraktLibraryRepository.isInAnyList(entry.id, entry.type) - } - return false - } - - if (isSimklLibrarySourceActive()) { - return SimklLibraryRepository.isInWatchlist(id, type) - } + activeLibraryProvider()?.let { provider -> return provider.contains(id, type) } return if (type != null) { localState.contains(id, type) @@ -447,44 +353,25 @@ object LibraryRepository { fun savedItem(id: String): LibraryItem? { ensureLoaded() - if (isTraktLibrarySourceActive()) { - return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } - } - - if (isSimklLibrarySourceActive()) { - return SimklLibraryRepository.uiState.value.items.firstOrNull { it.id == id } - } + activeLibraryProvider()?.let { provider -> return provider.find(id) } return localState.findById(id) } - fun libraryListTabs(): List { - val traktTabs = if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) { - TraktLibraryRepository.currentListTabs() - } else { - emptyList() - } - val simklTabs = if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) { - listOf(simklLibraryListTab()) - } else { - emptyList() - } - return libraryTabsWithLocal(traktTabs + simklTabs) - } - - fun traktListTabs(): List = libraryListTabs() + fun libraryListTabs(): List = + libraryTabsWithLocal( + TrackingProviderRegistry.connectedLibraryProviders() + .flatMap { provider -> provider.snapshot().tabs }, + ) suspend fun getMembershipSnapshot(item: LibraryItem): Map { ensureLoaded() val inLocal = localState.contains(item.id, item.type) val memberships = linkedMapOf() - if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.TRAKT)) { - memberships += TraktLibraryRepository.getMembershipSnapshot(item).listMembership + TrackingProviderRegistry.connectedLibraryProviders().forEach { provider -> + memberships += provider.membership(item) } - if (TrackingProviderRegistry.isAuthenticated(TrackingProviderId.SIMKL)) { - memberships[SIMKL_WATCHLIST_KEY] = SimklLibraryRepository.isInWatchlist(item.id, item.type) - } - return libraryMembershipWithLocal(inLocal = inLocal, traktMembership = memberships) + return libraryMembershipWithLocal(inLocal = inLocal, providerMembership = memberships) } suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map) { @@ -506,38 +393,21 @@ object LibraryRepository { } 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()) { + TrackingProviderRegistry.connectedLibraryProviders().forEach { provider -> + val providerListKeys = provider.snapshot().tabs.mapTo(mutableSetOf(), TrackingLibraryTab::key) + val providerMembership = desiredMembership.filterKeys(providerListKeys::contains) + if (providerMembership.isNotEmpty()) { 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" } - } - } - } - 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( + provider.applyMembership( profileId = profileId, item = item, - isMember = desired, + desiredMembership = providerMembership, ) } catch (error: CancellationException) { throw error } catch (error: Throwable) { if (firstFailure == null) firstFailure = error - log.e(error) { "Failed to update Simkl library membership" } + log.e(error) { "Failed to update ${provider.providerId.storageId} library membership" } } } } @@ -634,59 +504,21 @@ object LibraryRepository { private fun publish() { val localSnapshot = localState.snapshot() - 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 - } - return + val sourceMode = effectiveLibrarySourceMode() + activeLibraryProvider(sourceMode)?.let { provider -> + val providerSnapshot = provider.snapshot() + val newUiState = LibraryUiState( + sourceMode = sourceMode, + items = providerSnapshot.items, + sections = providerSnapshot.sections, + isLoaded = providerSnapshot.hasLoaded, + isLoading = providerSnapshot.isLoading, + errorMessage = providerSnapshot.errorMessage, + ) + localState.runIfTokenCurrent(localSnapshot.token) { + _uiState.value = newUiState } - 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 + return } val items = localSnapshot.items @@ -732,28 +564,31 @@ object LibraryRepository { } } - private fun refreshTraktLibraryAsync() { + private fun refreshLibraryProviderAsync(provider: TrackingLibraryProvider) { syncScope.launch { - runCatching { TraktLibraryRepository.refreshNow() } - .onFailure { e -> log.e(e) { "Failed to refresh Trakt library" } } + refreshLibraryProvider(provider, "background refresh") publish() } } - 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 suspend fun refreshLibraryProvider( + provider: TrackingLibraryProvider, + reason: String, + ) { + try { + provider.refresh() + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { + "Failed to refresh ${provider.providerId.storageId} library during $reason" + } } } private fun selectedLibrarySourceMode(): LibrarySourceMode { - TraktSettingsRepository.ensureLoaded() - return TraktSettingsRepository.uiState.value.librarySourceMode + TrackingSettingsRepository.ensureLoaded() + return TrackingSettingsRepository.uiState.value.librarySourceMode } private fun effectiveLibrarySourceMode(): LibrarySourceMode = @@ -762,43 +597,36 @@ object LibraryRepository { isProviderAuthenticated = TrackingProviderRegistry::isAuthenticated, ) - private fun isTraktLibrarySourceActive(): Boolean = - effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT - - private fun isSimklLibrarySourceActive(): Boolean = - effectiveLibrarySourceMode() == LibrarySourceMode.SIMKL + private fun activeLibraryProvider( + sourceMode: LibrarySourceMode = effectiveLibrarySourceMode(), + ): TrackingLibraryProvider? = + sourceMode.providerId?.let(TrackingProviderRegistry::libraryProvider) } internal const val LOCAL_LIBRARY_LIST_KEY = "local" private const val DEFAULT_LOCAL_LIBRARY_TAB_TITLE = "Nuvio Library" private const val DEFAULT_LIBRARY_OTHER_TITLE = "Other" -internal fun localLibraryListTab(): TraktListTab = - TraktListTab( +internal fun localLibraryListTab(): TrackingLibraryTab = + TrackingLibraryTab( key = LOCAL_LIBRARY_LIST_KEY, title = localizedStringOrDefault( resource = Res.string.library_local_tab_title, fallback = DEFAULT_LOCAL_LIBRARY_TAB_TITLE, ), - type = TraktListType.WATCHLIST, + providerId = null, + kind = TrackingLibraryTabKind.WATCHLIST, ) -internal fun simklLibraryListTab(): TraktListTab = - TraktListTab( - key = SIMKL_WATCHLIST_KEY, - title = SIMKL_WATCHLIST_TITLE, - type = TraktListType.WATCHLIST, - ) - -internal fun libraryTabsWithLocal(traktTabs: List): List = - listOf(localLibraryListTab()) + traktTabs +internal fun libraryTabsWithLocal(providerTabs: List): List = + listOf(localLibraryListTab()) + providerTabs internal fun libraryMembershipWithLocal( inLocal: Boolean, - traktMembership: Map = emptyMap(), + providerMembership: Map = emptyMap(), ): Map = linkedMapOf(LOCAL_LIBRARY_LIST_KEY to inLocal).apply { - putAll(traktMembership) + putAll(providerMembership) } internal fun libraryMembershipWithRemovedList( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt index b327b8b36..f9226a59f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklApplicationAdapters.kt @@ -2,9 +2,15 @@ package com.nuvio.app.features.simkl import co.touchlab.kermit.Logger import com.nuvio.app.features.library.LibraryItem +import com.nuvio.app.features.library.LibrarySection import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.tracking.TrackingHistoryItem +import com.nuvio.app.features.tracking.TrackingLibraryProvider +import com.nuvio.app.features.tracking.TrackingLibrarySnapshot +import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingLibraryTabKind import com.nuvio.app.features.tracking.TrackingListStatus +import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watching.sync.WatchedSyncAdapter import com.nuvio.app.features.watchprogress.WatchProgressEntry @@ -16,6 +22,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch data class SimklLibraryUiState( @@ -96,6 +104,73 @@ object SimklLibraryRepository { } } +object SimklTrackingLibraryProvider : TrackingLibraryProvider { + override val providerId: TrackingProviderId = TrackingProviderId.SIMKL + override val changes: Flow = SimklLibraryRepository.uiState.map { Unit } + + override fun ensureLoaded() = SimklLibraryRepository.ensureLoaded() + + override suspend fun refresh() = SimklLibraryRepository.refreshNow() + + override fun snapshot(): TrackingLibrarySnapshot { + val state = SimklLibraryRepository.uiState.value + val items = state.items.sortedByDescending(LibraryItem::savedAtEpochMs) + return TrackingLibrarySnapshot( + items = items, + sections = listOf( + LibrarySection( + type = SIMKL_WATCHLIST_KEY, + displayTitle = SIMKL_WATCHLIST_TITLE, + items = items, + ), + ), + tabs = listOf( + TrackingLibraryTab( + key = SIMKL_WATCHLIST_KEY, + title = SIMKL_WATCHLIST_TITLE, + providerId = TrackingProviderId.SIMKL, + kind = TrackingLibraryTabKind.WATCHLIST, + ), + ), + hasLoaded = state.hasLoaded, + isLoading = state.isLoading, + errorMessage = state.errorMessage, + ) + } + + override fun contains(contentId: String, contentType: String?): Boolean = + SimklLibraryRepository.isInWatchlist(contentId, contentType) + + override fun find(contentId: String): LibraryItem? = + SimklLibraryRepository.uiState.value.items.firstOrNull { item -> item.id == contentId } + + override suspend fun membership(item: LibraryItem): Map = + mapOf(SIMKL_WATCHLIST_KEY to SimklLibraryRepository.isInWatchlist(item.id, item.type)) + + override suspend fun applyMembership( + profileId: Int, + item: LibraryItem, + desiredMembership: Map, + ) { + val desired = desiredMembership[SIMKL_WATCHLIST_KEY] == true + if (desired != SimklLibraryRepository.isInWatchlist(item.id, item.type)) { + SimklLibraryRepository.setWatchlistMembership( + profileId = profileId, + item = item, + isMember = desired, + ) + } + } + + override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) { + SimklLibraryRepository.setWatchlistMembership( + profileId = profileId, + item = item, + isMember = !SimklLibraryRepository.isInWatchlist(item.id, item.type), + ) + } +} + object SimklWatchedSyncAdapter : WatchedSyncAdapter { override suspend fun pull(profileId: Int, pageSize: Int): List { if (profileId != ProfileRepository.activeProfileId) return emptyList() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt index 4c0d0619d..1b6c15384 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingProvider.kt @@ -74,6 +74,7 @@ object TrackingProviderRegistry { private val listWriters = mutableMapOf() private val historyWriters = mutableMapOf() private val scrobblers = mutableMapOf() + private val libraryProviders = mutableMapOf() private val _connectedProviderIds = MutableStateFlow>(emptySet()) val connectedProviderIds: StateFlow> = _connectedProviderIds.asStateFlow() @@ -113,6 +114,10 @@ object TrackingProviderRegistry { scrobblers[scrobbler.providerId] = scrobbler } + fun registerLibraryProvider(provider: TrackingLibraryProvider) = synchronized(lock) { + libraryProviders[provider.providerId] = provider + } + fun authProvider(id: TrackingProviderId): TrackingAuthProvider? = synchronized(lock) { authProviders[id] } @@ -144,6 +149,16 @@ object TrackingProviderRegistry { scrobblers[id] } + fun libraryProvider(id: TrackingProviderId): TrackingLibraryProvider? = synchronized(lock) { + libraryProviders[id] + } + + fun libraryProviders(): List = synchronized(lock) { + libraryProviders.entries + .sortedBy { (id, _) -> id.ordinal } + .map { (_, provider) -> provider } + } + fun connectedListWriters(): List = connectedPorts(listWriters, TrackingCapability.LIBRARY_WRITE) @@ -153,6 +168,9 @@ object TrackingProviderRegistry { fun connectedScrobblers(): List = connectedPorts(scrobblers, TrackingCapability.SCROBBLE) + fun connectedLibraryProviders(): List = + connectedPorts(libraryProviders, TrackingCapability.LIBRARY_READ) + fun handleAuthCallback(url: String): Boolean = providersWith(TrackingCapability.AUTHENTICATION) .any { provider -> provider.handleAuthCallback(url) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt new file mode 100644 index 000000000..55041ec1a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.tracking + +import com.nuvio.app.features.library.LibraryItem +import com.nuvio.app.features.library.LibrarySection +import kotlinx.coroutines.flow.Flow + +enum class TrackingLibraryTabKind { + WATCHLIST, + PERSONAL, +} + +data class TrackingLibraryTab( + val key: String, + val title: String, + val providerId: TrackingProviderId?, + val kind: TrackingLibraryTabKind, +) + +data class TrackingLibrarySnapshot( + val items: List = emptyList(), + val sections: List = emptyList(), + val tabs: List = emptyList(), + val hasLoaded: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null, +) + +/** + * Provider-owned projection of remote library state into application models. + * + * Application repositories consume this port through [TrackingProviderRegistry]; provider DTOs, + * list semantics, cache policy, and mutation details stay inside the provider package. + */ +interface TrackingLibraryProvider { + val providerId: TrackingProviderId + val changes: Flow + + fun ensureLoaded() + fun prepare() = Unit + fun onProfileChanged() = Unit + fun clearLocalState() = Unit + suspend fun refresh() + fun snapshot(): TrackingLibrarySnapshot + fun contains(contentId: String, contentType: String? = null): Boolean + fun find(contentId: String): LibraryItem? + suspend fun membership(item: LibraryItem): Map + suspend fun applyMembership( + profileId: Int, + item: LibraryItem, + desiredMembership: Map, + ) + suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt new file mode 100644 index 000000000..ecca9391e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt @@ -0,0 +1,86 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.library.LibraryItem +import com.nuvio.app.features.library.LibrarySection +import com.nuvio.app.features.tracking.TrackingLibraryProvider +import com.nuvio.app.features.tracking.TrackingLibrarySnapshot +import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingLibraryTabKind +import com.nuvio.app.features.tracking.TrackingProviderId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +object TraktTrackingLibraryProvider : TrackingLibraryProvider { + override val providerId: TrackingProviderId = TrackingProviderId.TRAKT + override val changes: Flow = TraktLibraryRepository.uiState.map { Unit } + + override fun ensureLoaded() = TraktLibraryRepository.ensureLoaded() + + override fun prepare() = TraktLibraryRepository.preloadListTabsAsync() + + override fun onProfileChanged() = TraktLibraryRepository.onProfileChanged() + + override fun clearLocalState() = TraktLibraryRepository.clearLocalState() + + override suspend fun refresh() = TraktLibraryRepository.refreshNow() + + override fun snapshot(): TrackingLibrarySnapshot { + val state = TraktLibraryRepository.uiState.value + return TrackingLibrarySnapshot( + items = state.allItems, + sections = state.listTabs.mapNotNull { tab -> + state.entriesByList[tab.key] + .orEmpty() + .takeIf(List::isNotEmpty) + ?.let { items -> + LibrarySection( + type = tab.key, + displayTitle = tab.title, + items = items, + ) + } + }, + tabs = state.listTabs.map(TraktListTab::toTrackingLibraryTab), + hasLoaded = state.hasLoaded, + isLoading = state.isLoading, + errorMessage = state.errorMessage, + ) + } + + override fun contains(contentId: String, contentType: String?): Boolean { + if (contentType != null) return TraktLibraryRepository.isInAnyList(contentId, contentType) + val item = find(contentId) ?: return false + return TraktLibraryRepository.isInAnyList(item.id, item.type) + } + + override fun find(contentId: String): LibraryItem? = + TraktLibraryRepository.uiState.value.allItems.firstOrNull { item -> item.id == contentId } + + override suspend fun membership(item: LibraryItem): Map = + TraktLibraryRepository.getMembershipSnapshot(item).listMembership + + override suspend fun applyMembership( + profileId: Int, + item: LibraryItem, + desiredMembership: Map, + ) { + TraktLibraryRepository.applyMembershipChanges( + item = item, + changes = TraktMembershipChanges(desiredMembership = desiredMembership), + ) + } + + override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) = + TraktLibraryRepository.toggleWatchlist(item) +} + +private fun TraktListTab.toTrackingLibraryTab(): TrackingLibraryTab = + TrackingLibraryTab( + key = key, + title = title, + providerId = TrackingProviderId.TRAKT, + kind = when (type) { + TraktListType.WATCHLIST -> TrackingLibraryTabKind.WATCHLIST + TraktListType.PERSONAL -> TrackingLibraryTabKind.PERSONAL + }, + ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt index 0a5add232..5beca8343 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt @@ -3,8 +3,9 @@ package com.nuvio.app.features.library import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.MetaPreview -import com.nuvio.app.features.trakt.TraktListTab -import com.nuvio.app.features.trakt.TraktListType +import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingLibraryTabKind +import com.nuvio.app.features.tracking.TrackingProviderId import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -71,10 +72,11 @@ class LibraryRepositoryTest { @Test fun `library tabs include local Nuvio library before Trakt tabs`() { - val traktTab = TraktListTab( + val traktTab = TrackingLibraryTab( key = "trakt:watchlist", title = "Watchlist", - type = TraktListType.WATCHLIST, + providerId = TrackingProviderId.TRAKT, + kind = TrackingLibraryTabKind.WATCHLIST, ) val tabs = libraryTabsWithLocal(listOf(traktTab)) @@ -87,7 +89,7 @@ class LibraryRepositoryTest { fun `library membership always includes local state before Trakt membership`() { val membership = libraryMembershipWithLocal( inLocal = true, - traktMembership = mapOf("trakt:watchlist" to false), + providerMembership = mapOf("trakt:watchlist" to false), ) assertEquals(