diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt index 409511ba..f7cd154a 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -16,6 +16,7 @@ import kotlinx.serialization.json.put actual object DebridSettingsStorage { private const val preferencesName = "nuvio_debrid_settings" private const val enabledKey = "debrid_enabled" + private const val cloudLibraryEnabledKey = "debrid_cloud_library_enabled" private const val torboxApiKeyKey = "debrid_torbox_api_key" private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit" @@ -31,6 +32,7 @@ actual object DebridSettingsStorage { private fun syncKeys(): List = listOf( enabledKey, + cloudLibraryEnabledKey, instantPlaybackPreparationLimitKey, streamMaxResultsKey, streamSortModeKey, @@ -55,6 +57,12 @@ actual object DebridSettingsStorage { saveBoolean(enabledKey, enabled) } + actual fun loadCloudLibraryEnabled(): Boolean? = loadBoolean(cloudLibraryEnabledKey) + + actual fun saveCloudLibraryEnabled(enabled: Boolean) { + saveBoolean(cloudLibraryEnabledKey, enabled) + } + actual fun loadProviderApiKey(providerId: String): String? = loadString(providerApiKeyKey(providerId)) @@ -180,6 +188,7 @@ actual object DebridSettingsStorage { actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) } DebridProviders.all().forEach { provider -> loadProviderApiKey(provider.id)?.let { put(providerApiKeyKey(provider.id), encodeSyncString(it)) @@ -203,6 +212,7 @@ actual object DebridSettingsStorage { }?.apply() payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + payload.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled) DebridProviders.all().forEach { provider -> payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey -> saveProviderApiKey(provider.id, apiKey) diff --git a/composeApp/src/commonMain/composeResources/values-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml index 64646871..e8d5de08 100644 --- a/composeApp/src/commonMain/composeResources/values-no/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml @@ -591,8 +591,10 @@ Administrer skytjenestekontoer og tilgang til skybibliotek Skytjenester Støtte for skytjenester er eksperimentell og kan endres eller fjernes senere. - Aktiver skytjenester - Bruk tilkoblede kontoer for spillbare lenker og tilgang til skybibliotek. + Skybibliotek + Bla gjennom og spill filer som allerede finnes i tilkoblede skytjenester. + Løs spillbare lenker + Be en tilkoblet tjeneste om spillbare lenker når et resultat trenger det. Dette kan legge elementet til i den tjenesten. Koble til en skytjenestekonto først. Skytjenester Koble til %1$s-kontoen din. @@ -612,7 +614,7 @@ Venter på godkjenning... Kunne ikke starte innlogging. Denne koden er utløpt. Prøv igjen. - Umiddelbar avspilling + Lenkeforberedelse Forbered lenker Løs spillbare lenker før avspilling starter. Lenker å forberede diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1d410fb7..f26a2718 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -592,8 +592,10 @@ Manage cloud service accounts and cloud library access Cloud Services Cloud Services support is experimental and may be kept, changed, or removed later. - Enable cloud services - Use connected accounts for playable links and cloud library access. + Cloud library + Browse and play files already in your connected cloud services. + Resolve playable links + Ask a connected service for playable links when a result needs it. This may add the item to that service. Connect a cloud service account first. Cloud Services Connect your %1$s account. @@ -614,7 +616,7 @@ Waiting for approval... Could not start sign-in. This code expired. Try again. - Instant Playback + Link Preparation Prepare links Resolve playable links before playback starts. Links to prepare @@ -1330,6 +1332,9 @@ Connect account Connect Torbox in Cloud Services settings to browse playable files from your cloud library. No cloud account connected + Open Cloud Services + Turn on Cloud library in Cloud Services settings to browse files from connected accounts. + Cloud library is off No playable cloud files match the current filters. Nothing here yet Choose a file to play diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt index 635192ee..4ee81729 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt @@ -52,6 +52,7 @@ data class CloudLibraryProviderState( data class CloudLibraryUiState( val isLoaded: Boolean = false, + val isEnabled: Boolean = true, val isRefreshing: Boolean = false, val providers: List = emptyList(), ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt index 0a2e63fe..c9eaa43c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt @@ -87,6 +87,11 @@ object CloudLibraryRepository { fun ensureLoaded() { DebridSettingsRepository.ensureLoaded() + if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) { + loadedConnectionKeys = emptyList() + _uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false) + return + } val current = _uiState.value if (current.isRefreshing) return val connectedKeys = connectedCloudConnectionKeys() @@ -96,8 +101,15 @@ object CloudLibraryRepository { } fun refresh() { + DebridSettingsRepository.ensureLoaded() + if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) { + loadedConnectionKeys = emptyList() + _uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false) + return + } _uiState.update { current -> current.copy( + isEnabled = true, isRefreshing = true, providers = current.providers.map { it.copy(isLoading = true, errorMessage = null) }, ) @@ -112,11 +124,19 @@ object CloudLibraryRepository { suspend fun resolvePlayback( item: CloudLibraryItem, file: CloudLibraryFile, - ): CloudLibraryPlaybackResult = - store.resolvePlayback(item, file) + ): CloudLibraryPlaybackResult { + DebridSettingsRepository.ensureLoaded() + if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) { + return CloudLibraryPlaybackResult.Failed("Cloud library is disabled.") + } + return store.resolvePlayback(item, file) + } private fun connectedCloudCredentials(): List = - DebridProviders.configuredServices(DebridSettingsRepository.snapshot()) + DebridSettingsRepository.snapshot() + .takeIf { settings -> settings.cloudLibraryEnabled } + ?.let(DebridProviders::configuredServices) + .orEmpty() .filter { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) } private fun connectedCloudConnectionKeys(): List = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt index 43007dce..33ba2608 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt @@ -83,8 +83,9 @@ internal fun TorboxCloudItemDto.toCloudLibraryItem( val itemId = id.scalarString() ?: hash?.trim()?.takeIf { it.isNotBlank() } ?: return null + val itemName = name?.trim()?.takeIf { it.isNotBlank() } ?: itemId val mappedFiles = files.orEmpty().mapNotNull { file -> - file.toCloudLibraryFile() + file.toCloudLibraryFile(parentName = itemName) } val filesSize = mappedFiles .mapNotNull { it.sizeBytes } @@ -95,7 +96,7 @@ internal fun TorboxCloudItemDto.toCloudLibraryItem( providerName = providerName, id = itemId, type = type, - name = name?.trim()?.takeIf { it.isNotBlank() } ?: itemId, + name = itemName, status = listOf(status, downloadState, state) .firstNonBlank(), sizeBytes = size ?: totalSize ?: filesSize, @@ -104,9 +105,8 @@ internal fun TorboxCloudItemDto.toCloudLibraryItem( ) } -internal fun TorboxCloudFileDto.toCloudLibraryFile(): CloudLibraryFile? { - val name = listOf(name, shortName, absolutePath) - .firstNonBlank() +internal fun TorboxCloudFileDto.toCloudLibraryFile(parentName: String? = null): CloudLibraryFile? { + val name = bestCloudFileName(parentName = parentName) ?: return null val fileId = id.scalarString() val mime = listOf(mimeType, mimeTypeAlt).firstNonBlank() @@ -119,6 +119,32 @@ internal fun TorboxCloudFileDto.toCloudLibraryFile(): CloudLibraryFile? { ) } +private fun TorboxCloudFileDto.bestCloudFileName(parentName: String?): String? { + val rawName = name?.trim()?.takeIf { it.isNotBlank() } + val short = shortName?.trim()?.takeIf { it.isNotBlank() } + val pathName = absolutePath + ?.trim() + ?.pathBasename() + ?.takeIf { it.isNotBlank() } + val parent = parentName?.trim()?.takeIf { it.isNotBlank() } + val rawNameIsPath = rawName?.isPathLike() == true + val rawNameBasename = rawName + ?.takeIf { rawNameIsPath } + ?.pathBasename() + ?.takeIf { it.isNotBlank() } + val candidates = listOf( + short, + rawNameBasename, + rawName?.takeUnless { rawNameIsPath }, + pathName, + rawName, + absolutePath?.trim()?.takeIf { it.isNotBlank() }, + ) + return candidates.firstOrNull { candidate -> + candidate?.isUsableCloudFileName(parentName = parent, pathName = pathName) == true + } ?: candidates.firstNonBlank() +} + internal fun torboxRequestIdParameterName(type: CloudLibraryItemType): String = when (type) { CloudLibraryItemType.Torrent -> "torrent_id" @@ -129,6 +155,33 @@ internal fun torboxRequestIdParameterName(type: CloudLibraryItemType): String = private fun List.firstNonBlank(): String? = firstOrNull { !it.isNullOrBlank() }?.trim() +private fun String.sameDisplayName(other: String?): Boolean { + val normalized = normalizeDisplayName() + return normalized.isNotBlank() && normalized == other?.normalizeDisplayName() +} + +private fun String.isUsableCloudFileName(parentName: String?, pathName: String?): Boolean { + if (isBlank() || sameDisplayName(parentName)) return false + val pathNameWithoutExtension = pathName?.substringBeforeLast('.', pathName) + if (!contains('.') && sameDisplayName(pathNameWithoutExtension)) return false + return true +} + +private fun String.isPathLike(): Boolean = + contains('/') || contains('\\') + +private fun String.pathBasename(): String = + substringAfterLast('/').substringAfterLast('\\') + +private fun String.normalizeDisplayName(): String = + trim() + .substringAfterLast('/') + .substringAfterLast('\\') + .substringBeforeLast('.', this) + .lowercase() + .replace(Regex("[^a-z0-9]+"), " ") + .trim() + private fun kotlinx.serialization.json.JsonElement?.scalarString(): String? { val primitive = this as? JsonPrimitive ?: return null return primitive.content.trim().takeIf { it.isNotBlank() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt index 5fc3417a..81582aa5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable data class DebridSettings( val enabled: Boolean = false, + val cloudLibraryEnabled: Boolean = true, val providerApiKeys: Map = emptyMap(), val instantPlaybackPreparationLimit: Int = 0, val streamMaxResults: Int = 0, @@ -25,6 +26,19 @@ data class DebridSettings( val hasAnyApiKey: Boolean get() = DebridProviders.configuredServices(this).isNotEmpty() + val linkResolvingEnabled: Boolean + get() = enabled + + val canResolvePlayableLinks: Boolean + get() = linkResolvingEnabled && hasAnyApiKey + + val hasCloudLibraryProvider: Boolean + get() = DebridProviders.configuredServices(this) + .any { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) } + + val canUseCloudLibrary: Boolean + get() = cloudLibraryEnabled && hasCloudLibraryProvider + val hasCustomStreamFormatting: Boolean get() = streamNameTemplate.isNotBlank() || streamDescriptionTemplate.isNotBlank() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt index 158f2fab..321fff52 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt @@ -21,6 +21,7 @@ object DebridSettingsRepository { private var hasLoaded = false private var enabled = false + private var cloudLibraryEnabled = true private var providerApiKeys = emptyMap() private var instantPlaybackPreparationLimit = 0 private var streamMaxResults = 0 @@ -56,6 +57,19 @@ object DebridSettingsRepository { DebridSettingsStorage.saveEnabled(value) } + fun setLinkResolvingEnabled(value: Boolean) { + setEnabled(value) + } + + fun setCloudLibraryEnabled(value: Boolean) { + ensureLoaded() + if (value && !hasVisibleApiKey()) return + if (cloudLibraryEnabled == value) return + cloudLibraryEnabled = value + publish() + DebridSettingsStorage.saveCloudLibraryEnabled(value) + } + fun setProviderApiKey(providerId: String, value: String) { ensureLoaded() val provider = DebridProviders.byId(providerId) ?: return @@ -217,6 +231,7 @@ object DebridSettingsRepository { } .toMap() enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasVisibleApiKey() + cloudLibraryEnabled = DebridSettingsStorage.loadCloudLibraryEnabled() ?: true instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit( DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0, ) @@ -264,6 +279,7 @@ object DebridSettingsRepository { private fun publish() { _uiState.value = DebridSettings( enabled = enabled, + cloudLibraryEnabled = cloudLibraryEnabled, providerApiKeys = providerApiKeys, instantPlaybackPreparationLimit = instantPlaybackPreparationLimit, streamMaxResults = streamMaxResults, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt index 4c75578e..31286bb1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -5,6 +5,8 @@ import kotlinx.serialization.json.JsonObject internal expect object DebridSettingsStorage { fun loadEnabled(): Boolean? fun saveEnabled(enabled: Boolean) + fun loadCloudLibraryEnabled(): Boolean? + fun saveCloudLibraryEnabled(enabled: Boolean) fun loadProviderApiKey(providerId: String): String? fun saveProviderApiKey(providerId: String, apiKey: String) fun loadTorboxApiKey(): String? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt index 6621cb84..85c5cca1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt @@ -8,7 +8,7 @@ object DebridStreamPresentation { private val formatter = DebridStreamFormatter() fun apply(groups: List, settings: DebridSettings): List { - if (!settings.enabled) return groups + if (!settings.canResolvePlayableLinks) return groups return groups.map { group -> val visibleStreams = group.streams.filterNot { stream -> stream.isUncachedDebridStream } val debridStreams = visibleStreams.filter { stream -> stream.isManagedDebridStream } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt index e052bc18..d6b6eb8c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt @@ -111,7 +111,7 @@ object DirectDebridPlaybackResolver { fun shouldResolveToPlayableStream(stream: StreamItem): Boolean { val settings = DebridSettingsRepository.snapshot() - if (!settings.enabled) return false + if (!settings.canResolvePlayableLinks) return false if (stream.needsLocalDebridResolve) { return stream.isInstalledAddonStream && localTorrentResolveCredential(settings) != null } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt index c69ce1f7..0a5c429f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt @@ -27,7 +27,7 @@ object DirectDebridStreamPreparer { ) { val settings = DebridSettingsRepository.snapshot() val limit = settings.instantPlaybackPreparationLimit - if (!settings.enabled || limit <= 0 || !settings.hasAnyApiKey) return + if (!settings.canResolvePlayableLinks || limit <= 0) return val candidates = prioritizeCandidates( streams = streams, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt index 2a3f3741..2342b198 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt @@ -81,7 +81,7 @@ object LocalDebridAvailabilityService { private fun cacheCheckAccount(): DebridServiceCredential? { val settings = DebridSettingsRepository.snapshot() - if (!settings.enabled) return null + if (!settings.canResolvePlayableLinks) return null return DebridProviders.configuredServices(settings) .firstOrNull { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentCacheCheck) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 61ca78cd..e50ff9f3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -68,6 +68,7 @@ import com.nuvio.app.features.cloud.CloudLibraryItem import com.nuvio.app.features.cloud.CloudLibraryItemType import com.nuvio.app.features.cloud.CloudLibraryRepository import com.nuvio.app.features.cloud.CloudLibraryUiState +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow @@ -95,6 +96,10 @@ fun LibraryScreen( LibraryRepository.uiState }.collectAsStateWithLifecycle() val cloudUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle() + val cloudSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() val watchedUiState by remember { WatchedRepository.ensureLoaded() WatchedRepository.uiState @@ -151,7 +156,7 @@ fun LibraryScreen( } } - LaunchedEffect(sourceMode) { + LaunchedEffect(sourceMode, cloudSettings.cloudLibraryEnabled, cloudSettings.providerApiKeys) { if (sourceMode == LibraryViewMode.Cloud) { CloudLibraryRepository.ensureLoaded() selectedCloudItemKey = null @@ -307,6 +312,18 @@ private fun LazyListScope.cloudLibraryContent( cloudLibrarySkeletonItems() } + !uiState.isEnabled -> { + item { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = stringResource(Res.string.cloud_library_disabled_title), + message = stringResource(Res.string.cloud_library_disabled_message), + actionLabel = stringResource(Res.string.cloud_library_disabled_action), + onActionClick = onConnectCloudClick, + ) + } + } + !uiState.hasConnectedProvider -> { item { HomeEmptyStateCard( @@ -702,46 +719,59 @@ private fun CloudLibraryFileRow( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - Row( + Surface( modifier = modifier .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.58f)) - .clickable(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + .clickable(onClick = onClick), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.58f), ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Text( - text = file.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - file.sizeBytes?.let { size -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + modifier = Modifier + .padding(top = 2.dp) + .size(18.dp), + imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) Text( - text = formatCloudBytes(size), + modifier = Modifier.weight(1f), + text = file.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = file.sizeBytes?.let { size -> formatCloudBytes(size) }.orEmpty(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = stringResource(Res.string.cloud_library_play_file), + tint = MaterialTheme.colorScheme.primary, ) } } - Icon( - imageVector = Icons.Rounded.PlayArrow, - contentDescription = stringResource(Res.string.cloud_library_play_file), - tint = MaterialTheme.colorScheme.primary, - ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 37940f03..02f88cc4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -608,7 +608,7 @@ private fun EpisodeStreamsSubView( ) { _, stream -> EpisodeSourceStreamRow( stream = stream, - enabled = stream.isSelectableForPlayback(debridSettings.enabled), + enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks), onClick = { onStreamSelected(stream, episode) }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 4db2d7b2..ce00fcde 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -1166,7 +1166,7 @@ fun PlayerScreen( null }, preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, - debridEnabled = DebridSettingsRepository.snapshot().enabled, + debridEnabled = DebridSettingsRepository.snapshot().canResolvePlayableLinks, ) } else null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index e5f6b1d9..bf68cb5c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt @@ -221,7 +221,7 @@ fun PlayerSourcesPanel( SourceStreamRow( stream = stream, isCurrent = isCurrent, - enabled = stream.isSelectableForPlayback(debridSettings.enabled), + enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks), onClick = { onStreamSelected(stream) }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index 0d60c6b3..df6a73c2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -77,6 +77,8 @@ import nuvio.composeapp.generated.resources.action_reset import nuvio.composeapp.generated.resources.action_save import nuvio.composeapp.generated.resources.action_saving import nuvio.composeapp.generated.resources.settings_debrid_add_key_first +import nuvio.composeapp.generated.resources.settings_debrid_cloud_library +import nuvio.composeapp.generated.resources.settings_debrid_cloud_library_description import nuvio.composeapp.generated.resources.settings_debrid_connected import nuvio.composeapp.generated.resources.settings_debrid_connect_provider import nuvio.composeapp.generated.resources.settings_debrid_disconnect_provider @@ -132,13 +134,22 @@ internal fun LazyListScope.debridSettingsContent( text = stringResource(Res.string.settings_debrid_experimental_notice), ) SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_debrid_cloud_library), + description = stringResource(Res.string.settings_debrid_cloud_library_description), + checked = settings.canUseCloudLibrary, + enabled = settings.hasCloudLibraryProvider, + isTablet = isTablet, + onCheckedChange = DebridSettingsRepository::setCloudLibraryEnabled, + ) + SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( title = stringResource(Res.string.settings_debrid_enable), description = stringResource(Res.string.settings_debrid_enable_description), - checked = settings.enabled && settings.hasAnyApiKey, + checked = settings.canResolvePlayableLinks, enabled = settings.hasAnyApiKey, isTablet = isTablet, - onCheckedChange = DebridSettingsRepository::setEnabled, + onCheckedChange = DebridSettingsRepository::setLinkResolvingEnabled, ) if (!settings.hasAnyApiKey) { SettingsGroupDivider(isTablet = isTablet) @@ -220,10 +231,12 @@ internal fun LazyListScope.debridSettingsContent( } } + if (!settings.canResolvePlayableLinks) return + item { var showPrepareCountDialog by rememberSaveable { mutableStateOf(false) } val prepareLimit = settings.instantPlaybackPreparationLimit - val prepareEnabled = settings.enabled && prepareLimit > 0 + val prepareEnabled = prepareLimit > 0 SettingsSection( title = stringResource(Res.string.settings_debrid_section_instant_playback), @@ -234,7 +247,7 @@ internal fun LazyListScope.debridSettingsContent( title = stringResource(Res.string.settings_debrid_prepare_instant_playback), description = stringResource(Res.string.settings_debrid_prepare_instant_playback_description), checked = prepareEnabled, - enabled = settings.enabled && settings.hasAnyApiKey, + enabled = settings.canResolvePlayableLinks, isTablet = isTablet, onCheckedChange = { enabled -> DebridSettingsRepository.setInstantPlaybackPreparationLimit( @@ -281,7 +294,7 @@ internal fun LazyListScope.debridSettingsContent( title = "Max results", description = "Limit how many cloud-service results appear.", value = streamMaxResultsLabel(preferences.maxResults), - enabled = settings.enabled, + enabled = settings.canResolvePlayableLinks, onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS }, ) SettingsGroupDivider(isTablet = isTablet) @@ -290,7 +303,7 @@ internal fun LazyListScope.debridSettingsContent( title = "Sort results", description = "Choose how cloud-service results are ordered.", value = sortProfileLabel(preferences.sortCriteria), - enabled = settings.enabled, + enabled = settings.canResolvePlayableLinks, onClick = { activeStreamPicker = DebridStreamPicker.SORT_MODE }, ) SettingsGroupDivider(isTablet = isTablet) @@ -299,7 +312,7 @@ internal fun LazyListScope.debridSettingsContent( title = "Per resolution limit", description = "Cap repeated 2160p, 1080p, 720p results after sorting.", value = streamMaxResultsLabel(preferences.maxPerResolution), - enabled = settings.enabled, + enabled = settings.canResolvePlayableLinks, onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_RESOLUTION }, ) SettingsGroupDivider(isTablet = isTablet) @@ -308,7 +321,7 @@ internal fun LazyListScope.debridSettingsContent( title = "Per quality limit", description = "Cap repeated BluRay, WEB-DL, REMUX results after sorting.", value = streamMaxResultsLabel(preferences.maxPerQuality), - enabled = settings.enabled, + enabled = settings.canResolvePlayableLinks, onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_QUALITY }, ) SettingsGroupDivider(isTablet = isTablet) @@ -317,7 +330,7 @@ internal fun LazyListScope.debridSettingsContent( title = "Size range", description = "Filter cloud-service results by file size.", value = sizeRangeLabel(preferences), - enabled = settings.enabled, + enabled = settings.canResolvePlayableLinks, onClick = { activeStreamPicker = DebridStreamPicker.SIZE_RANGE }, ) rows.forEach { row -> @@ -327,7 +340,7 @@ internal fun LazyListScope.debridSettingsContent( title = row.title, description = row.description, value = row.value, - enabled = settings.enabled, + enabled = settings.canResolvePlayableLinks, onClick = { activeStreamPicker = row.picker }, ) } @@ -357,7 +370,7 @@ internal fun LazyListScope.debridSettingsContent( title = stringResource(Res.string.settings_debrid_name_template), description = stringResource(Res.string.settings_debrid_name_template_description), value = templatePreview(settings.streamNameTemplate), - enabled = settings.enabled, + enabled = settings.canResolvePlayableLinks, onClick = { activeTemplateField = DebridTemplateField.NAME }, ) SettingsGroupDivider(isTablet = isTablet) @@ -366,7 +379,7 @@ internal fun LazyListScope.debridSettingsContent( title = stringResource(Res.string.settings_debrid_description_template), description = stringResource(Res.string.settings_debrid_description_template_description), value = templatePreview(settings.streamDescriptionTemplate), - enabled = settings.enabled, + enabled = settings.canResolvePlayableLinks, onClick = { activeTemplateField = DebridTemplateField.DESCRIPTION }, ) SettingsGroupDivider(isTablet = isTablet) @@ -375,7 +388,7 @@ internal fun LazyListScope.debridSettingsContent( title = stringResource(Res.string.settings_debrid_formatter_reset_title), description = stringResource(Res.string.settings_debrid_formatter_reset_subtitle), value = stringResource(Res.string.action_reset), - enabled = settings.enabled, + enabled = settings.canResolvePlayableLinks, onClick = DebridSettingsRepository::resetStreamTemplates, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt index e31709cb..8c731baf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt @@ -209,7 +209,7 @@ object AddonStreamWarmupRepository { DebridSettingsRepository.ensureLoaded() val settings = DebridSettingsRepository.snapshot() - if (!settings.enabled || settings.torboxApiKey.isBlank()) return null + if (!settings.canResolvePlayableLinks || settings.torboxApiKey.isBlank()) return null AddonRepository.initialize() val addonTargets = AddonRepository.uiState.value.addons diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 1d46f16d..575a78e4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -299,7 +299,7 @@ object StreamsRepository { installedAddonNames = installedAddonNames, selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, - debridEnabled = debridSettings.enabled, + debridEnabled = debridSettings.canResolvePlayableLinks, ) _uiState.update { it.copy(autoPlayStream = selected) } if (selected == null) { @@ -498,7 +498,7 @@ object StreamsRepository { installedAddonNames = installedAddonNames, selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, - debridEnabled = debridSettings.enabled, + debridEnabled = debridSettings.canResolvePlayableLinks, ) _uiState.update { it.copy(autoPlayStream = selected) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index c843a164..04853296 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -224,8 +224,8 @@ fun StreamsScreen( episodeNumber = episodeNumber, episodeTitle = episodeTitle, uiState = uiState, - debridEnabled = debridSettings.enabled, - appendInstantServiceToDefaultName = !debridSettings.hasCustomStreamFormatting, + debridEnabled = debridSettings.canResolvePlayableLinks, + appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && !debridSettings.hasCustomStreamFormatting, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = { stream, positionMs, progressFraction -> @@ -243,8 +243,8 @@ fun StreamsScreen( episodeNumber = episodeNumber, episodeTitle = episodeTitle, uiState = uiState, - debridEnabled = debridSettings.enabled, - appendInstantServiceToDefaultName = !debridSettings.hasCustomStreamFormatting, + debridEnabled = debridSettings.canResolvePlayableLinks, + appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && !debridSettings.hasCustomStreamFormatting, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = { stream, positionMs, progressFraction -> diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt index 7648b8d3..92e15b52 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt @@ -64,10 +64,72 @@ class TorboxCloudLibraryProviderApiTest { assertNotNull(item) assertEquals("abc123", item.id) assertEquals("abc123", item.name) - assertEquals("/downloads/show.mp4", item.files.single().name) + assertEquals("show.mp4", item.files.single().name) assertTrue(item.files.single().playable) } + @Test + fun `mapping prefers absolute path basename when file name repeats pack name`() { + val item = TorboxCloudItemDto( + id = JsonPrimitive(44), + name = "The Rookie S01", + files = listOf( + TorboxCloudFileDto( + id = JsonPrimitive(1), + name = "The Rookie S01", + absolutePath = "/The Rookie S01/The.Rookie.S01E01.1080p.WEB-DL.mkv", + mimeType = "video/x-matroska", + ), + TorboxCloudFileDto( + id = JsonPrimitive(2), + shortName = "The Rookie S01", + absolutePath = "/The Rookie S01/The.Rookie.S01E02.1080p.WEB-DL.mkv", + mimeType = "video/x-matroska", + ), + ), + ).toCloudLibraryItem( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.Torrent, + ) + + assertNotNull(item) + assertEquals( + listOf( + "The.Rookie.S01E01.1080p.WEB-DL.mkv", + "The.Rookie.S01E02.1080p.WEB-DL.mkv", + ), + item.playableFiles.map { it.name }, + ) + } + + @Test + fun `mapping prefers short name when Torbox file name is a relative pack path`() { + val item = TorboxCloudItemDto( + id = JsonPrimitive(29556645), + name = "From.The.Earth.To.The.Moon.1998.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.TrueHD.7.1.Atmos-FLUX[rartv]", + files = listOf( + TorboxCloudFileDto( + id = JsonPrimitive(1), + name = "From.The.Earth.To.The.Moon.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.TrueHD.7.1.Atmos-FLUX[rartv]/From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv", + shortName = "From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv", + absolutePath = "/completed/2c229180e129280a36ba7f3a22e2f5135a02a766/From.The.Earth.To.The.Moon.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.TrueHD.7.1.Atmos-FLUX[rartv]/From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv", + mimeType = "video/x-matroska", + ), + ), + ).toCloudLibraryItem( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.Torrent, + ) + + assertNotNull(item) + assertEquals( + "From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv", + item.playableFiles.single().name, + ) + } + @Test fun `mapping handles missing item ids and empty file lists`() { assertNull( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridSettingsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridSettingsTest.kt index b686d00b..8aa924d6 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridSettingsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridSettingsTest.kt @@ -33,4 +33,16 @@ class DebridSettingsTest { assertTrue(settings.hasAnyApiKey) assertFalse(DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID)) } + + @Test + fun `cloud library and link resolving capabilities are independent`() { + val settings = DebridSettings( + enabled = false, + cloudLibraryEnabled = true, + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "tb_key"), + ) + + assertTrue(settings.canUseCloudLibrary) + assertFalse(settings.canResolvePlayableLinks) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt index 16b947f8..b23a17c5 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt @@ -109,6 +109,32 @@ class DebridStreamPresentationTest { assertEquals(listOf("Cached"), presented.map { it.name }) } + @Test + fun `leaves cloud-service results untouched when link resolving is off`() { + val uncached = localTorboxStream( + name = "Uncached", + filename = "Movie.2160p.WEB-DL.HEVC-GRP.mkv", + size = 20_000_000_000, + cacheState = StreamDebridCacheState.NOT_CACHED, + ) + + val presented = DebridStreamPresentation.apply( + groups = listOf( + AddonStreamGroup( + addonName = "Addon", + addonId = "addon:test", + streams = listOf(uncached), + ), + ), + settings = DebridSettings( + enabled = false, + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), + ), + ).single().streams + + assertEquals(listOf("Uncached"), presented.map { it.name }) + } + private fun localTorboxStream( name: String = "Torrent", filename: String, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt index 1dae8d1b..311bba69 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -14,6 +14,7 @@ import platform.Foundation.NSUserDefaults actual object DebridSettingsStorage { private const val enabledKey = "debrid_enabled" + private const val cloudLibraryEnabledKey = "debrid_cloud_library_enabled" private const val torboxApiKeyKey = "debrid_torbox_api_key" private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit" @@ -29,6 +30,7 @@ actual object DebridSettingsStorage { private fun syncKeys(): List = listOf( enabledKey, + cloudLibraryEnabledKey, instantPlaybackPreparationLimitKey, streamMaxResultsKey, streamSortModeKey, @@ -47,6 +49,12 @@ actual object DebridSettingsStorage { saveBoolean(enabledKey, enabled) } + actual fun loadCloudLibraryEnabled(): Boolean? = loadBoolean(cloudLibraryEnabledKey) + + actual fun saveCloudLibraryEnabled(enabled: Boolean) { + saveBoolean(cloudLibraryEnabledKey, enabled) + } + actual fun loadProviderApiKey(providerId: String): String? = loadString(providerApiKeyKey(providerId)) @@ -163,6 +171,7 @@ actual object DebridSettingsStorage { actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) } DebridProviders.all().forEach { provider -> loadProviderApiKey(provider.id)?.let { put(providerApiKeyKey(provider.id), encodeSyncString(it)) @@ -186,6 +195,7 @@ actual object DebridSettingsStorage { } payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + payload.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled) DebridProviders.all().forEach { provider -> payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey -> saveProviderApiKey(provider.id, apiKey)