refactor(tracking): register library read providers

This commit is contained in:
tapframe 2026-07-22 03:25:32 +05:30
parent 5bbe3b9ef0
commit 410a9d4c4b
11 changed files with 372 additions and 302 deletions

View file

@ -515,6 +515,8 @@
<string name="settings_licenses_attributions_exoplayer_license">Licensed under the Apache License, Version 2.0.</string>
<string name="compose_trakt_list_picker_loading">Loading your Trakt lists…</string>
<string name="compose_trakt_list_picker_subtitle">Choose where to save this title on Trakt</string>
<string name="compose_tracking_list_picker_loading">Loading lists…</string>
<string name="compose_tracking_list_picker_subtitle">Choose where to save this title</string>
<string name="action_donate">Donate</string>
<string name="cw_action_go_to_details">Go to details</string>
<string name="cw_action_remove">Remove</string>
@ -1442,6 +1444,7 @@
<string name="trailer_unable_to_play">Unable to play trailer</string>
<string name="trakt_lists_load_failed">Failed to load Trakt lists</string>
<string name="trakt_lists_update_failed">Failed to update Trakt lists</string>
<string name="tracking_lists_update_failed">Failed to update tracking lists</string>
<string name="updates_asset_line">%1$s • %2$s</string>
<string name="updates_check_failed">Update check failed</string>
<string name="updates_download_failed">Download failed</string>

View file

@ -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<LibraryItem?>(null) }
var pickerTitle by remember { mutableStateOf("") }
var pickerTabs by remember { mutableStateOf<List<TraktListTab>>(emptyList()) }
var pickerTabs by remember { mutableStateOf<List<TrackingLibraryTab>>(emptyList()) }
var pickerMembership by remember { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember { mutableStateOf(false) }
var pickerError by remember { mutableStateOf<String?>(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
}

View file

@ -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)
}

View file

@ -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<TraktListTab>,
tabs: List<TrackingLibraryTab>,
membership: Map<String, Boolean>,
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,
)

View file

@ -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<TraktCommentReview?>(null) }
val detailsScope = rememberCoroutineScope()
var showLibraryListPicker by remember(type, id) { mutableStateOf(false) }
var pickerTabs by remember(type, id) { mutableStateOf<List<TraktListTab>>(emptyList()) }
var pickerTabs by remember(type, id) { mutableStateOf<List<TrackingLibraryTab>>(emptyList()) }
var pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
var pickerPending by remember(type, id) { mutableStateOf(false) }
var pickerError by remember(type, id) { mutableStateOf<String?>(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
}

View file

@ -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<Int, Long>()
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<TraktListTab> {
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<TraktListTab> = libraryListTabs()
fun libraryListTabs(): List<TrackingLibraryTab> =
libraryTabsWithLocal(
TrackingProviderRegistry.connectedLibraryProviders()
.flatMap { provider -> provider.snapshot().tabs },
)
suspend fun getMembershipSnapshot(item: LibraryItem): Map<String, Boolean> {
ensureLoaded()
val inLocal = localState.contains(item.id, item.type)
val memberships = linkedMapOf<String, Boolean>()
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<String, Boolean>) {
@ -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<TraktListTab>): List<TraktListTab> =
listOf(localLibraryListTab()) + traktTabs
internal fun libraryTabsWithLocal(providerTabs: List<TrackingLibraryTab>): List<TrackingLibraryTab> =
listOf(localLibraryListTab()) + providerTabs
internal fun libraryMembershipWithLocal(
inLocal: Boolean,
traktMembership: Map<String, Boolean> = emptyMap(),
providerMembership: Map<String, Boolean> = emptyMap(),
): Map<String, Boolean> =
linkedMapOf<String, Boolean>(LOCAL_LIBRARY_LIST_KEY to inLocal).apply {
putAll(traktMembership)
putAll(providerMembership)
}
internal fun libraryMembershipWithRemovedList(

View file

@ -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<Unit> = 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<String, Boolean> =
mapOf(SIMKL_WATCHLIST_KEY to SimklLibraryRepository.isInWatchlist(item.id, item.type))
override suspend fun applyMembership(
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
) {
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<WatchedItem> {
if (profileId != ProfileRepository.activeProfileId) return emptyList()

View file

@ -74,6 +74,7 @@ object TrackingProviderRegistry {
private val listWriters = mutableMapOf<TrackingProviderId, TrackingListWriter>()
private val historyWriters = mutableMapOf<TrackingProviderId, TrackingHistoryWriter>()
private val scrobblers = mutableMapOf<TrackingProviderId, TrackingScrobbler>()
private val libraryProviders = mutableMapOf<TrackingProviderId, TrackingLibraryProvider>()
private val _connectedProviderIds = MutableStateFlow<Set<TrackingProviderId>>(emptySet())
val connectedProviderIds: StateFlow<Set<TrackingProviderId>> = _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<TrackingLibraryProvider> = synchronized(lock) {
libraryProviders.entries
.sortedBy { (id, _) -> id.ordinal }
.map { (_, provider) -> provider }
}
fun connectedListWriters(): List<TrackingListWriter> =
connectedPorts(listWriters, TrackingCapability.LIBRARY_WRITE)
@ -153,6 +168,9 @@ object TrackingProviderRegistry {
fun connectedScrobblers(): List<TrackingScrobbler> =
connectedPorts(scrobblers, TrackingCapability.SCROBBLE)
fun connectedLibraryProviders(): List<TrackingLibraryProvider> =
connectedPorts(libraryProviders, TrackingCapability.LIBRARY_READ)
fun handleAuthCallback(url: String): Boolean =
providersWith(TrackingCapability.AUTHENTICATION)
.any { provider -> provider.handleAuthCallback(url) }

View file

@ -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<LibraryItem> = emptyList(),
val sections: List<LibrarySection> = emptyList(),
val tabs: List<TrackingLibraryTab> = 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<Unit>
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<String, Boolean>
suspend fun applyMembership(
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
)
suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem)
}

View file

@ -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<Unit> = 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<LibraryItem>::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<String, Boolean> =
TraktLibraryRepository.getMembershipSnapshot(item).listMembership
override suspend fun applyMembership(
profileId: Int,
item: LibraryItem,
desiredMembership: Map<String, Boolean>,
) {
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
},
)

View file

@ -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(