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 d1ff44e5..409511ba 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 @@ -28,21 +28,20 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" - private val syncKeys = listOf( - enabledKey, - torboxApiKeyKey, - realDebridApiKeyKey, - instantPlaybackPreparationLimitKey, - streamMaxResultsKey, - streamSortModeKey, - streamMinimumQualityKey, - streamDolbyVisionFilterKey, - streamHdrFilterKey, - streamCodecFilterKey, - streamPreferencesKey, - streamNameTemplateKey, - streamDescriptionTemplateKey, - ) + private fun syncKeys(): List = + listOf( + enabledKey, + instantPlaybackPreparationLimitKey, + streamMaxResultsKey, + streamSortModeKey, + streamMinimumQualityKey, + streamDolbyVisionFilterKey, + streamHdrFilterKey, + streamCodecFilterKey, + streamPreferencesKey, + streamNameTemplateKey, + streamDescriptionTemplateKey, + ) + DebridProviders.all().map { providerApiKeyKey(it.id) } private var preferences: SharedPreferences? = null @@ -56,16 +55,23 @@ actual object DebridSettingsStorage { saveBoolean(enabledKey, enabled) } - actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey) + actual fun loadProviderApiKey(providerId: String): String? = + loadString(providerApiKeyKey(providerId)) - actual fun saveTorboxApiKey(apiKey: String) { - saveString(torboxApiKeyKey, apiKey) + actual fun saveProviderApiKey(providerId: String, apiKey: String) { + saveString(providerApiKeyKey(providerId), apiKey) } - actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey) + actual fun loadTorboxApiKey(): String? = loadProviderApiKey(DebridProviders.TORBOX_ID) + + actual fun saveTorboxApiKey(apiKey: String) { + saveProviderApiKey(DebridProviders.TORBOX_ID, apiKey) + } + + actual fun loadRealDebridApiKey(): String? = loadProviderApiKey(DebridProviders.REAL_DEBRID_ID) actual fun saveRealDebridApiKey(apiKey: String) { - saveString(realDebridApiKeyKey, apiKey) + saveProviderApiKey(DebridProviders.REAL_DEBRID_ID, apiKey) } actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey) @@ -174,8 +180,11 @@ actual object DebridSettingsStorage { actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } - loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) } - loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) } + DebridProviders.all().forEach { provider -> + loadProviderApiKey(provider.id)?.let { + put(providerApiKeyKey(provider.id), encodeSyncString(it)) + } + } loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) } loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) } @@ -190,12 +199,15 @@ actual object DebridSettingsStorage { actual fun replaceFromSyncPayload(payload: JsonObject) { preferences?.edit()?.apply { - syncKeys.forEach { remove(ProfileScopedKey.of(it)) } + syncKeys().forEach { remove(ProfileScopedKey.of(it)) } }?.apply() payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) - payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) - payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) + DebridProviders.all().forEach { provider -> + payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey -> + saveProviderApiKey(provider.id, apiKey) + } + } payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults) payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode) @@ -207,4 +219,14 @@ actual object DebridSettingsStorage { payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) } + + private fun providerApiKeyKey(providerId: String): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") + return when (normalized) { + DebridProviders.TORBOX_ID -> torboxApiKeyKey + DebridProviders.REAL_DEBRID_ID -> realDebridApiKeyKey + else -> "debrid_${normalized}_api_key" + } + } } diff --git a/composeApp/src/commonMain/composeResources/values-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml index 06b43610..57e7b185 100644 --- a/composeApp/src/commonMain/composeResources/values-no/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml @@ -595,7 +595,10 @@ Vis spillbare resultater fra tilkoblede kontoer. Legg til en API-nøkkel først. Konto - Koble til Torbox-kontoen din. + Koble til %1$s-kontoen din. + %1$s API-nøkkel + Skriv inn API-nøkkelen din for %1$s. + Skriv inn %1$s API-nøkkel Umiddelbar avspilling Forbered lenker Løs første kilder før avspilling starter. diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index fe97f276..210c3fb9 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -596,10 +596,10 @@ Show playable results from connected accounts. Add an API key first. Account - Connect your Torbox account. - Torbox API Key - Enter your Torbox API key. - Enter Torbox API key + Connect your %1$s account. + %1$s API Key + Enter your %1$s API key. + Enter %1$s API key Not set Instant Playback Prepare links diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt index 88d3c32a..fb67eadb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt @@ -242,11 +242,7 @@ object DebridCredentialValidator { suspend fun validateProvider(providerId: String, apiKey: String): Boolean { val normalized = apiKey.trim() if (normalized.isBlank()) return false - return when (DebridProviders.byId(providerId)?.id) { - DebridProviders.TORBOX_ID -> TorboxApiClient.validateApiKey(normalized) - DebridProviders.REAL_DEBRID_ID -> RealDebridApiClient.validateApiKey(normalized) - else -> false - } + return DebridProviderApis.apiFor(providerId)?.validateApiKey(normalized) == true } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt index c37e584d..7176188d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt @@ -5,6 +5,7 @@ data class DebridProvider( val displayName: String, val shortName: String, val visibleInUi: Boolean = true, + val capabilities: Set = emptySet(), ) data class DebridServiceCredential( @@ -12,6 +13,12 @@ data class DebridServiceCredential( val apiKey: String, ) +enum class DebridProviderCapability { + ClientResolve, + LocalTorrentCacheCheck, + LocalTorrentResolve, +} + object DebridProviders { const val TORBOX_ID = "torbox" const val REAL_DEBRID_ID = "realdebrid" @@ -20,6 +27,11 @@ object DebridProviders { id = TORBOX_ID, displayName = "Torbox", shortName = "TB", + capabilities = setOf( + DebridProviderCapability.ClientResolve, + DebridProviderCapability.LocalTorrentCacheCheck, + DebridProviderCapability.LocalTorrentResolve, + ), ) val RealDebrid = DebridProvider( @@ -27,6 +39,7 @@ object DebridProviders { displayName = "Real-Debrid", shortName = "RD", visibleInUi = false, + capabilities = setOf(DebridProviderCapability.ClientResolve), ) private val registered = listOf(Torbox, RealDebrid) @@ -56,13 +69,11 @@ object DebridProviders { byId(id)?.shortName ?: id?.trim()?.takeIf { it.isNotBlank() }?.uppercase().orEmpty() fun configuredServices(settings: DebridSettings): List = - buildList { - settings.torboxApiKey.trim().takeIf { Torbox.visibleInUi && it.isNotBlank() }?.let { apiKey -> - add(DebridServiceCredential(Torbox, apiKey)) - } - settings.realDebridApiKey.trim().takeIf { RealDebrid.visibleInUi && it.isNotBlank() }?.let { apiKey -> - add(DebridServiceCredential(RealDebrid, apiKey)) - } + registered.mapNotNull { provider -> + settings.apiKeyFor(provider.id) + .trim() + .takeIf { provider.visibleInUi && it.isNotBlank() } + ?.let { apiKey -> DebridServiceCredential(provider, apiKey) } } fun configuredSourceNames(settings: DebridSettings): List = @@ -81,3 +92,6 @@ object DebridProviders { .ifBlank { "Debrid" } } } + +fun DebridProvider.supports(capability: DebridProviderCapability): Boolean = + capability in capabilities diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt new file mode 100644 index 00000000..b04c3538 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt @@ -0,0 +1,198 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamItem +import kotlinx.coroutines.CancellationException + +internal interface DebridProviderApi { + val provider: DebridProvider + + suspend fun validateApiKey(apiKey: String): Boolean + + suspend fun resolveClientStream( + stream: StreamItem, + apiKey: String, + season: Int?, + episode: Int?, + ): DirectDebridResolveResult +} + +internal object DebridProviderApis { + private val registered = listOf( + TorboxDebridProviderApi(), + RealDebridProviderApi(), + ) + + fun apiFor(providerId: String?): DebridProviderApi? { + val normalized = DebridProviders.byId(providerId)?.id ?: return null + return registered.firstOrNull { it.provider.id == normalized } + } +} + +private class TorboxDebridProviderApi( + private val fileSelector: TorboxFileSelector = TorboxFileSelector(), +) : DebridProviderApi { + override val provider: DebridProvider = DebridProviders.Torbox + + override suspend fun validateApiKey(apiKey: String): Boolean = + TorboxApiClient.validateApiKey(apiKey) + + override suspend fun resolveClientStream( + stream: StreamItem, + apiKey: String, + season: Int?, + episode: Int?, + ): DirectDebridResolveResult { + val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error + val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() } + ?: buildMagnetUri(resolve) + ?: return DirectDebridResolveResult.Stale + + return try { + val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet) + val torrentId = create.body?.takeIf { it.success != false }?.data?.resolvedTorrentId() + ?: return create.toFailureForCreate() + + val torrent = TorboxApiClient.getTorrent(apiKey = apiKey, id = torrentId) + if (!torrent.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val files = torrent.body?.data?.files.orEmpty() + val file = fileSelector.selectFile(files, resolve, season, episode) + ?: return DirectDebridResolveResult.Stale + val fileId = file.id ?: return DirectDebridResolveResult.Stale + + val link = TorboxApiClient.requestDownloadLink( + apiKey = apiKey, + torrentId = torrentId, + fileId = fileId, + ) + if (!link.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val url = link.body?.data?.takeIf { it.isNotBlank() } + ?: return DirectDebridResolveResult.Stale + + DirectDebridResolveResult.Success( + url = url, + filename = file.displayName().takeIf { it.isNotBlank() }, + videoSize = file.size, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + DirectDebridResolveResult.Error + } + } +} + +private class RealDebridProviderApi( + private val fileSelector: RealDebridFileSelector = RealDebridFileSelector(), +) : DebridProviderApi { + override val provider: DebridProvider = DebridProviders.RealDebrid + + override suspend fun validateApiKey(apiKey: String): Boolean = + RealDebridApiClient.validateApiKey(apiKey) + + override suspend fun resolveClientStream( + stream: StreamItem, + apiKey: String, + season: Int?, + episode: Int?, + ): DirectDebridResolveResult { + val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error + val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() } + ?: buildMagnetUri(resolve) + ?: return DirectDebridResolveResult.Stale + + return try { + val add = RealDebridApiClient.addMagnet(apiKey, magnet) + val torrentId = add.body?.id?.takeIf { add.isSuccessful && it.isNotBlank() } + ?: return add.toFailureForAdd() + var resolved = false + try { + val infoBefore = RealDebridApiClient.getTorrentInfo(apiKey, torrentId) + if (!infoBefore.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val filesBefore = infoBefore.body?.files.orEmpty() + val file = fileSelector.selectFile( + files = filesBefore, + resolve = resolve, + season = season, + episode = episode, + ) ?: return DirectDebridResolveResult.Stale + val fileId = file.id ?: return DirectDebridResolveResult.Stale + val select = RealDebridApiClient.selectFiles(apiKey, torrentId, fileId.toString()) + if (!select.isSuccessful && select.status != 202) { + return DirectDebridResolveResult.Stale + } + + val infoAfter = RealDebridApiClient.getTorrentInfo(apiKey, torrentId) + if (!infoAfter.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val link = infoAfter.body?.firstDownloadLink() + ?: return DirectDebridResolveResult.Stale + val unrestrict = RealDebridApiClient.unrestrictLink(apiKey, link) + if (!unrestrict.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val url = unrestrict.body?.download?.takeIf { it.isNotBlank() } + ?: return DirectDebridResolveResult.Stale + resolved = true + DirectDebridResolveResult.Success( + url = url, + filename = unrestrict.body.filename?.takeIf { it.isNotBlank() } + ?: file.displayName().takeIf { it.isNotBlank() }, + videoSize = unrestrict.body.filesize ?: file.bytes, + ) + } finally { + if (!resolved) { + runCatching { RealDebridApiClient.deleteTorrent(apiKey, torrentId) } + } + } + } catch (error: Exception) { + if (error is CancellationException) throw error + DirectDebridResolveResult.Error + } + } +} + +private fun buildMagnetUri(resolve: StreamClientResolve): String? { + val hash = resolve.infoHash?.takeIf { it.isNotBlank() } ?: return null + return buildString { + append("magnet:?xt=urn:btih:") + append(hash) + resolve.sources + .mapNotNull { it.toTrackerUrlOrNull() } + .distinct() + .forEach { source -> + append("&tr=") + append(encodePathSegment(source)) + } + } +} + +private fun String.toTrackerUrlOrNull(): String? { + val value = trim() + if (value.isBlank() || value.startsWith("dht:", ignoreCase = true)) return null + return value.removePrefix("tracker:").trim().takeIf { it.isNotBlank() } +} + +private fun DebridApiResponse>.toFailureForCreate(): DirectDebridResolveResult = + when (status) { + 401, 403 -> DirectDebridResolveResult.Error + 409 -> DirectDebridResolveResult.NotCached + else -> DirectDebridResolveResult.Stale + } + +private fun DebridApiResponse.toFailureForAdd(): DirectDebridResolveResult = + when (status) { + 401, 403 -> DirectDebridResolveResult.Error + else -> DirectDebridResolveResult.Stale + } + +private fun RealDebridTorrentInfoDto.firstDownloadLink(): String? { + if (!status.equals("downloaded", ignoreCase = true)) return null + return links.orEmpty().firstOrNull { 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 6fb882f8..5fc3417a 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,8 +4,7 @@ import kotlinx.serialization.Serializable data class DebridSettings( val enabled: Boolean = false, - val torboxApiKey: String = "", - val realDebridApiKey: String = "", + val providerApiKeys: Map = emptyMap(), val instantPlaybackPreparationLimit: Int = 0, val streamMaxResults: Int = 0, val streamSortMode: DebridStreamSortMode = DebridStreamSortMode.DEFAULT, @@ -17,11 +16,24 @@ data class DebridSettings( val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE, val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, ) { + val torboxApiKey: String + get() = apiKeyFor(DebridProviders.TORBOX_ID) + + val realDebridApiKey: String + get() = apiKeyFor(DebridProviders.REAL_DEBRID_ID) + val hasAnyApiKey: Boolean get() = DebridProviders.configuredServices(this).isNotEmpty() val hasCustomStreamFormatting: Boolean get() = streamNameTemplate.isNotBlank() || streamDescriptionTemplate.isNotBlank() + + fun apiKeyFor(providerId: String?): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId?.trim()?.lowercase() + ?: return "" + return providerApiKeys[normalized].orEmpty() + } } const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2 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 475597fd..158f2fab 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,8 +21,7 @@ object DebridSettingsRepository { private var hasLoaded = false private var enabled = false - private var torboxApiKey = "" - private var realDebridApiKey = "" + private var providerApiKeys = emptyMap() private var instantPlaybackPreparationLimit = 0 private var streamMaxResults = 0 private var streamSortMode = DebridStreamSortMode.DEFAULT @@ -57,24 +56,27 @@ object DebridSettingsRepository { DebridSettingsStorage.saveEnabled(value) } - fun setTorboxApiKey(value: String) { + fun setProviderApiKey(providerId: String, value: String) { ensureLoaded() + val provider = DebridProviders.byId(providerId) ?: return val normalized = value.trim() - if (torboxApiKey == normalized) return - torboxApiKey = normalized + if (providerApiKeys[provider.id].orEmpty() == normalized) return + providerApiKeys = if (normalized.isBlank()) { + providerApiKeys - provider.id + } else { + providerApiKeys + (provider.id to normalized) + } disableIfNoKeys() publish() - DebridSettingsStorage.saveTorboxApiKey(normalized) + DebridSettingsStorage.saveProviderApiKey(provider.id, normalized) + } + + fun setTorboxApiKey(value: String) { + setProviderApiKey(DebridProviders.TORBOX_ID, value) } fun setRealDebridApiKey(value: String) { - ensureLoaded() - val normalized = value.trim() - if (realDebridApiKey == normalized) return - realDebridApiKey = normalized - disableIfNoKeys() - publish() - DebridSettingsStorage.saveRealDebridApiKey(normalized) + setProviderApiKey(DebridProviders.REAL_DEBRID_ID, value) } fun setInstantPlaybackPreparationLimit(value: Int) { @@ -200,13 +202,20 @@ object DebridSettingsRepository { } private fun hasVisibleApiKey(): Boolean = - (DebridProviders.isVisible(DebridProviders.TORBOX_ID) && torboxApiKey.isNotBlank()) || - (DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID) && realDebridApiKey.isNotBlank()) + DebridProviders.visible().any { provider -> + providerApiKeys[provider.id].orEmpty().isNotBlank() + } private fun loadFromDisk() { hasLoaded = true - torboxApiKey = DebridSettingsStorage.loadTorboxApiKey()?.trim().orEmpty() - realDebridApiKey = DebridSettingsStorage.loadRealDebridApiKey()?.trim().orEmpty() + providerApiKeys = DebridProviders.all() + .mapNotNull { provider -> + DebridSettingsStorage.loadProviderApiKey(provider.id) + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let { apiKey -> provider.id to apiKey } + } + .toMap() enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasVisibleApiKey() instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit( DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0, @@ -255,8 +264,7 @@ object DebridSettingsRepository { private fun publish() { _uiState.value = DebridSettings( enabled = enabled, - torboxApiKey = torboxApiKey, - realDebridApiKey = realDebridApiKey, + providerApiKeys = providerApiKeys, instantPlaybackPreparationLimit = instantPlaybackPreparationLimit, streamMaxResults = streamMaxResults, streamSortMode = streamSortMode, 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 62fddac4..4c75578e 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 loadProviderApiKey(providerId: String): String? + fun saveProviderApiKey(providerId: String, apiKey: String) fun loadTorboxApiKey(): String? fun saveTorboxApiKey(apiKey: String) fun loadRealDebridApiKey(): 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 1f9d6d2d..6621cb84 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 @@ -43,15 +43,15 @@ object DebridStreamPresentation { return isAddonDebridCandidate && (isDirectDebridStream || ( isTorrentStream && status != null && - status.providerId == DebridProviders.TORBOX_ID && + DebridProviders.byId(status.providerId)?.supports(DebridProviderCapability.LocalTorrentCacheCheck) == true && status.state != StreamDebridCacheState.CHECKING )) } private val StreamItem.isUncachedDebridStream: Boolean get() = isInstalledAddonStream && - debridCacheStatus?.providerId == DebridProviders.TORBOX_ID && - debridCacheStatus.state == StreamDebridCacheState.NOT_CACHED + DebridProviders.byId(debridCacheStatus?.providerId)?.supports(DebridProviderCapability.LocalTorrentCacheCheck) == true && + debridCacheStatus?.state == StreamDebridCacheState.NOT_CACHED private fun applyLimits( streams: List>, 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 68968c35..e052bc18 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 @@ -23,9 +23,7 @@ import nuvio.composeapp.generated.resources.debrid_stream_stale import org.jetbrains.compose.resources.getString object DirectDebridPlaybackResolver { - private val torboxResolver = TorboxDirectDebridResolver() - private val torboxAddonStreamResolver = TorboxAddonStreamResolver() - private val realDebridResolver = RealDebridDirectDebridResolver() + private val localAddonStreamResolver = LocalDebridAddonStreamResolver() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val mutex = Mutex() private val resolvedCache = mutableMapOf() @@ -115,27 +113,29 @@ object DirectDebridPlaybackResolver { val settings = DebridSettingsRepository.snapshot() if (!settings.enabled) return false if (stream.needsLocalDebridResolve) { - return stream.isInstalledAddonStream && settings.torboxApiKey.isNotBlank() + return stream.isInstalledAddonStream && localTorrentResolveCredential(settings) != null } if (!stream.isInstalledAddonStream || !stream.isDirectDebridStream || stream.playableDirectUrl != null) { return false } - return when (DebridProviders.byId(stream.clientResolve?.service)?.id) { - DebridProviders.TORBOX_ID -> settings.torboxApiKey.isNotBlank() - DebridProviders.REAL_DEBRID_ID -> settings.realDebridApiKey.isNotBlank() - else -> false - } + val providerId = DebridProviders.byId(stream.clientResolve?.service)?.id ?: return false + return settings.apiKeyFor(providerId).isNotBlank() && DebridProviderApis.apiFor(providerId) != null } - private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult = - when { - stream.needsLocalDebridResolve -> torboxAddonStreamResolver.resolve(stream, season, episode) - else -> when (DebridProviders.byId(stream.clientResolve?.service)?.id) { - DebridProviders.TORBOX_ID -> torboxResolver.resolve(stream, season, episode) - DebridProviders.REAL_DEBRID_ID -> realDebridResolver.resolve(stream, season, episode) - else -> DirectDebridResolveResult.Error - } + private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { + if (stream.needsLocalDebridResolve) { + return localAddonStreamResolver.resolve(stream, season, episode) } + val providerId = DebridProviders.byId(stream.clientResolve?.service)?.id + ?: return DirectDebridResolveResult.Error + val apiKey = DebridSettingsRepository.snapshot() + .apiKeyFor(providerId) + .trim() + .takeIf { it.isNotBlank() } + ?: return DirectDebridResolveResult.MissingApiKey + val api = DebridProviderApis.apiFor(providerId) ?: return DirectDebridResolveResult.Error + return api.resolveClientStream(stream, apiKey, season, episode) + } suspend fun resolveToPlayableStream( stream: StreamItem, @@ -192,21 +192,23 @@ fun DirectDebridPlayableResult.toastMessage(): String? = DirectDebridPlayableResult.Error -> runBlocking { getString(Res.string.debrid_resolve_failed) } } -private class TorboxAddonStreamResolver( +private class LocalDebridAddonStreamResolver( private val fileSelector: TorboxFileSelector = TorboxFileSelector(), ) { suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { - val apiKey = DebridSettingsRepository.snapshot().torboxApiKey.trim() - if (apiKey.isBlank()) { - return DirectDebridResolveResult.MissingApiKey - } + val account = localTorrentResolveCredential() ?: return DirectDebridResolveResult.MissingApiKey + val apiKey = account.apiKey.trim() val hash = stream.infoHash?.trim()?.lowercase() if (stream.debridCacheStatus?.state == StreamDebridCacheState.NOT_CACHED) { return DirectDebridResolveResult.NotCached } - if (!hash.isNullOrBlank() && stream.debridCacheStatus?.state != StreamDebridCacheState.CACHED) { - when (TorboxAvailabilityService.isCached(hash)) { + if ( + !hash.isNullOrBlank() && + stream.debridCacheStatus?.state != StreamDebridCacheState.CACHED && + account.provider.supports(DebridProviderCapability.LocalTorrentCacheCheck) + ) { + when (LocalDebridService.isCached(account, hash)) { false -> return DirectDebridResolveResult.NotCached true, null -> Unit } @@ -214,8 +216,22 @@ private class TorboxAddonStreamResolver( val magnet = DebridMagnetBuilder.fromStream(stream) ?: return DirectDebridResolveResult.Stale - val resolve = stream.toResolveMetadata(season, episode) + val resolve = stream.toResolveMetadata(season, episode, account.provider.id) + return when (account.provider.id) { + DebridProviders.TORBOX_ID -> resolveTorbox(stream, resolve, apiKey, magnet, season, episode) + else -> DirectDebridResolveResult.Error + } + } + + private suspend fun resolveTorbox( + stream: StreamItem, + resolve: StreamClientResolve, + apiKey: String, + magnet: String, + season: Int?, + episode: Int?, + ): DirectDebridResolveResult { return try { val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet) val torrentId = create.body?.takeIf { it.success != false }?.data?.resolvedTorrentId() @@ -254,184 +270,20 @@ private class TorboxAddonStreamResolver( } } -private class TorboxDirectDebridResolver( - private val fileSelector: TorboxFileSelector = TorboxFileSelector(), -) { - suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { - val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error - val apiKey = DebridSettingsRepository.snapshot().torboxApiKey.trim() - if (apiKey.isBlank()) { - return DirectDebridResolveResult.MissingApiKey - } - val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() } - ?: buildMagnetUri(resolve) - ?: run { - return DirectDebridResolveResult.Stale - } - - return try { - val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet) - val torrentId = create.body?.takeIf { it.success != false }?.data?.resolvedTorrentId() - ?: return create.toFailureForCreate() - - val torrent = TorboxApiClient.getTorrent(apiKey = apiKey, id = torrentId) - if (!torrent.isSuccessful) { - return DirectDebridResolveResult.Stale - } - val files = torrent.body?.data?.files.orEmpty() - val file = fileSelector.selectFile(files, resolve, season, episode) - ?: run { - return DirectDebridResolveResult.Stale - } - val fileId = file.id - ?: run { - return DirectDebridResolveResult.Stale - } - - val link = TorboxApiClient.requestDownloadLink( - apiKey = apiKey, - torrentId = torrentId, - fileId = fileId, - ) - if (!link.isSuccessful) { - return DirectDebridResolveResult.Stale - } - val url = link.body?.data?.takeIf { it.isNotBlank() } - ?: run { - return DirectDebridResolveResult.Stale - } - - DirectDebridResolveResult.Success( - url = url, - filename = file.displayName().takeIf { it.isNotBlank() }, - videoSize = file.size, - ) - } catch (error: Exception) { - if (error is CancellationException) throw error - DirectDebridResolveResult.Error - } - } - -} - -private class RealDebridDirectDebridResolver( - private val fileSelector: RealDebridFileSelector = RealDebridFileSelector(), -) { - suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { - val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error - val apiKey = DebridSettingsRepository.snapshot().realDebridApiKey.trim() - if (apiKey.isBlank()) { - return DirectDebridResolveResult.MissingApiKey - } - val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() } - ?: buildMagnetUri(resolve) - ?: run { - return DirectDebridResolveResult.Stale - } - - return try { - val add = RealDebridApiClient.addMagnet(apiKey, magnet) - val torrentId = add.body?.id?.takeIf { add.isSuccessful && it.isNotBlank() } - ?: return add.toFailureForAdd() - var resolved = false - try { - val infoBefore = RealDebridApiClient.getTorrentInfo(apiKey, torrentId) - if (!infoBefore.isSuccessful) { - return DirectDebridResolveResult.Stale - } - val filesBefore = infoBefore.body?.files.orEmpty() - val file = fileSelector.selectFile( - files = filesBefore, - resolve = resolve, - season = season, - episode = episode, - ) - ?: run { - return DirectDebridResolveResult.Stale - } - val fileId = file.id - ?: run { - return DirectDebridResolveResult.Stale - } - val select = RealDebridApiClient.selectFiles(apiKey, torrentId, fileId.toString()) - if (!select.isSuccessful && select.status != 202) { - return DirectDebridResolveResult.Stale - } - - val infoAfter = RealDebridApiClient.getTorrentInfo(apiKey, torrentId) - if (!infoAfter.isSuccessful) { - return DirectDebridResolveResult.Stale - } - val link = infoAfter.body?.firstDownloadLink() - ?: run { - return DirectDebridResolveResult.Stale - } - val unrestrict = RealDebridApiClient.unrestrictLink(apiKey, link) - if (!unrestrict.isSuccessful) { - return DirectDebridResolveResult.Stale - } - val url = unrestrict.body?.download?.takeIf { it.isNotBlank() } - ?: run { - return DirectDebridResolveResult.Stale - } - resolved = true - DirectDebridResolveResult.Success( - url = url, - filename = unrestrict.body.filename?.takeIf { it.isNotBlank() } - ?: file.displayName().takeIf { it.isNotBlank() }, - videoSize = unrestrict.body.filesize ?: file.bytes, - ) - } finally { - if (!resolved) { - runCatching { RealDebridApiClient.deleteTorrent(apiKey, torrentId) } - } - } - } catch (error: Exception) { - if (error is CancellationException) throw error - DirectDebridResolveResult.Error - } - } - - private fun DebridApiResponse.toFailureForAdd(): DirectDebridResolveResult = - when (status) { - 401, 403 -> DirectDebridResolveResult.Error - else -> DirectDebridResolveResult.Stale - } - - private fun RealDebridTorrentInfoDto.firstDownloadLink(): String? { - if (!status.equals("downloaded", ignoreCase = true)) return null - return links.orEmpty().firstOrNull { it.isNotBlank() } - } -} - -private fun buildMagnetUri(resolve: StreamClientResolve): String? { - val hash = resolve.infoHash?.takeIf { it.isNotBlank() } ?: return null - return buildString { - append("magnet:?xt=urn:btih:") - append(hash) - resolve.sources - .mapNotNull { it.toTrackerUrlOrNull() } - .distinct() - .forEach { source -> - append("&tr=") - append(encodePathSegment(source)) - } - } -} - -private fun String.toTrackerUrlOrNull(): String? { - val value = trim() - if (value.isBlank() || value.startsWith("dht:", ignoreCase = true)) return null - return value.removePrefix("tracker:").trim().takeIf { it.isNotBlank() } -} +private fun localTorrentResolveCredential( + settings: DebridSettings = DebridSettingsRepository.snapshot(), +): DebridServiceCredential? = + DebridProviders.configuredServices(settings) + .firstOrNull { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentResolve) } private fun StreamItem.debridResolveCacheKey(season: Int?, episode: Int?): String? { val resolve = clientResolve if (resolve == null && needsLocalDebridResolve) { - val apiKey = DebridSettingsRepository.snapshot().torboxApiKey.trim().takeIf { it.isNotBlank() } ?: return null + val account = localTorrentResolveCredential() ?: return null + val apiKey = account.apiKey.trim().takeIf { it.isNotBlank() } ?: return null val identity = infoHash ?: torrentMagnetUri ?: behaviorHints.filename ?: return null return listOf( - DebridProviders.TORBOX_ID, + account.provider.id, apiKey.stableFingerprint(), identity.trim().lowercase(), fileIdx?.toString().orEmpty(), @@ -442,11 +294,11 @@ private fun StreamItem.debridResolveCacheKey(season: Int?, episode: Int?): Strin } resolve ?: return null val providerId = DebridProviders.byId(resolve.service)?.id ?: return null - val apiKey = when (providerId) { - DebridProviders.TORBOX_ID -> DebridSettingsRepository.snapshot().torboxApiKey - DebridProviders.REAL_DEBRID_ID -> DebridSettingsRepository.snapshot().realDebridApiKey - else -> "" - }.trim().takeIf { it.isNotBlank() } ?: return null + val apiKey = DebridSettingsRepository.snapshot() + .apiKeyFor(providerId) + .trim() + .takeIf { it.isNotBlank() } + ?: return null val identity = resolve.infoHash ?: resolve.magnetUri ?: resolve.torrentName @@ -464,7 +316,7 @@ private fun StreamItem.debridResolveCacheKey(season: Int?, episode: Int?): Strin ).joinToString("|") } -private fun StreamItem.toResolveMetadata(season: Int?, episode: Int?): StreamClientResolve = +private fun StreamItem.toResolveMetadata(season: Int?, episode: Int?, providerId: String): StreamClientResolve = StreamClientResolve( type = "torrent", infoHash = infoHash, @@ -475,7 +327,7 @@ private fun StreamItem.toResolveMetadata(season: Int?, episode: Int?): StreamCli filename = behaviorHints.filename, season = season, episode = episode, - service = DebridProviders.TORBOX_ID, + service = providerId, isCached = debridCacheStatus?.state == StreamDebridCacheState.CACHED, ) 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 f9e99075..c69ce1f7 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 @@ -42,7 +42,7 @@ object DirectDebridStreamPreparer { } if (!consumeBackgroundBudget()) { - log.d { "Skipping instant playback preparation; local Torbox budget reached" } + log.d { "Skipping instant playback preparation; local debrid budget reached" } return } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt similarity index 60% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt index 39cb0e07..2a3f3741 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt @@ -4,23 +4,21 @@ import com.nuvio.app.features.streams.AddonStreamGroup import com.nuvio.app.features.streams.StreamDebridCacheState import com.nuvio.app.features.streams.StreamDebridCacheStatus import com.nuvio.app.features.streams.StreamItem -import kotlinx.coroutines.CancellationException -object TorboxAvailabilityService { +object LocalDebridAvailabilityService { fun markChecking( groups: List, eligibleGroupIds: Set? = null, ): List { - val settings = DebridSettingsRepository.snapshot() - if (!settings.enabled || settings.torboxApiKey.isBlank()) return groups + val account = cacheCheckAccount() ?: return groups return groups.updateAvailabilityStatus(eligibleGroupIds) { stream -> - if (stream.torboxAvailabilityHash() == null || stream.debridCacheStatus?.state == StreamDebridCacheState.CACHED) { + if (stream.localAvailabilityHash() == null || stream.debridCacheStatus?.state == StreamDebridCacheState.CACHED) { stream } else { stream.copy( debridCacheStatus = StreamDebridCacheStatus( - providerId = DebridProviders.TORBOX_ID, - providerName = DebridProviders.Torbox.displayName, + providerId = account.provider.id, + providerName = account.provider.displayName, state = StreamDebridCacheState.CHECKING, ), ) @@ -32,31 +30,28 @@ object TorboxAvailabilityService { groups: List, eligibleGroupIds: Set? = null, ): List { - val settings = DebridSettingsRepository.snapshot() - val apiKey = settings.torboxApiKey.trim() - if (!settings.enabled || apiKey.isBlank()) return groups - + val account = cacheCheckAccount() ?: return groups val hashes = groups .filter { group -> eligibleGroupIds == null || group.addonId in eligibleGroupIds } .flatMap { group -> group.streams.mapNotNull { stream -> - stream.torboxAvailabilityHash() + stream.localAvailabilityHash() ?.takeUnless { stream.debridCacheStatus?.state in FINAL_CACHE_STATES } } } .distinct() if (hashes.isEmpty()) return groups - val cached = checkCached(apiKey = apiKey, hashes = hashes) + val cached = LocalDebridService.checkCached(account = account, hashes = hashes) ?: return groups.updateAvailabilityStatus(eligibleGroupIds) { stream -> - val hash = stream.torboxAvailabilityHash() + val hash = stream.localAvailabilityHash() if (hash == null) { stream } else { stream.copy( debridCacheStatus = StreamDebridCacheStatus( - providerId = DebridProviders.TORBOX_ID, - providerName = DebridProviders.Torbox.displayName, + providerId = account.provider.id, + providerName = account.provider.displayName, state = StreamDebridCacheState.UNKNOWN, ), ) @@ -64,13 +59,13 @@ object TorboxAvailabilityService { } return groups.updateAvailabilityStatus(eligibleGroupIds) { stream -> - val hash = stream.torboxAvailabilityHash() ?: return@updateAvailabilityStatus stream + val hash = stream.localAvailabilityHash() ?: return@updateAvailabilityStatus stream if (stream.debridCacheStatus?.state in FINAL_CACHE_STATES) return@updateAvailabilityStatus stream val cachedItem = cached[hash] stream.copy( debridCacheStatus = StreamDebridCacheStatus( - providerId = DebridProviders.TORBOX_ID, - providerName = DebridProviders.Torbox.displayName, + providerId = account.provider.id, + providerName = account.provider.displayName, state = if (cachedItem == null) StreamDebridCacheState.NOT_CACHED else StreamDebridCacheState.CACHED, cachedName = cachedItem?.name, cachedSize = cachedItem?.size, @@ -80,28 +75,16 @@ object TorboxAvailabilityService { } suspend fun isCached(hash: String): Boolean? { - val settings = DebridSettingsRepository.snapshot() - val apiKey = settings.torboxApiKey.trim() - val normalizedHash = hash.trim().lowercase().takeIf { it.isNotBlank() } ?: return null - if (!settings.enabled || apiKey.isBlank()) return null - return checkCached(apiKey = apiKey, hashes = listOf(normalizedHash))?.containsKey(normalizedHash) + val account = cacheCheckAccount() ?: return null + return LocalDebridService.isCached(account, hash) } - private suspend fun checkCached( - apiKey: String, - hashes: List, - ): Map? = - try { - val response = TorboxApiClient.checkCached(apiKey = apiKey, hashes = hashes) - if (!response.isSuccessful || response.body?.success == false) { - null - } else { - response.body?.data.orEmpty().mapKeys { it.key.lowercase() } - } - } catch (error: Exception) { - if (error is CancellationException) throw error - null - } + private fun cacheCheckAccount(): DebridServiceCredential? { + val settings = DebridSettingsRepository.snapshot() + if (!settings.enabled) return null + return DebridProviders.configuredServices(settings) + .firstOrNull { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentCacheCheck) } + } } private val FINAL_CACHE_STATES = setOf( @@ -109,7 +92,7 @@ private val FINAL_CACHE_STATES = setOf( StreamDebridCacheState.NOT_CACHED, ) -internal fun StreamItem.torboxAvailabilityHash(): String? = +internal fun StreamItem.localAvailabilityHash(): String? = infoHash ?.trim() ?.lowercase() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridService.kt new file mode 100644 index 00000000..4c40e901 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridService.kt @@ -0,0 +1,45 @@ +package com.nuvio.app.features.debrid + +import kotlinx.coroutines.CancellationException + +internal data class LocalDebridCachedItem( + val name: String?, + val size: Long?, +) + +internal object LocalDebridService { + suspend fun checkCached( + account: DebridServiceCredential, + hashes: List, + ): Map? = + when (account.provider.id) { + DebridProviders.TORBOX_ID -> checkTorboxCached(account.apiKey, hashes) + else -> null + } + + suspend fun isCached(account: DebridServiceCredential, hash: String): Boolean? { + val normalizedHash = hash.trim().lowercase().takeIf { it.isNotBlank() } ?: return null + return checkCached(account, listOf(normalizedHash))?.containsKey(normalizedHash) + } + + private suspend fun checkTorboxCached( + apiKey: String, + hashes: List, + ): Map? = + try { + val response = TorboxApiClient.checkCached(apiKey = apiKey, hashes = hashes) + if (!response.isSuccessful || response.body?.success == false) { + null + } else { + response.body?.data.orEmpty().mapKeys { it.key.lowercase() }.mapValues { (_, value) -> + LocalDebridCachedItem( + name = value.name, + size = value.size, + ) + } + } + } catch (error: Exception) { + if (error is CancellationException) throw error + null + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 7db0dc80..12b59bb4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -8,7 +8,7 @@ import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.debrid.DebridStreamPresentation import com.nuvio.app.features.debrid.DirectDebridStreamPreparer -import com.nuvio.app.features.debrid.TorboxAvailabilityService +import com.nuvio.app.features.debrid.LocalDebridAvailabilityService import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.pluginContentId @@ -274,14 +274,14 @@ object PlayerStreamsRepository { if (group.addonId !in installedAddonIds || group.streams.isEmpty()) return val eligibleGroupIds = setOf(group.addonId) - val checkingGroup = TorboxAvailabilityService.markChecking( + val checkingGroup = LocalDebridAvailabilityService.markChecking( groups = listOf(group), eligibleGroupIds = eligibleGroupIds, ).firstOrNull() ?: group publishStreamGroup(checkingGroup) val availabilityJob = launch { - val availabilityGroup = TorboxAvailabilityService.annotateCachedAvailability( + val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability( groups = listOf(checkingGroup), eligibleGroupIds = eligibleGroupIds, ).firstOrNull() ?: checkingGroup 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 86c8a4b7..43633e1e 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 @@ -85,7 +85,7 @@ import nuvio.composeapp.generated.resources.settings_debrid_key_invalid import nuvio.composeapp.generated.resources.settings_debrid_name_template import nuvio.composeapp.generated.resources.settings_debrid_name_template_description import nuvio.composeapp.generated.resources.settings_debrid_not_set -import nuvio.composeapp.generated.resources.settings_debrid_provider_torbox_description +import nuvio.composeapp.generated.resources.settings_debrid_provider_description import nuvio.composeapp.generated.resources.settings_debrid_section_instant_playback import nuvio.composeapp.generated.resources.settings_debrid_section_formatting import nuvio.composeapp.generated.resources.settings_debrid_section_providers @@ -127,35 +127,43 @@ internal fun LazyListScope.debridSettingsContent( } item { - var showApiKeyDialog by rememberSaveable { mutableStateOf(false) } + var activeProviderId by rememberSaveable { mutableStateOf(null) } + val providers = remember { DebridProviders.visible() } SettingsSection( title = stringResource(Res.string.settings_debrid_section_providers), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { - DebridPreferenceRow( - isTablet = isTablet, - title = DebridProviders.Torbox.displayName, - description = stringResource(Res.string.settings_debrid_provider_torbox_description), - value = maskDebridApiKey(settings.torboxApiKey, stringResource(Res.string.settings_debrid_not_set)), - enabled = true, - onClick = { showApiKeyDialog = true }, - ) + providers.forEachIndexed { index, provider -> + if (index > 0) { + SettingsGroupDivider(isTablet = isTablet) + } + DebridPreferenceRow( + isTablet = isTablet, + title = provider.displayName, + description = stringResource(Res.string.settings_debrid_provider_description, provider.displayName), + value = maskDebridApiKey(settings.apiKeyFor(provider.id), stringResource(Res.string.settings_debrid_not_set)), + enabled = true, + onClick = { activeProviderId = provider.id }, + ) + } } } - if (showApiKeyDialog) { - DebridApiKeyDialog( - providerId = DebridProviders.TORBOX_ID, - title = stringResource(Res.string.settings_debrid_dialog_title), - subtitle = stringResource(Res.string.settings_debrid_dialog_subtitle), - placeholder = stringResource(Res.string.settings_debrid_dialog_placeholder), - currentValue = settings.torboxApiKey, - onSave = DebridSettingsRepository::setTorboxApiKey, - onDismiss = { showApiKeyDialog = false }, - ) - } + activeProviderId + ?.let(DebridProviders::byId) + ?.let { provider -> + DebridApiKeyDialog( + providerId = provider.id, + title = stringResource(Res.string.settings_debrid_dialog_title, provider.displayName), + subtitle = stringResource(Res.string.settings_debrid_dialog_subtitle, provider.displayName), + placeholder = stringResource(Res.string.settings_debrid_dialog_placeholder, provider.displayName), + currentValue = settings.apiKeyFor(provider.id), + onSave = { apiKey -> DebridSettingsRepository.setProviderApiKey(provider.id, apiKey) }, + onDismiss = { activeProviderId = null }, + ) + } } item { 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 e9cd4f2d..e31709cb 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 @@ -10,7 +10,7 @@ import com.nuvio.app.features.debrid.DebridSettings import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.debrid.DebridStreamPresentation import com.nuvio.app.features.debrid.DirectDebridStreamPreparer -import com.nuvio.app.features.debrid.TorboxAvailabilityService +import com.nuvio.app.features.debrid.LocalDebridAvailabilityService import com.nuvio.app.features.player.PlayerSettingsRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -120,11 +120,11 @@ object AddonStreamWarmupRepository { videoId = key.videoId, ) val eligibleGroupIds = setOf(group.addonId) - val checkingGroup = TorboxAvailabilityService.markChecking( + val checkingGroup = LocalDebridAvailabilityService.markChecking( groups = listOf(group), eligibleGroupIds = eligibleGroupIds, ).firstOrNull() ?: group - val availabilityGroup = TorboxAvailabilityService.annotateCachedAvailability( + val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability( groups = listOf(checkingGroup), eligibleGroupIds = eligibleGroupIds, ).firstOrNull() ?: checkingGroup 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 6ffe3299..1d46f16d 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 @@ -8,7 +8,7 @@ import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.debrid.DirectDebridStreamPreparer import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.debrid.DebridStreamPresentation -import com.nuvio.app.features.debrid.TorboxAvailabilityService +import com.nuvio.app.features.debrid.LocalDebridAvailabilityService import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.plugins.PluginRepository @@ -265,14 +265,14 @@ object StreamsRepository { if (group.addonId !in installedAddonIds || group.streams.isEmpty()) return val eligibleGroupIds = setOf(group.addonId) - val checkingGroup = TorboxAvailabilityService.markChecking( + val checkingGroup = LocalDebridAvailabilityService.markChecking( groups = listOf(group), eligibleGroupIds = eligibleGroupIds, ).firstOrNull() ?: group publishAddonGroup(checkingGroup) val availabilityJob = launch { - val availabilityGroup = TorboxAvailabilityService.annotateCachedAvailability( + val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability( groups = listOf(checkingGroup), eligibleGroupIds = eligibleGroupIds, ).firstOrNull() ?: checkingGroup diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt new file mode 100644 index 00000000..a8499b58 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt @@ -0,0 +1,22 @@ +package com.nuvio.app.features.debrid + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DebridProviderTest { + @Test + fun `torbox exposes local addon capabilities`() { + assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.ClientResolve)) + assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentCacheCheck)) + assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentResolve)) + } + + @Test + fun `real debrid stays hidden from local addon capability paths`() { + assertFalse(DebridProviders.RealDebrid.visibleInUi) + assertTrue(DebridProviders.RealDebrid.supports(DebridProviderCapability.ClientResolve)) + assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentCacheCheck)) + assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentResolve)) + } +} 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 new file mode 100644 index 00000000..b686d00b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridSettingsTest.kt @@ -0,0 +1,36 @@ +package com.nuvio.app.features.debrid + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DebridSettingsTest { + @Test + fun `normalizes provider ids when reading api keys`() { + val settings = DebridSettings( + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "tb_key"), + ) + + assertEquals("tb_key", settings.apiKeyFor("TORBOX")) + assertEquals("tb_key", settings.torboxApiKey) + assertEquals("", settings.realDebridApiKey) + } + + @Test + fun `configured services are driven by visible registered providers`() { + val settings = DebridSettings( + providerApiKeys = mapOf( + DebridProviders.TORBOX_ID to "tb_key", + DebridProviders.REAL_DEBRID_ID to "rd_key", + ), + ) + + val services = DebridProviders.configuredServices(settings) + + assertEquals(listOf(DebridProviders.TORBOX_ID), services.map { it.provider.id }) + assertEquals("tb_key", services.single().apiKey) + assertTrue(settings.hasAnyApiKey) + assertFalse(DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID)) + } +} 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 6abf6975..16b947f8 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 @@ -21,7 +21,7 @@ class DebridStreamPresentationTest { stream = stream, settings = DebridSettings( enabled = true, - torboxApiKey = "key", + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), streamNameTemplate = "{stream.resolution} {service.shortName} {service.cached::istrue[\"Ready\"||\"Not Ready\"]}", streamDescriptionTemplate = "{stream.quality} {stream.encode}\n{stream.size::bytes}\n{stream.filename}", ), @@ -67,7 +67,7 @@ class DebridStreamPresentationTest { groups = listOf(group), settings = DebridSettings( enabled = true, - torboxApiKey = "key", + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), streamMaxResults = 2, streamSortMode = DebridStreamSortMode.QUALITY_DESC, streamMinimumQuality = DebridStreamMinimumQuality.P1080, @@ -102,7 +102,7 @@ class DebridStreamPresentationTest { ), settings = DebridSettings( enabled = true, - torboxApiKey = "key", + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), ), ).single().streams diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt index 68acd752..8f56606c 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt @@ -56,8 +56,8 @@ class DirectDebridStreamPreparerTest { StreamItem( name = name, url = url, - addonName = "Torbox Instant", - addonId = "debrid:torbox", + addonName = "Addon", + addonId = "addon:test", clientResolve = StreamClientResolve( type = "debrid", service = DebridProviders.TORBOX_ID, 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 dc85c449..1dae8d1b 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 @@ -26,21 +26,20 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" - private val syncKeys = listOf( - enabledKey, - torboxApiKeyKey, - realDebridApiKeyKey, - instantPlaybackPreparationLimitKey, - streamMaxResultsKey, - streamSortModeKey, - streamMinimumQualityKey, - streamDolbyVisionFilterKey, - streamHdrFilterKey, - streamCodecFilterKey, - streamPreferencesKey, - streamNameTemplateKey, - streamDescriptionTemplateKey, - ) + private fun syncKeys(): List = + listOf( + enabledKey, + instantPlaybackPreparationLimitKey, + streamMaxResultsKey, + streamSortModeKey, + streamMinimumQualityKey, + streamDolbyVisionFilterKey, + streamHdrFilterKey, + streamCodecFilterKey, + streamPreferencesKey, + streamNameTemplateKey, + streamDescriptionTemplateKey, + ) + DebridProviders.all().map { providerApiKeyKey(it.id) } actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) @@ -48,16 +47,23 @@ actual object DebridSettingsStorage { saveBoolean(enabledKey, enabled) } - actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey) + actual fun loadProviderApiKey(providerId: String): String? = + loadString(providerApiKeyKey(providerId)) - actual fun saveTorboxApiKey(apiKey: String) { - saveString(torboxApiKeyKey, apiKey) + actual fun saveProviderApiKey(providerId: String, apiKey: String) { + saveString(providerApiKeyKey(providerId), apiKey) } - actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey) + actual fun loadTorboxApiKey(): String? = loadProviderApiKey(DebridProviders.TORBOX_ID) + + actual fun saveTorboxApiKey(apiKey: String) { + saveProviderApiKey(DebridProviders.TORBOX_ID, apiKey) + } + + actual fun loadRealDebridApiKey(): String? = loadProviderApiKey(DebridProviders.REAL_DEBRID_ID) actual fun saveRealDebridApiKey(apiKey: String) { - saveString(realDebridApiKeyKey, apiKey) + saveProviderApiKey(DebridProviders.REAL_DEBRID_ID, apiKey) } actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey) @@ -157,8 +163,11 @@ actual object DebridSettingsStorage { actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } - loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) } - loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) } + DebridProviders.all().forEach { provider -> + loadProviderApiKey(provider.id)?.let { + put(providerApiKeyKey(provider.id), encodeSyncString(it)) + } + } loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) } loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) } @@ -172,13 +181,16 @@ actual object DebridSettingsStorage { } actual fun replaceFromSyncPayload(payload: JsonObject) { - syncKeys.forEach { key -> + syncKeys().forEach { key -> NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key)) } payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) - payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) - payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) + DebridProviders.all().forEach { provider -> + payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey -> + saveProviderApiKey(provider.id, apiKey) + } + } payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults) payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode) @@ -190,4 +202,14 @@ actual object DebridSettingsStorage { payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) } + + private fun providerApiKeyKey(providerId: String): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") + return when (normalized) { + DebridProviders.TORBOX_ID -> torboxApiKeyKey + DebridProviders.REAL_DEBRID_ID -> realDebridApiKeyKey + else -> "debrid_${normalized}_api_key" + } + } }