From 2a550c43567f39871fa170d1f020fc7006503c48 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 20 May 2026 13:16:22 +0530 Subject: [PATCH 01/18] feat: debrid hybrid approach --- composeApp/build.gradle.kts | 13 - .../composeResources/values/strings.xml | 1 + .../commonMain/kotlin/com/nuvio/app/App.kt | 50 +-- .../app/features/debrid/DebridApiClients.kt | 28 ++ .../app/features/debrid/DebridApiModels.kt | 13 +- .../features/debrid/DebridMagnetBuilder.kt | 37 +++ .../features/debrid/DebridStreamFormatter.kt | 90 ++++-- .../debrid/DebridStreamFormatterDefaults.kt | 3 +- ...mFilter.kt => DebridStreamPresentation.kt} | 290 +++++++++--------- .../debrid/DebridStreamTemplateEngine.kt | 6 +- .../debrid/DirectDebridConfigEncoder.kt | 39 --- .../features/debrid/DirectDebridResolver.kt | 164 +++++++++- .../debrid/DirectDebridStreamPreparer.kt | 16 +- .../debrid/DirectDebridStreamSource.kt | 253 --------------- .../debrid/TorboxAvailabilityService.kt | 111 +++++++ .../app/features/details/MetaDetailsScreen.kt | 16 - .../features/downloads/DownloadsRepository.kt | 2 +- .../features/player/PlayerEpisodesPanel.kt | 11 +- .../nuvio/app/features/player/PlayerScreen.kt | 8 +- .../app/features/player/PlayerSourcesPanel.kt | 16 +- .../player/PlayerStreamsRepository.kt | 74 ++--- .../features/settings/DebridSettingsPage.kt | 4 +- .../streams/StreamAutoPlaySelector.kt | 15 +- .../app/features/streams/StreamModels.kt | 33 ++ .../app/features/streams/StreamsRepository.kt | 97 +++--- .../app/features/streams/StreamsScreen.kt | 81 ++++- .../features/streams/StreamsTabletLayout.kt | 2 + .../debrid/DebridStreamFormatterTest.kt | 122 -------- .../debrid/DebridStreamPresentationTest.kt | 103 +++++++ .../debrid/DebridStreamTemplateEngineTest.kt | 45 --- .../debrid/DirectDebridConfigEncoderTest.kt | 27 -- .../debrid/DirectDebridStreamFilterTest.kt | 210 ------------- .../app/features/streams/StreamParserTest.kt | 2 +- iosApp/Configuration/Version.xcconfig | 2 +- 34 files changed, 895 insertions(+), 1089 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt rename composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/{DirectDebridStreamFilter.kt => DebridStreamPresentation.kt} (84%) delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 89ebdf46..5c5811e4 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -90,19 +90,6 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { ) } - outDir.resolve("com/nuvio/app/features/debrid").apply { - mkdirs() - resolve("DebridConfig.kt").writeText( - """ - |package com.nuvio.app.features.debrid - | - |object DebridConfig { - | const val DIRECT_DEBRID_API_BASE_URL = "${props.getProperty("DIRECT_DEBRID_API_BASE_URL", "")}" - |} - """.trimMargin() - ) - } - outDir.resolve("com/nuvio/app/core/build").apply { mkdirs() resolve("AppVersionConfig.kt").writeText( diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ddbb9b65..fe97f276 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1153,6 +1153,7 @@ SIZE %1$s This stream type is not supported Add a Debrid API key in Settings. + Not cached on Torbox. This Debrid result expired. Refreshing streams. Could not resolve this Debrid stream. Couldn't open external player diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 4058c118..3d5e7c37 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1512,30 +1512,34 @@ private fun MainAppContent( if (autoPlayHandled) return@LaunchedEffect if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect val selectedStream = streamsUiState.autoPlayStream ?: return@LaunchedEffect - val stream = when ( - val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( - stream = selectedStream, - season = launch.seasonNumber, - episode = launch.episodeNumber, - ) - ) { - is DirectDebridPlayableResult.Success -> resolved.stream - else -> { - resolved.toastMessage()?.let { NuvioToastController.show(it) } - StreamsRepository.consumeAutoPlay() - if (resolved == DirectDebridPlayableResult.Stale) { - StreamsRepository.reload( - type = launch.type, - videoId = effectiveVideoId, - season = launch.seasonNumber, - episode = launch.episodeNumber, - manualSelection = launch.manualSelection, - ) + val stream = if (DirectDebridPlaybackResolver.shouldResolveToPlayableStream(selectedStream)) { + when ( + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = selectedStream, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) + ) { + is DirectDebridPlayableResult.Success -> resolved.stream + else -> { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + StreamsRepository.consumeAutoPlay() + if (resolved == DirectDebridPlayableResult.Stale) { + StreamsRepository.reload( + type = launch.type, + videoId = effectiveVideoId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + manualSelection = launch.manualSelection, + ) + } + return@LaunchedEffect } - return@LaunchedEffect } + } else { + selectedStream } - val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect + val sourceUrl = stream.playableDirectUrl ?: return@LaunchedEffect autoPlayHandled = true if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( @@ -1612,7 +1616,7 @@ private fun MainAppContent( forceExternal: Boolean, forceInternal: Boolean, ) { - if (stream.isDirectDebridStream && stream.directPlaybackUrl == null) { + if (DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) { if (resolvingDebridStream) return streamRouteScope.launch { resolvingDebridStream = true @@ -1646,7 +1650,7 @@ private fun MainAppContent( } return } - val sourceUrl = stream.directPlaybackUrl ?: return + val sourceUrl = stream.playableDirectUrl ?: return if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( type = launch.type, 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 cc89019a..88d3c32a 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 @@ -5,6 +5,7 @@ import com.nuvio.app.features.addons.httpRequestRaw import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json internal data class DebridApiResponse( @@ -38,6 +39,33 @@ internal object TorboxApiClient { body = "", ) + suspend fun checkCached( + apiKey: String, + hashes: List, + ): DebridApiResponse>> { + val normalizedHashes = hashes + .map { it.trim().lowercase() } + .filter { it.isNotBlank() } + .distinct() + if (normalizedHashes.isEmpty()) { + return DebridApiResponse( + status = 200, + body = TorboxEnvelopeDto(success = true, data = emptyMap()), + rawBody = "", + ) + } + val body = DebridApiJson.json.encodeToString( + TorboxCheckCachedRequestDto(hashes = normalizedHashes), + ) + return request( + method = "POST", + url = "$BASE_URL/v1/api/torrents/checkcached?format=object", + apiKey = apiKey, + body = body, + contentType = "application/json", + ) + } + suspend fun createTorrent(apiKey: String, magnet: String): DebridApiResponse> { val boundary = "NuvioDebrid${magnet.hashCode().toUInt()}" val body = multipartFormBody( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt index 50a89fde..ff74b44d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt @@ -44,6 +44,18 @@ internal data class TorboxTorrentFileDto( .orEmpty() } +@Serializable +internal data class TorboxCheckCachedRequestDto( + val hashes: List, +) + +@Serializable +internal data class TorboxCachedItemDto( + val name: String? = null, + val size: Long? = null, + val hash: String? = null, +) + @Serializable internal data class RealDebridAddTorrentDto( val id: String? = null, @@ -91,4 +103,3 @@ internal data class RealDebridUnrestrictLinkDto( val streamable: Int? = null, val type: String? = null, ) - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt new file mode 100644 index 00000000..e45d32bb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt @@ -0,0 +1,37 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamItem + +internal object DebridMagnetBuilder { + fun fromStream(stream: StreamItem): String? { + stream.torrentMagnetUri?.takeIf { it.isNotBlank() }?.let { return it } + val hash = stream.infoHash?.trim()?.takeIf { it.isNotBlank() } ?: return null + return buildString { + append("magnet:?xt=urn:btih:") + append(hash) + stream.behaviorHints.filename + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let { filename -> + append("&dn=") + append(encodePathSegment(filename)) + } + stream.sources + .mapNotNull(::trackerUrl) + .distinct() + .forEach { tracker -> + append("&tr=") + append(encodePathSegment(tracker)) + } + } + } + + private fun trackerUrl(source: String): String? { + val value = source.trim() + if (value.isBlank() || value.startsWith("dht:", ignoreCase = true)) return null + return value + .removePrefix("tracker:") + .trim() + .takeIf { it.isNotBlank() } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt index dd73d303..c4880bc1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt @@ -1,15 +1,17 @@ package com.nuvio.app.features.debrid +import com.nuvio.app.features.debrid.DebridStreamPresentation.isManagedDebridStream import com.nuvio.app.features.streams.StreamClientResolve import com.nuvio.app.features.streams.StreamClientResolveParsed +import com.nuvio.app.features.streams.StreamDebridCacheState import com.nuvio.app.features.streams.StreamItem class DebridStreamFormatter( private val engine: DebridStreamTemplateEngine = DebridStreamTemplateEngine(), ) { fun format(stream: StreamItem, settings: DebridSettings): StreamItem { - if (!stream.isDirectDebridStream) return stream - val values = buildValues(stream) + if (!stream.isManagedDebridStream) return stream + val values = buildValues(stream, settings) val formattedName = engine.render(settings.streamNameTemplate, values) .lineSequence() .joinToString(" ") { it.trim() } @@ -23,23 +25,26 @@ class DebridStreamFormatter( .trim() return stream.copy( - name = formattedName.ifBlank { stream.name ?: DebridProviders.instantName(stream.clientResolve?.service) }, + name = formattedName.ifBlank { stream.name ?: DebridProviders.displayName(serviceId(stream)) }, description = formattedDescription.ifBlank { stream.description ?: stream.title }, ) } - private fun buildValues(stream: StreamItem): Map { + private fun buildValues(stream: StreamItem, settings: DebridSettings): Map { val resolve = stream.clientResolve val raw = resolve?.stream?.raw val parsed = raw?.parsed + val facts = DebridStreamMetadata.facts( + stream = stream, + preferences = DebridStreamMetadata.effectivePreferences(settings), + ) val seasons = parsed?.seasons.orEmpty() val episodes = parsed?.episodes.orEmpty() val season = resolve?.season ?: seasons.singleOrFirstOrNull() val episode = resolve?.episode ?: episodes.singleOrFirstOrNull() - val visualTags = buildList { - addAll(parsed?.hdr.orEmpty()) - parsed?.bitDepth?.takeIf { it.isNotBlank() }?.let { add(it) } - } + val visualTags = facts.visualTags.mapNotUnknown { it.label } + val audioTags = facts.audioTags.mapNotUnknown { it.label } + val audioChannels = facts.audioChannels.mapNotUnknown { it.label } val edition = parsed?.edition ?: buildEdition(parsed) return linkedMapOf( @@ -52,46 +57,52 @@ class DebridStreamFormatter( "stream.seasonEpisode" to buildSeasonEpisodeList(season, episode, seasons, episodes), "stream.formattedEpisodes" to formatEpisodes(episodes), "stream.formattedSeasons" to formatSeasons(seasons), - "stream.resolution" to parsed?.resolution, + "stream.resolution" to facts.resolution.labelUnlessUnknown(), "stream.library" to false, - "stream.quality" to parsed?.quality, + "stream.quality" to facts.quality.labelUnlessUnknown(), "stream.visualTags" to visualTags, - "stream.audioTags" to parsed?.audio.orEmpty(), - "stream.audioChannels" to parsed?.channels.orEmpty(), - "stream.languages" to parsed?.languages.orEmpty(), - "stream.languageEmojis" to parsed?.languages.orEmpty().map { languageEmoji(it) }, - "stream.size" to (raw?.size ?: stream.behaviorHints.videoSize)?.let(::DebridTemplateBytes), + "stream.audioTags" to audioTags, + "stream.audioChannels" to audioChannels, + "stream.languages" to languageValues(parsed, facts), + "stream.languageEmojis" to languageValues(parsed, facts).map { languageEmoji(it) }, + "stream.size" to facts.size?.let(::DebridTemplateBytes), "stream.folderSize" to raw?.folderSize?.let(::DebridTemplateBytes), - "stream.encode" to parsed?.codec?.uppercase(), - "stream.indexer" to (raw?.indexer ?: raw?.tracker), + "stream.encode" to facts.encode.labelUnlessUnknown(), + "stream.indexer" to (raw?.indexer ?: raw?.tracker ?: stream.sourceName), "stream.network" to (parsed?.network ?: raw?.network), - "stream.releaseGroup" to parsed?.group, + "stream.releaseGroup" to facts.releaseGroup.takeIf { it.isNotBlank() }, "stream.duration" to parsed?.duration, "stream.edition" to edition, - "stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename), + "stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename ?: stream.debridCacheStatus?.cachedName), "stream.regexMatched" to null, - "stream.type" to streamType(resolve), - "service.cached" to resolve?.isCached, - "service.shortName" to serviceShortName(resolve), - "service.name" to serviceName(resolve), - "addon.name" to "Nuvio Direct Debrid", + "stream.type" to streamType(stream, resolve), + "service.cached" to serviceCached(stream, resolve), + "service.shortName" to DebridProviders.shortName(serviceId(stream)), + "service.name" to DebridProviders.displayName(serviceId(stream)), + "addon.name" to stream.addonName, ) } - private fun streamType(resolve: StreamClientResolve?): String = + private fun serviceId(stream: StreamItem): String? = + stream.debridCacheStatus?.providerId ?: stream.clientResolve?.service + + private fun serviceCached(stream: StreamItem, resolve: StreamClientResolve?): Boolean? = + when (stream.debridCacheStatus?.state) { + StreamDebridCacheState.CACHED -> true + StreamDebridCacheState.NOT_CACHED -> false + StreamDebridCacheState.CHECKING, + StreamDebridCacheState.UNKNOWN, + null -> resolve?.isCached + } + + private fun streamType(stream: StreamItem, resolve: StreamClientResolve?): String = when { + stream.debridCacheStatus != null -> "Debrid" resolve?.type.equals("debrid", ignoreCase = true) -> "Debrid" resolve?.type.equals("torrent", ignoreCase = true) -> "p2p" else -> resolve?.type.orEmpty() } - private fun serviceShortName(resolve: StreamClientResolve?): String = - resolve?.serviceExtension?.takeIf { it.isNotBlank() } - ?: DebridProviders.shortName(resolve?.service) - - private fun serviceName(resolve: StreamClientResolve?): String = - DebridProviders.displayName(resolve?.service) - private fun buildEdition(parsed: StreamClientResolveParsed?): String? { if (parsed == null) return null return buildList { @@ -124,6 +135,9 @@ class DebridStreamFormatter( private fun Int.twoDigits(): String = toString().padStart(2, '0') + private fun languageValues(parsed: StreamClientResolveParsed?, facts: DebridStreamFacts): List = + parsed?.languages.orEmpty().ifEmpty { facts.languages.map { it.code } } + private fun languageEmoji(language: String): String = when (language.lowercase()) { "en", "eng", "english" -> "GB" @@ -140,4 +154,16 @@ class DebridStreamFormatter( "multi" -> "Multi" else -> language } + + private inline fun List.mapNotUnknown(label: (T) -> String): List = + map(label).filterNot { it.equals("Unknown", ignoreCase = true) } + + private fun DebridStreamResolution.labelUnlessUnknown(): String? = + label.takeUnless { this == DebridStreamResolution.UNKNOWN } + + private fun DebridStreamQuality.labelUnlessUnknown(): String? = + label.takeUnless { this == DebridStreamQuality.UNKNOWN } + + private fun DebridStreamEncode.labelUnlessUnknown(): String? = + label.takeUnless { this == DebridStreamEncode.UNKNOWN } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt index bb5d25b3..585b020c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt @@ -1,8 +1,7 @@ package com.nuvio.app.features.debrid object DebridStreamFormatterDefaults { - const val NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{stream.resolution::exists[\"\"||\"Direct \"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}Instant" + const val NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}{service.cached::istrue[\"Ready\"||\"Not Ready\"]}" const val DESCRIPTION_TEMPLATE = "{stream.title::exists[\"{stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year})\"||\"\"]}\n{stream.quality::exists[\"{stream.quality} \"||\"\"]}{stream.visualTags::exists[\"{stream.visualTags::join(' | ')} \"||\"\"]}{stream.encode::exists[\"{stream.encode} \"||\"\"]}\n{stream.audioTags::exists[\"{stream.audioTags::join(' | ')}\"||\"\"]}{stream.audioTags::exists::and::stream.audioChannels::exists[\" | \"||\"\"]}{stream.audioChannels::exists[\"{stream.audioChannels::join(' | ')}\"||\"\"]}\n{stream.size::>0[\"{stream.size::bytes} \"||\"\"]}{stream.releaseGroup::exists[\"{stream.releaseGroup} \"||\"\"]}{stream.indexer::exists[\"{stream.indexer}\"||\"\"]}\n{service.cached::istrue[\"Ready\"||\"Not Ready\"]}{service.shortName::exists[\" ({service.shortName})\"||\"\"]}{stream.filename::exists[\"\n{stream.filename}\"||\"\"]}" } - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt similarity index 84% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt rename to composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt index 6647d607..68505de3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt @@ -1,55 +1,126 @@ package com.nuvio.app.features.debrid +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamDebridCacheState import com.nuvio.app.features.streams.StreamItem -object DirectDebridStreamFilter { - const val FALLBACK_SOURCE_NAME = "Direct Debrid" +object DebridStreamPresentation { + private val formatter = DebridStreamFormatter() - fun filterInstant(streams: List, settings: DebridSettings? = null): List { - val instantStreams = streams - .filter(::isInstantCandidate) - .map { stream -> - val providerId = stream.clientResolve?.service - val sourceName = DebridProviders.instantName(providerId) - stream.copy( - name = stream.name ?: sourceName, - addonName = sourceName, - addonId = DebridProviders.addonId(providerId), - sourceName = stream.sourceName ?: FALLBACK_SOURCE_NAME, - ) - } - .distinctBy { stream -> - listOf( - stream.clientResolve?.infoHash?.lowercase(), - stream.clientResolve?.fileIdx?.toString(), - stream.clientResolve?.filename, - stream.name, - stream.title, - ).joinToString("|") - } - return if (settings == null) instantStreams else applyPreferences(instantStreams, settings) + fun apply(groups: List, settings: DebridSettings): List { + if (!settings.enabled) return groups + return groups.map { group -> + val debridStreams = group.streams.filter { stream -> stream.isManagedDebridStream } + if (debridStreams.isEmpty()) return@map group + + val presentedDebridStreams = applyPreferences(debridStreams, settings) + .map { stream -> formatter.format(stream, settings) } + val passthroughStreams = group.streams.filterNot { stream -> stream.isManagedDebridStream } + + group.copy(streams = presentedDebridStreams + passthroughStreams) + } } - fun isInstantCandidate(stream: StreamItem): Boolean { - val resolve = stream.clientResolve ?: return false - return resolve.type.equals("debrid", ignoreCase = true) && - DebridProviders.isSupported(resolve.service) && - resolve.isCached == true - } - - fun isDirectDebridSourceName(addonName: String): Boolean = - DebridProviders.all().any { addonName == DebridProviders.instantName(it.id) } - - private fun applyPreferences(streams: List, settings: DebridSettings): List { - val preferences = effectivePreferences(settings) - return streams.map { it to streamFacts(it, preferences) } + internal fun applyPreferences(streams: List, settings: DebridSettings): List { + val preferences = DebridStreamMetadata.effectivePreferences(settings) + return streams.map { it to DebridStreamMetadata.facts(it, preferences) } .filter { (_, facts) -> facts.matchesFilters(preferences) } .sortedWith { left, right -> compareFacts(left.second, right.second, preferences.sortCriteria) } .let { sorted -> applyLimits(sorted, preferences) } .map { it.first } } - private fun effectivePreferences(settings: DebridSettings): DebridStreamPreferences { + internal val StreamItem.isManagedDebridStream: Boolean + get() { + val status = debridCacheStatus + return isDirectDebridStream || ( + isTorrentStream && + status != null && + status.providerId == DebridProviders.TORBOX_ID && + status.state != StreamDebridCacheState.CHECKING + ) + } + + private fun applyLimits( + streams: List>, + preferences: DebridStreamPreferences, + ): List> { + val resolutionCounts = mutableMapOf() + val qualityCounts = mutableMapOf() + val result = mutableListOf>() + for (stream in streams) { + if (preferences.maxResults > 0 && result.size >= preferences.maxResults) break + if (preferences.maxPerResolution > 0) { + val count = resolutionCounts[stream.second.resolution] ?: 0 + if (count >= preferences.maxPerResolution) continue + } + if (preferences.maxPerQuality > 0) { + val count = qualityCounts[stream.second.quality] ?: 0 + if (count >= preferences.maxPerQuality) continue + } + resolutionCounts[stream.second.resolution] = (resolutionCounts[stream.second.resolution] ?: 0) + 1 + qualityCounts[stream.second.quality] = (qualityCounts[stream.second.quality] ?: 0) + 1 + result += stream + } + return result + } + + private fun DebridStreamFacts.matchesFilters(preferences: DebridStreamPreferences): Boolean { + if (preferences.requiredResolutions.isNotEmpty() && resolution !in preferences.requiredResolutions) return false + if (resolution in preferences.excludedResolutions) return false + if (preferences.requiredQualities.isNotEmpty() && quality !in preferences.requiredQualities) return false + if (quality in preferences.excludedQualities) return false + if (preferences.requiredVisualTags.isNotEmpty() && visualTags.none { it in preferences.requiredVisualTags }) return false + if (visualTags.any { it in preferences.excludedVisualTags }) return false + if (preferences.requiredAudioTags.isNotEmpty() && audioTags.none { it in preferences.requiredAudioTags }) return false + if (audioTags.any { it in preferences.excludedAudioTags }) return false + if (preferences.requiredAudioChannels.isNotEmpty() && audioChannels.none { it in preferences.requiredAudioChannels }) return false + if (audioChannels.any { it in preferences.excludedAudioChannels }) return false + if (preferences.requiredEncodes.isNotEmpty() && encode !in preferences.requiredEncodes) return false + if (encode in preferences.excludedEncodes) return false + if (preferences.requiredLanguages.isNotEmpty() && languages.none { it in preferences.requiredLanguages }) return false + if (languages.isNotEmpty() && languages.all { it in preferences.excludedLanguages }) return false + if (preferences.requiredReleaseGroups.isNotEmpty() && preferences.requiredReleaseGroups.none { releaseGroup.equals(it, ignoreCase = true) }) return false + if (preferences.excludedReleaseGroups.any { releaseGroup.equals(it, ignoreCase = true) }) return false + if (preferences.sizeMinGb > 0 && size != null && size < preferences.sizeMinGb.gigabytes()) return false + if (preferences.sizeMaxGb > 0 && size != null && size > preferences.sizeMaxGb.gigabytes()) return false + return true + } + + private fun compareFacts( + left: DebridStreamFacts, + right: DebridStreamFacts, + criteria: List, + ): Int { + for (criterion in criteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }) { + val comparison = compareKey(left, right, criterion) + if (comparison != 0) return comparison + } + return 0 + } + + private fun compareKey( + left: DebridStreamFacts, + right: DebridStreamFacts, + criterion: DebridStreamSortCriterion, + ): Int { + val direction = if (criterion.direction == DebridStreamSortDirection.ASC) 1 else -1 + return when (criterion.key) { + DebridStreamSortKey.RESOLUTION -> left.resolutionRank.compareTo(right.resolutionRank) * -direction + DebridStreamSortKey.QUALITY -> left.qualityRank.compareTo(right.qualityRank) * -direction + DebridStreamSortKey.VISUAL_TAG -> left.visualRank.compareTo(right.visualRank) * -direction + DebridStreamSortKey.AUDIO_TAG -> left.audioRank.compareTo(right.audioRank) * -direction + DebridStreamSortKey.AUDIO_CHANNEL -> left.channelRank.compareTo(right.channelRank) * -direction + DebridStreamSortKey.ENCODE -> left.encodeRank.compareTo(right.encodeRank) * -direction + DebridStreamSortKey.SIZE -> (left.size ?: 0L).compareTo(right.size ?: 0L) * direction + DebridStreamSortKey.LANGUAGE -> left.languageRank.compareTo(right.languageRank) * -direction + DebridStreamSortKey.RELEASE_GROUP -> left.releaseGroup.compareTo(right.releaseGroup, ignoreCase = true) + } + } +} + +internal object DebridStreamMetadata { + fun effectivePreferences(settings: DebridSettings): DebridStreamPreferences { val default = DebridStreamPreferences() if (settings.streamPreferences != default) return settings.streamPreferences.normalized() if ( @@ -71,8 +142,12 @@ object DirectDebridStreamFilter { DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), ) - DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) - DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + DebridStreamSortMode.SIZE_DESC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridStreamSortMode.SIZE_ASC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC), + ) }, requiredResolutions = DebridStreamResolution.defaultOrder.filter { it.value >= settings.streamMinimumQuality.minResolution && it != DebridStreamResolution.UNKNOWN @@ -126,84 +201,7 @@ object DirectDebridStreamFilter { }.normalized() } - private fun applyLimits( - streams: List>, - preferences: DebridStreamPreferences, - ): List> { - val resolutionCounts = mutableMapOf() - val qualityCounts = mutableMapOf() - val result = mutableListOf>() - for (stream in streams) { - if (preferences.maxResults > 0 && result.size >= preferences.maxResults) break - if (preferences.maxPerResolution > 0) { - val count = resolutionCounts[stream.second.resolution] ?: 0 - if (count >= preferences.maxPerResolution) continue - } - if (preferences.maxPerQuality > 0) { - val count = qualityCounts[stream.second.quality] ?: 0 - if (count >= preferences.maxPerQuality) continue - } - resolutionCounts[stream.second.resolution] = (resolutionCounts[stream.second.resolution] ?: 0) + 1 - qualityCounts[stream.second.quality] = (qualityCounts[stream.second.quality] ?: 0) + 1 - result += stream - } - return result - } - - private fun StreamFacts.matchesFilters(preferences: DebridStreamPreferences): Boolean { - if (preferences.requiredResolutions.isNotEmpty() && resolution !in preferences.requiredResolutions) return false - if (resolution in preferences.excludedResolutions) return false - if (preferences.requiredQualities.isNotEmpty() && quality !in preferences.requiredQualities) return false - if (quality in preferences.excludedQualities) return false - if (preferences.requiredVisualTags.isNotEmpty() && visualTags.none { it in preferences.requiredVisualTags }) return false - if (visualTags.any { it in preferences.excludedVisualTags }) return false - if (preferences.requiredAudioTags.isNotEmpty() && audioTags.none { it in preferences.requiredAudioTags }) return false - if (audioTags.any { it in preferences.excludedAudioTags }) return false - if (preferences.requiredAudioChannels.isNotEmpty() && audioChannels.none { it in preferences.requiredAudioChannels }) return false - if (audioChannels.any { it in preferences.excludedAudioChannels }) return false - if (preferences.requiredEncodes.isNotEmpty() && encode !in preferences.requiredEncodes) return false - if (encode in preferences.excludedEncodes) return false - if (preferences.requiredLanguages.isNotEmpty() && languages.none { it in preferences.requiredLanguages }) return false - if (languages.isNotEmpty() && languages.all { it in preferences.excludedLanguages }) return false - if (preferences.requiredReleaseGroups.isNotEmpty() && preferences.requiredReleaseGroups.none { releaseGroup.equals(it, ignoreCase = true) }) return false - if (preferences.excludedReleaseGroups.any { releaseGroup.equals(it, ignoreCase = true) }) return false - if (preferences.sizeMinGb > 0 && size != null && size < preferences.sizeMinGb.gigabytes()) return false - if (preferences.sizeMaxGb > 0 && size != null && size > preferences.sizeMaxGb.gigabytes()) return false - return true - } - - private fun compareFacts( - left: StreamFacts, - right: StreamFacts, - criteria: List, - ): Int { - for (criterion in criteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }) { - val comparison = compareKey(left, right, criterion) - if (comparison != 0) return comparison - } - return 0 - } - - private fun compareKey( - left: StreamFacts, - right: StreamFacts, - criterion: DebridStreamSortCriterion, - ): Int { - val direction = if (criterion.direction == DebridStreamSortDirection.ASC) 1 else -1 - return when (criterion.key) { - DebridStreamSortKey.RESOLUTION -> left.resolutionRank.compareTo(right.resolutionRank) * -direction - DebridStreamSortKey.QUALITY -> left.qualityRank.compareTo(right.qualityRank) * -direction - DebridStreamSortKey.VISUAL_TAG -> left.visualRank.compareTo(right.visualRank) * -direction - DebridStreamSortKey.AUDIO_TAG -> left.audioRank.compareTo(right.audioRank) * -direction - DebridStreamSortKey.AUDIO_CHANNEL -> left.channelRank.compareTo(right.channelRank) * -direction - DebridStreamSortKey.ENCODE -> left.encodeRank.compareTo(right.encodeRank) * -direction - DebridStreamSortKey.SIZE -> (left.size ?: 0L).compareTo(right.size ?: 0L) * direction - DebridStreamSortKey.LANGUAGE -> left.languageRank.compareTo(right.languageRank) * -direction - DebridStreamSortKey.RELEASE_GROUP -> left.releaseGroup.compareTo(right.releaseGroup, ignoreCase = true) - } - } - - private fun streamFacts(stream: StreamItem, preferences: DebridStreamPreferences): StreamFacts { + fun facts(stream: StreamItem, preferences: DebridStreamPreferences): DebridStreamFacts { val parsed = stream.clientResolve?.stream?.raw?.parsed val searchText = streamSearchText(stream) val resolution = streamResolution(parsed?.resolution, parsed?.quality, searchText) @@ -216,7 +214,7 @@ object DirectDebridStreamFilter { DebridStreamLanguage.entries.filter { searchText.hasToken(it.code) } } val releaseGroup = parsed?.group?.takeIf { it.isNotBlank() } ?: releaseGroupFromText(searchText) - return StreamFacts( + return DebridStreamFacts( resolution = resolution, quality = quality, visualTags = visualTags, @@ -276,9 +274,9 @@ object DirectDebridStreamFilter { val text = (parsedHdr + searchText).joinToString(" ").lowercase() val tags = mutableListOf() val hasDv = parsedHdr.any { it.isDolbyVisionToken() } || - Regex("(^|[^a-z0-9])(dv|dovi|dolby[ ._-]?vision)([^a-z0-9]|$)").containsMatchIn(searchText) + Regex("(^|[^a-z0-9])(dv|dovi|dolby[ ._-]?vision)([^a-z0-9]|\$)").containsMatchIn(searchText) val hasHdr = parsedHdr.any { it.isHdrToken() } || - Regex("(^|[^a-z0-9])(hdr|hdr10|hdr10plus|hdr10\\+|hlg)([^a-z0-9]|$)").containsMatchIn(searchText) + Regex("(^|[^a-z0-9])(hdr|hdr10|hdr10plus|hdr10\\+|hlg)([^a-z0-9]|\$)").containsMatchIn(searchText) if (hasDv && hasHdr) tags += DebridStreamVisualTag.HDR_DV if (hasDv && !hasHdr) tags += DebridStreamVisualTag.DV_ONLY if (hasHdr && !hasDv) tags += DebridStreamVisualTag.HDR_ONLY @@ -380,7 +378,9 @@ object DirectDebridStreamFilter { } private fun streamSize(stream: StreamItem): Long? = - stream.clientResolve?.stream?.raw?.size ?: stream.behaviorHints.videoSize + stream.clientResolve?.stream?.raw?.size + ?: stream.behaviorHints.videoSize + ?: stream.debridCacheStatus?.cachedSize private fun streamSearchText(stream: StreamItem): String { val resolve = stream.clientResolve @@ -390,6 +390,8 @@ object DirectDebridStreamFilter { stream.name, stream.title, stream.description, + stream.behaviorHints.filename, + stream.debridCacheStatus?.cachedName, resolve?.torrentName, resolve?.filename, raw?.torrentName, @@ -401,25 +403,25 @@ object DirectDebridStreamFilter { parsed?.audio?.joinToString(" "), ).joinToString(" ").lowercase() } - - private fun Int.gigabytes(): Long = this * 1_000_000_000L - - private data class StreamFacts( - val resolution: DebridStreamResolution, - val quality: DebridStreamQuality, - val visualTags: List, - val audioTags: List, - val audioChannels: List, - val encode: DebridStreamEncode, - val languages: List, - val releaseGroup: String, - val size: Long?, - val resolutionRank: Int, - val qualityRank: Int, - val visualRank: Int, - val audioRank: Int, - val channelRank: Int, - val encodeRank: Int, - val languageRank: Int, - ) } + +internal data class DebridStreamFacts( + val resolution: DebridStreamResolution, + val quality: DebridStreamQuality, + val visualTags: List, + val audioTags: List, + val audioChannels: List, + val encode: DebridStreamEncode, + val languages: List, + val releaseGroup: String, + val size: Long?, + val resolutionRank: Int, + val qualityRank: Int, + val visualRank: Int, + val audioRank: Int, + val channelRank: Int, + val encodeRank: Int, + val languageRank: Int, +) + +private fun Int.gigabytes(): Long = this * 1_000_000_000L diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt index 23e635e9..f397d841 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt @@ -143,7 +143,8 @@ class DebridStreamTemplateEngine { op.startsWith("join(") -> { val separator = parseArgs(op).firstOrNull() ?: ", " when (value) { - is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }.joinToString(separator) + is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } } + .joinToString(separator) else -> valueToText(value) } } @@ -342,7 +343,8 @@ class DebridStreamTemplateEngine { private fun valueToText(value: Any?): String = when (value) { null -> "" - is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }.joinToString(", ") + is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } } + .joinToString(", ") is DebridTemplateBytes -> formatBytes(value.value.toDouble()) is Double -> if (value % 1.0 == 0.0) value.toLong().toString() else value.toString() is Float -> if (value % 1f == 0f) value.toLong().toString() else value.toString() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt deleted file mode 100644 index 855e9124..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.nuvio.app.features.debrid - -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi - -class DirectDebridConfigEncoder { - @OptIn(ExperimentalEncodingApi::class) - fun encode(service: DebridServiceCredential): String { - val servicesJson = """{"service":"${service.provider.id.jsonEscaped()}","apiKey":"${service.apiKey.jsonEscaped()}"}""" - val json = """{"cachedOnly":true,"debridServices":[$servicesJson],"enableTorrent":false}""" - return Base64.Default.encode(json.encodeToByteArray()) - } - - fun encodeTorbox(apiKey: String): String = - encode(DebridServiceCredential(DebridProviders.Torbox, apiKey)) -} - -private fun String.jsonEscaped(): String = buildString { - this@jsonEscaped.forEach { char -> - when (char) { - '\\' -> append("\\\\") - '"' -> append("\\\"") - '\b' -> append("\\b") - '\u000C' -> append("\\f") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - else -> { - if (char.code < 0x20) { - append("\\u") - append(char.code.toString(16).padStart(4, '0')) - } else { - append(char) - } - } - } - } -} - 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 6b8e3425..7aff970b 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 @@ -2,6 +2,7 @@ package com.nuvio.app.features.debrid import com.nuvio.app.features.streams.StreamBehaviorHints import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamDebridCacheState import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.epochMs import kotlinx.coroutines.CancellationException @@ -16,12 +17,14 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.debrid_missing_api_key +import nuvio.composeapp.generated.resources.debrid_not_cached import nuvio.composeapp.generated.resources.debrid_resolve_failed 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 scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val mutex = Mutex() @@ -29,7 +32,10 @@ object DirectDebridPlaybackResolver { private val inFlightResolves = mutableMapOf>() suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { - val cacheKey = stream.directDebridResolveCacheKey(season, episode) + if (!shouldResolveToPlayableStream(stream)) { + return DirectDebridResolveResult.Stale + } + val cacheKey = stream.debridResolveCacheKey(season, episode) if (cacheKey == null) { return resolveUncached(stream, season, episode) } @@ -85,7 +91,8 @@ object DirectDebridPlaybackResolver { } suspend fun cachedPlayableStream(stream: StreamItem, season: Int?, episode: Int?): StreamItem? { - val cacheKey = stream.directDebridResolveCacheKey(season, episode) ?: return null + if (!shouldResolveToPlayableStream(stream)) return null + val cacheKey = stream.debridResolveCacheKey(season, episode) ?: return null return getCachedResult(cacheKey) ?.let { result -> stream.withResolvedDebridUrl(result) } } @@ -96,7 +103,7 @@ object DirectDebridPlaybackResolver { private fun getCachedResultLocked(cacheKey: String): DirectDebridResolveResult.Success? { val cached = resolvedCache[cacheKey] ?: return null val age = epochMs() - cached.cachedAtMs - return if (age in 0..DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS) { + return if (age in 0..DEBRID_RESOLVE_CACHE_TTL_MS) { cached.result } else { resolvedCache.remove(cacheKey) @@ -104,11 +111,30 @@ object DirectDebridPlaybackResolver { } } + fun shouldResolveToPlayableStream(stream: StreamItem): Boolean { + val settings = DebridSettingsRepository.snapshot() + if (!settings.enabled) return false + if (stream.needsLocalDebridResolve) { + return settings.torboxApiKey.isNotBlank() + } + if (!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 + } + } + private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult = - 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 + 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 + } } suspend fun resolveToPlayableStream( @@ -116,19 +142,20 @@ object DirectDebridPlaybackResolver { season: Int?, episode: Int?, ): DirectDebridPlayableResult { - if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) { + if (!shouldResolveToPlayableStream(stream)) { return DirectDebridPlayableResult.Success(stream) } return when (val result = resolve(stream, season, episode)) { is DirectDebridResolveResult.Success -> DirectDebridPlayableResult.Success(stream.withResolvedDebridUrl(result)) DirectDebridResolveResult.MissingApiKey -> DirectDebridPlayableResult.MissingApiKey + DirectDebridResolveResult.NotCached -> DirectDebridPlayableResult.NotCached DirectDebridResolveResult.Stale -> DirectDebridPlayableResult.Stale DirectDebridResolveResult.Error -> DirectDebridPlayableResult.Error } } } -private const val DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS = 15L * 60L * 1000L +private const val DEBRID_RESOLVE_CACHE_TTL_MS = 15L * 60L * 1000L private data class CachedDirectDebridResolve( val result: DirectDebridResolveResult.Success, @@ -138,6 +165,7 @@ private data class CachedDirectDebridResolve( sealed class DirectDebridPlayableResult { data class Success(val stream: StreamItem) : DirectDebridPlayableResult() data object MissingApiKey : DirectDebridPlayableResult() + data object NotCached : DirectDebridPlayableResult() data object Stale : DirectDebridPlayableResult() data object Error : DirectDebridPlayableResult() } @@ -150,6 +178,7 @@ sealed class DirectDebridResolveResult { ) : DirectDebridResolveResult() data object MissingApiKey : DirectDebridResolveResult() + data object NotCached : DirectDebridResolveResult() data object Stale : DirectDebridResolveResult() data object Error : DirectDebridResolveResult() } @@ -158,10 +187,73 @@ fun DirectDebridPlayableResult.toastMessage(): String? = when (this) { is DirectDebridPlayableResult.Success -> null DirectDebridPlayableResult.MissingApiKey -> runBlocking { getString(Res.string.debrid_missing_api_key) } + DirectDebridPlayableResult.NotCached -> runBlocking { getString(Res.string.debrid_not_cached) } DirectDebridPlayableResult.Stale -> runBlocking { getString(Res.string.debrid_stream_stale) } DirectDebridPlayableResult.Error -> runBlocking { getString(Res.string.debrid_resolve_failed) } } +private class TorboxAddonStreamResolver( + 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 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)) { + false -> return DirectDebridResolveResult.NotCached + true, null -> Unit + } + } + + val magnet = DebridMagnetBuilder.fromStream(stream) + ?: return DirectDebridResolveResult.Stale + val resolve = stream.toResolveMetadata(season, episode) + + 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() } + ?: stream.behaviorHints.filename?.takeIf { it.isNotBlank() }, + videoSize = file.size ?: stream.behaviorHints.videoSize, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + DirectDebridResolveResult.Error + } + } +} + private class TorboxDirectDebridResolver( private val fileSelector: TorboxFileSelector = TorboxFileSelector(), ) { @@ -220,11 +312,6 @@ private class TorboxDirectDebridResolver( } } - private fun DebridApiResponse>.toFailureForCreate(): DirectDebridResolveResult = - when (status) { - 401, 403 -> DirectDebridResolveResult.Error - else -> DirectDebridResolveResult.Stale - } } private class RealDebridDirectDebridResolver( @@ -323,7 +410,8 @@ private fun buildMagnetUri(resolve: StreamClientResolve): String? { append("magnet:?xt=urn:btih:") append(hash) resolve.sources - .filter { it.isNotBlank() } + .mapNotNull { it.toTrackerUrlOrNull() } + .distinct() .forEach { source -> append("&tr=") append(encodePathSegment(source)) @@ -331,8 +419,28 @@ private fun buildMagnetUri(resolve: StreamClientResolve): String? { } } -private fun StreamItem.directDebridResolveCacheKey(season: Int?, episode: Int?): String? { - val resolve = clientResolve ?: return null +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 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 identity = infoHash ?: torrentMagnetUri ?: behaviorHints.filename ?: return null + return listOf( + DebridProviders.TORBOX_ID, + apiKey.stableFingerprint(), + identity.trim().lowercase(), + fileIdx?.toString().orEmpty(), + behaviorHints.filename.orEmpty().trim().lowercase(), + season?.toString().orEmpty(), + episode?.toString().orEmpty(), + ).joinToString("|") + } + resolve ?: return null val providerId = DebridProviders.byId(resolve.service)?.id ?: return null val apiKey = when (providerId) { DebridProviders.TORBOX_ID -> DebridSettingsRepository.snapshot().torboxApiKey @@ -356,6 +464,28 @@ private fun StreamItem.directDebridResolveCacheKey(season: Int?, episode: Int?): ).joinToString("|") } +private fun StreamItem.toResolveMetadata(season: Int?, episode: Int?): StreamClientResolve = + StreamClientResolve( + type = "torrent", + infoHash = infoHash, + fileIdx = fileIdx, + magnetUri = torrentMagnetUri, + sources = sources, + torrentName = title ?: name, + filename = behaviorHints.filename, + season = season, + episode = episode, + service = DebridProviders.TORBOX_ID, + isCached = debridCacheStatus?.state == StreamDebridCacheState.CACHED, + ) + +private fun DebridApiResponse>.toFailureForCreate(): DirectDebridResolveResult = + when (status) { + 401, 403 -> DirectDebridResolveResult.Error + 409 -> DirectDebridResolveResult.NotCached + else -> DirectDebridResolveResult.Stale + } + private fun String.stableFingerprint(): String { val hash = fold(1125899906842597L) { acc, char -> (acc * 31L) + char.code } return hash.toULong().toString(16) 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 61952674..7cd37661 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 @@ -49,7 +49,7 @@ object DirectDebridStreamPreparer { try { when (val result = DirectDebridPlaybackResolver.resolveToPlayableStream(stream, season, episode)) { is DirectDebridPlayableResult.Success -> { - if (result.stream.directPlaybackUrl != null) { + if (result.stream.playableDirectUrl != null) { onPrepared(stream, result.stream) } } @@ -71,7 +71,10 @@ object DirectDebridStreamPreparer { ): List { if (limit <= 0) return emptyList() val candidates = streams - .filter { it.isDirectDebridStream && it.directPlaybackUrl == null } + .filter { stream -> + stream.playableDirectUrl == null && + (stream.isDirectDebridStream || stream.isCachedDebridTorrentStream) + } .distinctBy { it.preparationKey() } if (candidates.isEmpty()) return emptyList() @@ -85,7 +88,7 @@ object DirectDebridStreamPreparer { selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, ) - if (autoPlaySelection?.isDirectDebridStream == true) { + if (autoPlaySelection?.let { it.isDirectDebridStream || it.isCachedDebridTorrentStream } == true) { candidates.firstOrNull { it.preparationKey() == autoPlaySelection.preparationKey() } ?.let(prioritized::add) } @@ -180,7 +183,10 @@ private fun StreamItem.preparationKey(): String { return listOf( addonId.lowercase(), - directPlaybackUrl.orEmpty().lowercase(), + infoHash.orEmpty().lowercase(), + fileIdx?.toString().orEmpty(), + behaviorHints.filename.orEmpty().lowercase(), + playableDirectUrl.orEmpty().lowercase(), name.orEmpty().lowercase(), title.orEmpty().lowercase(), ).joinToString("|") @@ -192,5 +198,5 @@ private fun StreamItem.searchableText(): String = append(name.orEmpty()).append(' ') append(title.orEmpty()).append(' ') append(description.orEmpty()).append(' ') - append(directPlaybackUrl.orEmpty()) + append(playableDirectUrl.orEmpty()) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt deleted file mode 100644 index 6cd5573d..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt +++ /dev/null @@ -1,253 +0,0 @@ -package com.nuvio.app.features.debrid - -import co.touchlab.kermit.Logger -import com.nuvio.app.features.addons.httpGetText -import com.nuvio.app.features.streams.AddonStreamGroup -import com.nuvio.app.features.streams.StreamParser -import com.nuvio.app.features.streams.epochMs -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -private const val DIRECT_DEBRID_TAG = "DirectDebridStreams" -private const val STREAM_CACHE_TTL_MS = 5L * 60L * 1000L - -data class DirectDebridStreamTarget( - val provider: DebridProvider, - val apiKey: String, -) { - val addonId: String = DebridProviders.addonId(provider.id) - val addonName: String = DebridProviders.instantName(provider.id) -} - -object DirectDebridStreamSource { - private val log = Logger.withTag(DIRECT_DEBRID_TAG) - private val encoder = DirectDebridConfigEncoder() - private val formatter = DebridStreamFormatter() - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val mutex = Mutex() - private val streamCache = mutableMapOf() - private val inFlightFetches = mutableMapOf>() - - fun configuredTargets(): List { - DebridSettingsRepository.ensureLoaded() - val settings = DebridSettingsRepository.snapshot() - if (!settings.enabled || DebridConfig.DIRECT_DEBRID_API_BASE_URL.isBlank()) return emptyList() - return DebridProviders.configuredServices(settings).map { credential -> - DirectDebridStreamTarget( - provider = credential.provider, - apiKey = credential.apiKey, - ) - } - } - - fun sourceNames(): List = - configuredTargets().map { it.addonName } - - fun isEnabled(): Boolean = - sourceNames().isNotEmpty() - - fun placeholders(): List = - configuredTargets().map { target -> - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = emptyList(), - isLoading = true, - ) - } - - fun preloadStreams(type: String, videoId: String) { - if (type.isBlank() || videoId.isBlank()) return - configuredTargets().forEach { target -> - scope.launch { - runCatching { fetchProviderStreams(type, videoId, target) } - } - } - } - - suspend fun fetchStreams(type: String, videoId: String): DirectDebridStreamFetchResult { - val targets = configuredTargets() - if (targets.isEmpty()) return DirectDebridStreamFetchResult.Disabled - - val results = mutableListOf() - val errors = mutableListOf() - targets.forEach { target -> - val group = fetchProviderStreams(type, videoId, target) - when { - group.streams.isNotEmpty() -> results += group - !group.error.isNullOrBlank() -> errors += group.error - } - } - - return when { - results.isNotEmpty() -> DirectDebridStreamFetchResult.Success(results) - errors.isNotEmpty() -> DirectDebridStreamFetchResult.Error(errors.first()) - else -> DirectDebridStreamFetchResult.Empty - } - } - - suspend fun fetchProviderStreams( - type: String, - videoId: String, - target: DirectDebridStreamTarget, - ): AddonStreamGroup { - val settings = DebridSettingsRepository.snapshot() - val baseUrl = DebridConfig.DIRECT_DEBRID_API_BASE_URL.trim().trimEnd('/') - if (!settings.enabled || baseUrl.isBlank()) { - return target.emptyGroup() - } - - val cacheKey = DirectDebridStreamCacheKey( - providerId = target.provider.id, - type = type.trim().lowercase(), - videoId = videoId.trim(), - baseUrl = baseUrl, - settingsFingerprint = settings.toString(), - ) - cachedGroup(cacheKey)?.let { return it } - - var ownsFetch = false - val newFetch = scope.async(start = CoroutineStart.LAZY) { - fetchProviderStreamsUncached( - baseUrl = baseUrl, - type = type, - videoId = videoId, - target = target, - settings = settings, - ) - } - val activeFetch = mutex.withLock { - cachedGroupLocked(cacheKey)?.let { cached -> - return@withLock null to cached - } - val existing = inFlightFetches[cacheKey] - if (existing != null) { - existing to null - } else { - inFlightFetches[cacheKey] = newFetch - ownsFetch = true - newFetch to null - } - } - activeFetch.second?.let { - newFetch.cancel() - return it - } - val deferred = activeFetch.first ?: return target.errorGroup("Could not start Direct Debrid fetch") - if (!ownsFetch) newFetch.cancel() - if (ownsFetch) deferred.start() - - return try { - val result = deferred.await() - if (ownsFetch && result.streams.isNotEmpty() && result.error == null) { - mutex.withLock { - streamCache[cacheKey] = CachedDirectDebridStreams( - group = result, - createdAtMs = epochMs(), - ) - } - } - result - } finally { - if (ownsFetch) { - mutex.withLock { - if (inFlightFetches[cacheKey] === deferred) { - inFlightFetches.remove(cacheKey) - } - } - } - } - } - - private suspend fun cachedGroup(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? = - mutex.withLock { cachedGroupLocked(cacheKey) } - - private fun cachedGroupLocked(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? { - val cached = streamCache[cacheKey] ?: return null - val age = epochMs() - cached.createdAtMs - return if (age in 0..STREAM_CACHE_TTL_MS) { - cached.group - } else { - streamCache.remove(cacheKey) - null - } - } - - private suspend fun fetchProviderStreamsUncached( - baseUrl: String, - type: String, - videoId: String, - target: DirectDebridStreamTarget, - settings: DebridSettings, - ): AddonStreamGroup { - val credential = DebridServiceCredential(target.provider, target.apiKey) - val url = "$baseUrl/${encoder.encode(credential)}/client-stream/${encodePathSegment(type)}/${encodePathSegment(videoId)}.json" - return try { - val payload = httpGetText(url) - val streams = StreamParser.parse( - payload = payload, - addonName = DirectDebridStreamFilter.FALLBACK_SOURCE_NAME, - addonId = target.addonId, - ) - .let { DirectDebridStreamFilter.filterInstant(it, settings) } - .filter { stream -> stream.clientResolve?.service.equals(target.provider.id, ignoreCase = true) } - .map { stream -> formatter.format(stream.copy(addonId = target.addonId), settings) } - - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = streams, - isLoading = false, - ) - } catch (error: Exception) { - if (error is CancellationException) throw error - log.w(error) { "Direct debrid ${target.provider.id} stream fetch failed" } - target.errorGroup(error.message) - } - } - - private fun DirectDebridStreamTarget.emptyGroup(): AddonStreamGroup = - AddonStreamGroup( - addonName = addonName, - addonId = addonId, - streams = emptyList(), - isLoading = false, - ) - - private fun DirectDebridStreamTarget.errorGroup(message: String?): AddonStreamGroup = - AddonStreamGroup( - addonName = addonName, - addonId = addonId, - streams = emptyList(), - isLoading = false, - error = message, - ) -} - -private data class DirectDebridStreamCacheKey( - val providerId: String, - val type: String, - val videoId: String, - val baseUrl: String, - val settingsFingerprint: String, -) - -private data class CachedDirectDebridStreams( - val group: AddonStreamGroup, - val createdAtMs: Long, -) - -sealed class DirectDebridStreamFetchResult { - data object Disabled : DirectDebridStreamFetchResult() - data object Empty : DirectDebridStreamFetchResult() - data class Success(val streams: List) : DirectDebridStreamFetchResult() - data class Error(val message: String) : DirectDebridStreamFetchResult() -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt new file mode 100644 index 00000000..9dbd3876 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt @@ -0,0 +1,111 @@ +package com.nuvio.app.features.debrid + +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 { + fun markChecking(groups: List): List { + val settings = DebridSettingsRepository.snapshot() + if (!settings.enabled || settings.torboxApiKey.isBlank()) return groups + return groups.updateAvailabilityStatus { stream -> + if (stream.torboxAvailabilityHash() == null || stream.debridCacheStatus?.state == StreamDebridCacheState.CACHED) { + stream + } else { + stream.copy( + debridCacheStatus = StreamDebridCacheStatus( + providerId = DebridProviders.TORBOX_ID, + providerName = DebridProviders.Torbox.displayName, + state = StreamDebridCacheState.CHECKING, + ), + ) + } + } + } + + suspend fun annotateCachedAvailability(groups: List): List { + val settings = DebridSettingsRepository.snapshot() + val apiKey = settings.torboxApiKey.trim() + if (!settings.enabled || apiKey.isBlank()) return groups + + val hashes = groups + .flatMap { group -> group.streams.mapNotNull { stream -> stream.torboxAvailabilityHash() } } + .distinct() + if (hashes.isEmpty()) return groups + + val cached = checkCached(apiKey = apiKey, hashes = hashes) + ?: return groups.updateAvailabilityStatus { stream -> + val hash = stream.torboxAvailabilityHash() + if (hash == null) { + stream + } else { + stream.copy( + debridCacheStatus = StreamDebridCacheStatus( + providerId = DebridProviders.TORBOX_ID, + providerName = DebridProviders.Torbox.displayName, + state = StreamDebridCacheState.UNKNOWN, + ), + ) + } + } + + return groups.updateAvailabilityStatus { stream -> + val hash = stream.torboxAvailabilityHash() ?: return@updateAvailabilityStatus stream + val cachedItem = cached[hash] + stream.copy( + debridCacheStatus = StreamDebridCacheStatus( + providerId = DebridProviders.TORBOX_ID, + providerName = DebridProviders.Torbox.displayName, + state = if (cachedItem == null) StreamDebridCacheState.NOT_CACHED else StreamDebridCacheState.CACHED, + cachedName = cachedItem?.name, + cachedSize = cachedItem?.size, + ), + ) + } + } + + 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) + } + + 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 + } +} + +internal fun StreamItem.torboxAvailabilityHash(): String? = + infoHash + ?.trim() + ?.lowercase() + ?.takeIf { needsLocalDebridResolve && it.isNotBlank() } + +private fun List.updateAvailabilityStatus( + transform: (StreamItem) -> StreamItem, +): List = + map { group -> + var changed = false + val updatedStreams = group.streams.map { stream -> + val updated = transform(stream) + if (updated != stream) changed = true + updated + } + if (changed) group.copy(streams = updatedStreams) else group + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 99493f36..d8bfbf27 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -62,7 +62,6 @@ import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.nuvioSafeBottomPadding -import com.nuvio.app.features.debrid.DirectDebridStreamSource import com.nuvio.app.features.details.components.DetailActionButtons import com.nuvio.app.features.details.components.CommentDetailSheet import com.nuvio.app.features.details.components.DetailAdditionalInfoSection @@ -373,16 +372,6 @@ fun MetaDetailsScreen( seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId } val hasEpisodes = meta.videos.any { it.season != null || it.episode != null } - val debridPreloadVideoId = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction?.videoId) { - if (meta.isSeriesLikeForDebridPreload(hasEpisodes)) { - seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id - } else { - meta.id - } - } - LaunchedEffect(meta.type, debridPreloadVideoId) { - DirectDebridStreamSource.preloadStreams(meta.type, debridPreloadVideoId) - } val hasProductionSection = remember(meta) { meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty() } @@ -1270,8 +1259,3 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp = } else { (maxWidth * 0.6f).coerceIn(520.dp, 680.dp) } - -private fun MetaDetails.isSeriesLikeForDebridPreload(hasEpisodes: Boolean): Boolean = - hasEpisodes || type.equals("series", ignoreCase = true) || - type.equals("show", ignoreCase = true) || - type.equals("tv", ignoreCase = true) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt index 7ed74677..b0440dc8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt @@ -117,7 +117,7 @@ object DownloadsRepository { ): DownloadEnqueueResult { ensureLoaded() - val sourceUrl = stream.directPlaybackUrl + val sourceUrl = stream.playableDirectUrl ?.trim() ?.takeIf { it.isNotBlank() } ?: return DownloadEnqueueResult.MissingUrl 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 255205cd..37940f03 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 @@ -57,10 +57,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.streams.isSelectableForPlayback import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watching.application.WatchingState @@ -460,6 +463,10 @@ private fun EpisodeStreamsSubView( onDismiss: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() val episode = state.selectedEpisode ?: return val streamsUiState = state.streamsUiState @@ -601,6 +608,7 @@ private fun EpisodeStreamsSubView( ) { _, stream -> EpisodeSourceStreamRow( stream = stream, + enabled = stream.isSelectableForPlayback(debridSettings.enabled), onClick = { onStreamSelected(stream, episode) }, ) } @@ -613,6 +621,7 @@ private fun EpisodeStreamsSubView( @Composable private fun EpisodeSourceStreamRow( stream: StreamItem, + enabled: Boolean, onClick: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme @@ -622,7 +631,7 @@ private fun EpisodeSourceStreamRow( .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(colorScheme.surfaceVariant.copy(alpha = 0.35f)) - .clickable(onClick = onClick) + .clickable(enabled = enabled, onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), 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 476b0a77..4db2d7b2 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 @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.ui.NuvioToastController +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.debrid.DirectDebridPlayableResult import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver import com.nuvio.app.features.debrid.toastMessage @@ -854,7 +855,7 @@ fun PlayerScreen( onResolved: (StreamItem) -> Unit, onStale: () -> Unit, ): Boolean { - if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) return false + if (!DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) return false scope.launch { val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( stream = stream, @@ -896,7 +897,7 @@ fun PlayerScreen( }, ) ) return - val url = stream.directPlaybackUrl ?: return + val url = stream.playableDirectUrl ?: return if (url == activeSourceUrl) return val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) flushWatchProgress() @@ -957,7 +958,7 @@ fun PlayerScreen( }, ) ) return - val url = stream.directPlaybackUrl ?: return + val url = stream.playableDirectUrl ?: return showNextEpisodeCard = false showSourcesPanel = false showEpisodesPanel = false @@ -1165,6 +1166,7 @@ fun PlayerScreen( null }, preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, + debridEnabled = DebridSettingsRepository.snapshot().enabled, ) } 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 d57dd46d..e5f6b1d9 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 @@ -36,6 +36,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -47,9 +48,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.i18n.localizedByteUnit +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.streams.isSelectableForPlayback import kotlin.math.round import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -67,6 +71,10 @@ fun PlayerSourcesPanel( modifier: Modifier = Modifier, ) { val colorScheme = MaterialTheme.colorScheme + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() AnimatedVisibility( visible = visible, @@ -213,6 +221,7 @@ fun PlayerSourcesPanel( SourceStreamRow( stream = stream, isCurrent = isCurrent, + enabled = stream.isSelectableForPlayback(debridSettings.enabled), onClick = { onStreamSelected(stream) }, ) } @@ -230,6 +239,7 @@ fun PlayerSourcesPanel( private fun SourceStreamRow( stream: StreamItem, isCurrent: Boolean, + enabled: Boolean, onClick: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme @@ -256,7 +266,7 @@ private fun SourceStreamRow( Modifier }, ) - .clickable(onClick = onClick) + .clickable(enabled = enabled, onClick = onClick) .padding(14.dp), verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -452,9 +462,9 @@ private fun isCurrentStream( currentUrl: String?, currentName: String?, ): Boolean { - if (currentUrl != null && stream.directPlaybackUrl == currentUrl) return true + if (currentUrl != null && stream.playableDirectUrl == currentUrl) return true if (currentName != null && stream.streamLabel.equals(currentName, ignoreCase = true) && - stream.directPlaybackUrl == currentUrl + stream.playableDirectUrl == currentUrl ) return true return false } 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 013460c3..dffdff27 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 @@ -5,8 +5,10 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl 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.DirectDebridStreamSource +import com.nuvio.app.features.debrid.TorboxAvailabilityService import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.pluginContentId @@ -159,7 +161,6 @@ object PlayerStreamsRepository { val installedAddonNames = installedAddons.map { it.displayTitle }.toSet() PlayerSettingsRepository.ensureLoaded() val playerSettings = PlayerSettingsRepository.uiState.value - val debridTargets = DirectDebridStreamSource.configuredTargets() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.initialize() PluginRepository.getEnabledScrapersForType(type) @@ -167,7 +168,7 @@ object PlayerStreamsRepository { emptyList() } - if (installedAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) { + if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) { stateFlow.value = StreamsUiState( isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled, @@ -193,7 +194,7 @@ object PlayerStreamsRepository { ) } - if (streamAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) { + if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) { stateFlow.value = StreamsUiState( isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons, @@ -216,13 +217,6 @@ object PlayerStreamsRepository { streams = emptyList(), isLoading = true, ) - } + debridTargets.map { target -> - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = emptyList(), - isLoading = true, - ) }, installedAddonOrder) stateFlow.value = StreamsUiState( groups = initialGroups, @@ -291,17 +285,7 @@ object PlayerStreamsRepository { } } - val debridJobs = debridTargets.map { target -> - async { - DirectDebridStreamSource.fetchProviderStreams( - type = type, - videoId = videoId, - target = target, - ) - } - } - - val jobs = addonJobs + pluginJobs + debridJobs + val jobs = addonJobs + pluginJobs val completions = Channel(capacity = Channel.BUFFERED) jobs.forEach { deferred -> launch { @@ -329,25 +313,33 @@ object PlayerStreamsRepository { } else null, ) } - if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) { - debridPreparationLaunched = true - launch { - DirectDebridStreamPreparer.prepare( - streams = stateFlow.value.groups.flatMap { it.streams }, - season = season, - episode = episode, - playerSettings = playerSettings, - installedAddonNames = installedAddonNames, - ) { original, prepared -> - stateFlow.update { current -> - current.copy( - groups = DirectDebridStreamPreparer.replacePreparedStream( - groups = current.groups, - original = original, - prepared = prepared, - ), - ) - } + } + if (!debridPreparationLaunched) { + debridPreparationLaunched = true + val checkingGroups = TorboxAvailabilityService.markChecking(stateFlow.value.groups) + stateFlow.update { current -> current.copy(groups = checkingGroups) } + val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability(stateFlow.value.groups) + val presentedGroups = DebridStreamPresentation.apply( + groups = availabilityGroups, + settings = DebridSettingsRepository.snapshot(), + ) + stateFlow.update { current -> current.copy(groups = presentedGroups) } + launch { + DirectDebridStreamPreparer.prepare( + streams = stateFlow.value.groups.flatMap { it.streams }, + season = season, + episode = episode, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) { original, prepared -> + stateFlow.update { current -> + current.copy( + groups = DirectDebridStreamPreparer.replacePreparedStream( + groups = current.groups, + original = original, + prepared = prepared, + ), + ) } } } 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 30d59534..cbc9eece 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 @@ -217,7 +217,7 @@ internal fun LazyListScope.debridSettingsContent( DebridPreferenceRow( isTablet = isTablet, title = "Max results", - description = "Limit how many Direct Debrid sources appear.", + description = "Limit how many debrid-ready addon streams appear.", value = streamMaxResultsLabel(preferences.maxResults), enabled = settings.enabled, onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS }, @@ -226,7 +226,7 @@ internal fun LazyListScope.debridSettingsContent( DebridPreferenceRow( isTablet = isTablet, title = "Sort streams", - description = "Choose how Direct Debrid sources are ordered.", + description = "Choose how debrid-ready addon streams are ordered.", value = sortProfileLabel(preferences.sortCriteria), enabled = settings.enabled, onClick = { activeStreamPicker = DebridStreamPicker.SORT_MODE }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index 5917325a..f01c2392 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -40,6 +40,7 @@ object StreamAutoPlaySelector { selectedPlugins: Set, preferredBingeGroup: String? = null, preferBingeGroupInSelection: Boolean = false, + debridEnabled: Boolean = true, ): StreamItem? { if (streams.isEmpty()) return null @@ -62,14 +63,14 @@ object StreamAutoPlaySelector { val targetBingeGroup = preferredBingeGroup?.trim().orEmpty() if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { val bingeGroupMatch = candidateStreams.firstOrNull { stream -> - stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable() + stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable(debridEnabled) } if (bingeGroupMatch != null) return bingeGroupMatch } return when (mode) { StreamAutoPlayMode.MANUAL -> null - StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.isAutoPlayable() } + StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.isAutoPlayable(debridEnabled) } StreamAutoPlayMode.REGEX_MATCH -> { val pattern = regexPattern.trim() @@ -89,8 +90,8 @@ object StreamAutoPlaySelector { } else null val matchingStreams = candidateStreams.filter { stream -> - if (!stream.isAutoPlayable()) return@filter false - val url = stream.directPlaybackUrl.orEmpty() + if (!stream.isAutoPlayable(debridEnabled)) return@filter false + val url = stream.playableDirectUrl.orEmpty() val searchableText = buildString { append(stream.addonName).append(' ') @@ -110,11 +111,11 @@ object StreamAutoPlaySelector { } if (matchingStreams.isEmpty()) return null - matchingStreams.firstOrNull { it.isAutoPlayable() } + matchingStreams.firstOrNull { it.isAutoPlayable(debridEnabled) } } } } - private fun StreamItem.isAutoPlayable(): Boolean = - directPlaybackUrl != null || isDirectDebridStream + private fun StreamItem.isAutoPlayable(debridEnabled: Boolean): Boolean = + playableDirectUrl != null || (debridEnabled && (isDirectDebridStream || isCachedDebridTorrentStream)) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 0b3d8b24..dd87cc15 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -18,6 +18,7 @@ data class StreamItem( val addonId: String, val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), val clientResolve: StreamClientResolve? = null, + val debridCacheStatus: StreamDebridCacheStatus? = null, ) { val streamLabel: String get() = name ?: runBlocking { getString(Res.string.stream_default_name) } @@ -28,6 +29,14 @@ data class StreamItem( val directPlaybackUrl: String? get() = url ?: externalUrl + val playableDirectUrl: String? + get() = listOfNotNull(url, externalUrl) + .firstOrNull { !it.isMagnetLink() } + + val torrentMagnetUri: String? + get() = listOfNotNull(url, externalUrl) + .firstOrNull { it.isMagnetLink() } + val isDirectDebridStream: Boolean get() = clientResolve?.isDirectDebridCandidate == true @@ -38,6 +47,12 @@ data class StreamItem( externalUrl.isMagnetLink() ) + val isCachedDebridTorrentStream: Boolean + get() = isTorrentStream && debridCacheStatus?.state == StreamDebridCacheState.CACHED + + val needsLocalDebridResolve: Boolean + get() = isTorrentStream && playableDirectUrl == null + val hasPlayableSource: Boolean get() = url != null || infoHash != null || externalUrl != null || clientResolve != null } @@ -45,6 +60,9 @@ data class StreamItem( private fun String?.isMagnetLink(): Boolean = this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true +fun StreamItem.isSelectableForPlayback(debridEnabled: Boolean): Boolean = + playableDirectUrl != null || (debridEnabled && (needsLocalDebridResolve || isDirectDebridStream)) + data class StreamBehaviorHints( val bingeGroup: String? = null, val notWebReady: Boolean = false, @@ -59,6 +77,21 @@ data class StreamProxyHeaders( val response: Map? = null, ) +enum class StreamDebridCacheState { + CHECKING, + CACHED, + NOT_CACHED, + UNKNOWN, +} + +data class StreamDebridCacheStatus( + val providerId: String, + val providerName: String, + val state: StreamDebridCacheState, + val cachedName: String? = null, + val cachedSize: Long? = null, +) + data class StreamClientResolve( val type: String? = null, val infoHash: String? = null, 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 2fc87a24..52f8965b 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 @@ -6,7 +6,9 @@ import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.debrid.DirectDebridStreamPreparer -import com.nuvio.app.features.debrid.DirectDebridStreamSource +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.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.plugins.PluginRepository @@ -101,6 +103,7 @@ object StreamsRepository { PlayerSettingsRepository.ensureLoaded() val playerSettings = PlayerSettingsRepository.uiState.value + val debridSettings = DebridSettingsRepository.snapshot() val autoPlayMode = playerSettings.streamAutoPlayMode val isAutoPlayEnabled = !manualSelection && autoPlayMode != StreamAutoPlayMode.MANUAL && !(autoPlayMode == StreamAutoPlayMode.REGEX_MATCH && @@ -134,7 +137,6 @@ object StreamsRepository { } val installedAddons = AddonRepository.uiState.value.addons - val debridTargets = DirectDebridStreamSource.configuredTargets() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.getEnabledScrapersForType(type) } else { @@ -145,7 +147,7 @@ object StreamsRepository { groupByRepository = pluginUiState.groupStreamsByRepository, ) - if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) { + if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) { _uiState.value = StreamsUiState( requestToken = requestToken, isAnyLoading = false, @@ -174,7 +176,7 @@ object StreamsRepository { log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" } - if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) { + if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) { _uiState.value = StreamsUiState( requestToken = requestToken, isAnyLoading = false, @@ -199,13 +201,6 @@ object StreamsRepository { streams = emptyList(), isLoading = true, ) - } + debridTargets.map { target -> - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = emptyList(), - isLoading = true, - ) }, installedAddonOrder) _uiState.value = StreamsUiState( requestToken = requestToken, @@ -224,8 +219,7 @@ object StreamsRepository { .toMutableMap() val pluginFirstErrorByAddonId = mutableMapOf() val totalTasks = streamAddons.size + - pluginProviderGroups.sumOf { it.scrapers.size } + - debridTargets.size + pluginProviderGroups.sumOf { it.scrapers.size } val installedAddonNames = installedAddonOrder.toSet() var autoSelectTriggered = false @@ -255,6 +249,7 @@ object StreamsRepository { installedAddonNames = installedAddonNames, selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + debridEnabled = debridSettings.enabled, ) _uiState.update { it.copy(autoPlayStream = selected) } if (selected == null) { @@ -363,20 +358,6 @@ object StreamsRepository { } } - debridTargets.forEach { target -> - launch { - publishCompletion( - StreamLoadCompletion.Debrid( - DirectDebridStreamSource.fetchProviderStreams( - type = type, - videoId = videoId, - target = target, - ), - ), - ) - } - } - repeat(totalTasks) { when (val completion = completions.receive()) { is StreamLoadCompletion.Addon -> { @@ -439,44 +420,36 @@ object StreamsRepository { } } - is StreamLoadCompletion.Debrid -> { - val result = completion.group + } + } + + if (!debridPreparationLaunched) { + debridPreparationLaunched = true + val checkingGroups = TorboxAvailabilityService.markChecking(_uiState.value.groups) + _uiState.update { current -> current.copy(groups = checkingGroups) } + val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability(_uiState.value.groups) + val presentedGroups = DebridStreamPresentation.apply( + groups = availabilityGroups, + settings = debridSettings, + ) + _uiState.update { current -> current.copy(groups = presentedGroups) } + launch { + DirectDebridStreamPreparer.prepare( + streams = _uiState.value.groups.flatMap { it.streams }, + season = season, + episode = episode, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) { original, prepared -> _uiState.update { current -> - val updated = StreamAutoPlaySelector.orderAddonStreams( - groups = current.groups.map { group -> - if (group.addonId == result.addonId) result else group - }, - installedOrder = installedAddonOrder, - ) - val anyLoading = updated.any { it.isLoading } current.copy( - groups = updated, - isAnyLoading = anyLoading, - emptyStateReason = updated.toEmptyStateReason(anyLoading), + groups = DirectDebridStreamPreparer.replacePreparedStream( + groups = current.groups, + original = original, + prepared = prepared, + ), ) } - if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) { - debridPreparationLaunched = true - launch { - DirectDebridStreamPreparer.prepare( - streams = _uiState.value.groups.flatMap { it.streams }, - season = season, - episode = episode, - playerSettings = playerSettings, - installedAddonNames = installedAddonNames, - ) { original, prepared -> - _uiState.update { current -> - current.copy( - groups = DirectDebridStreamPreparer.replacePreparedStream( - groups = current.groups, - original = original, - prepared = prepared, - ), - ) - } - } - } - } } } } @@ -492,6 +465,7 @@ object StreamsRepository { installedAddonNames = installedAddonNames, selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + debridEnabled = debridSettings.enabled, ) _uiState.update { it.copy(autoPlayStream = selected) } } @@ -569,7 +543,6 @@ private data class PluginProviderGroup( private sealed interface StreamLoadCompletion { data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion - data class Debrid(val group: AddonStreamGroup) : StreamLoadCompletion data class PluginScraper( val addonId: String, val streams: List, 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 e7078fd9..8b0cf9d3 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 @@ -85,6 +85,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState import coil3.compose.AsyncImage import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository import kotlinx.coroutines.launch @@ -130,6 +131,10 @@ fun StreamsScreen( PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.uiState }.collectAsStateWithLifecycle() + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() val watchProgressUiState by remember { WatchProgressRepository.ensureLoaded() WatchProgressRepository.uiState @@ -141,7 +146,6 @@ fun StreamsScreen( val clipboardManager = LocalClipboardManager.current val streamLinkCopiedText = stringResource(Res.string.streams_link_copied) val noDirectStreamLinkText = stringResource(Res.string.streams_no_direct_link) - val torrentUnsupportedText = stringResource(Res.string.streams_torrent_not_supported) var streamActionsTarget by remember(videoId) { mutableStateOf(null) } var preferredFilterApplied by remember(videoId) { mutableStateOf(false) } val storedProgress = if (startFromBeginning) { @@ -216,14 +220,11 @@ fun StreamsScreen( episodeNumber = episodeNumber, episodeTitle = episodeTitle, uiState = uiState, + debridEnabled = debridSettings.enabled, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = { stream, positionMs, progressFraction -> - if (stream.isTorrentStream) { - NuvioToastController.show(torrentUnsupportedText) - } else { - onStreamSelected(stream, positionMs, progressFraction) - } + onStreamSelected(stream, positionMs, progressFraction) }, onStreamLongPress = { stream -> streamActionsTarget = stream }, ) @@ -237,14 +238,11 @@ fun StreamsScreen( episodeNumber = episodeNumber, episodeTitle = episodeTitle, uiState = uiState, + debridEnabled = debridSettings.enabled, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = { stream, positionMs, progressFraction -> - if (stream.isTorrentStream) { - NuvioToastController.show(torrentUnsupportedText) - } else { - onStreamSelected(stream, positionMs, progressFraction) - } + onStreamSelected(stream, positionMs, progressFraction) }, onStreamLongPress = { stream -> streamActionsTarget = stream }, ) @@ -338,7 +336,7 @@ fun StreamsScreen( externalPlayerEnabled = playerSettings.externalPlayerEnabled, onDismiss = { streamActionsTarget = null }, onCopyLink = { stream -> - val directUrl = stream.directPlaybackUrl + val directUrl = stream.playableDirectUrl if (!directUrl.isNullOrBlank()) { clipboardManager.setText(AnnotatedString(directUrl)) NuvioToastController.show(streamLinkCopiedText) @@ -386,6 +384,7 @@ private fun MobileStreamsLayout( episodeNumber: Int?, episodeTitle: String?, uiState: StreamsUiState, + debridEnabled: Boolean, resumePositionMs: Long?, resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, @@ -466,6 +465,7 @@ private fun MobileStreamsLayout( StreamList( uiState = uiState, + debridEnabled = debridEnabled, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, @@ -759,6 +759,7 @@ private fun FilterChip( @Composable internal fun StreamList( uiState: StreamsUiState, + debridEnabled: Boolean, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, resumePositionMs: Long?, @@ -797,6 +798,7 @@ internal fun StreamList( sectionKey = streamSectionRenderKey(groupIndex = groupIndex, group = group), group = group, showHeader = uiState.selectedFilter == null, + debridEnabled = debridEnabled, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, @@ -820,6 +822,7 @@ private fun LazyListScope.streamSection( sectionKey: String, group: AddonStreamGroup, showHeader: Boolean, + debridEnabled: Boolean, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, resumePositionMs: Long?, @@ -863,13 +866,14 @@ private fun LazyListScope.streamSection( ) { _, stream -> StreamCard( stream = stream, + enabled = stream.isSelectableForPlayback(debridEnabled), onClick = { - if (stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream) { + if (stream.isSelectableForPlayback(debridEnabled)) { onStreamSelected(stream, resumePositionMs, resumeProgressFraction) } }, onLongClick = { - if (stream.directPlaybackUrl != null) { + if (stream.playableDirectUrl != null) { onStreamLongPress(stream) } }, @@ -966,11 +970,11 @@ private fun StreamSourceHeader( @Composable private fun StreamCard( stream: StreamItem, + enabled: Boolean, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { - val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream val cardShape = RoundedCornerShape(12.dp) Row( modifier = modifier @@ -985,7 +989,7 @@ private fun StreamCard( .clip(cardShape) .background(Color.White.copy(alpha = 0.05f)) .combinedClickable( - enabled = isEnabled, + enabled = enabled, onClick = onClick, onLongClick = onLongClick, ) @@ -1019,6 +1023,7 @@ private fun StreamCard( Spacer(modifier = Modifier.height(6.dp)) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + StreamAvailabilityBadge(stream = stream) StreamFileSizeBadge(stream = stream) } } @@ -1123,6 +1128,50 @@ private fun StreamActionsSheet( } } +@Composable +private fun StreamAvailabilityBadge(stream: StreamItem) { + val status = stream.debridCacheStatus ?: return + val (label, background, foreground) = when (status.state) { + StreamDebridCacheState.CHECKING -> Triple( + "${status.providerName} checking", + MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), + MaterialTheme.colorScheme.primary, + ) + StreamDebridCacheState.CACHED -> Triple( + "${status.providerName} ready", + Color(0xFF123D2B), + Color(0xFFB8F6D3), + ) + StreamDebridCacheState.NOT_CACHED -> Triple( + "${status.providerName} not cached", + Color(0xFF3D2424), + Color(0xFFFFC9C9), + ) + StreamDebridCacheState.UNKNOWN -> Triple( + "${status.providerName} unknown", + Color(0xFF2C3033), + MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(background) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.2.sp, + ), + color = foreground, + ) + } +} + @Composable private fun StreamFileSizeBadge(stream: StreamItem) { val bytes = stream.behaviorHints.videoSize ?: return diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt index 3f33ce98..9b7ffbda 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -60,6 +60,7 @@ internal fun TabletStreamsLayout( episodeNumber: Int?, episodeTitle: String?, uiState: StreamsUiState, + debridEnabled: Boolean, resumePositionMs: Long?, resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, @@ -199,6 +200,7 @@ internal fun TabletStreamsLayout( StreamList( uiState = uiState, + debridEnabled = debridEnabled, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt deleted file mode 100644 index 83b127cc..00000000 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.nuvio.app.features.debrid - -import com.nuvio.app.features.streams.StreamParser -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertFalse - -class DebridStreamFormatterTest { - private val formatter = DebridStreamFormatter() - - @Test - fun `formats real client stream episode fields and behavior size`() { - val stream = StreamParser.parse( - payload = clientStreamPayload(), - addonName = "Torbox Instant", - addonId = "debrid:torbox", - ).single() - - val formatted = formatter.format( - stream = stream, - settings = DebridSettings( - enabled = true, - torboxApiKey = "key", - streamDescriptionTemplate = CLIENT_TEMPLATE, - ), - ) - - val description = formatted.description.orEmpty() - assertEquals(0, stream.clientResolve?.fileIdx) - assertContains(description, "S05") - assertContains(description, "E02") - assertContains(description, "6.3 GB") - assertFalse(description.contains("6761331156")) - } - - @Test - fun `formats season episode from parsed fields when top level resolve omits them`() { - val stream = StreamParser.parse( - payload = clientStreamPayload(includeTopLevelSeasonEpisode = false), - addonName = "Torbox Instant", - addonId = "debrid:torbox", - ).single() - - val formatted = formatter.format( - stream = stream, - settings = DebridSettings( - enabled = true, - torboxApiKey = "key", - streamDescriptionTemplate = CLIENT_TEMPLATE, - ), - ) - - val description = formatted.description.orEmpty() - assertContains(description, "S05") - assertContains(description, "E02") - assertContains(description, "6.3 GB") - } - - private fun clientStreamPayload(includeTopLevelSeasonEpisode: Boolean = true): String { - val seasonEpisode = if (includeTopLevelSeasonEpisode) { - """ - "season": 5, - "episode": 2, - """.trimIndent() - } else { - "" - } - return """ - { - "streams": [ - { - "name": "TB 2160p cached", - "description": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", - "clientResolve": { - "type": "debrid", - "service": "torbox", - "isCached": true, - "infoHash": "cb7286fb422ed0643037523e7b09446734e9dbc4", - "sources": [], - "fileIdx": "0", - "filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", - "title": "The Boys", - "torrentName": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", - $seasonEpisode - "stream": { - "raw": { - "parsed": { - "resolution": "2160p", - "quality": "WEB-DL", - "codec": "hevc", - "audio": ["Atmos", "Dolby Digital Plus"], - "channels": ["5.1"], - "hdr": ["DV", "HDR10+"], - "group": "Kitsune", - "seasons": [5], - "episodes": [2], - "raw_title": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv" - } - } - } - }, - "behaviorHints": { - "filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", - "videoSize": 6761331156 - } - } - ] - } - """.trimIndent() - } - - private companion object { - private const val CLIENT_TEMPLATE = - "{stream.title::exists[\"🍿 {stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year}) \"||\"\"]}\n" + - "{stream.season::>=0[\"πŸ‚ S\"||\"\"]}{stream.season::<=9[\"0\"||\"\"]}{stream.season::>0[\"{stream.season} \"||\"\"]}{stream.episode::>=0[\"🎞️ E\"||\"\"]}{stream.episode::<=9[\"0\"||\"\"]}{stream.episode::>0[\"{stream.episode} \"||\"\"]}\n" + - "{stream.quality::exists[\"πŸŽ₯ {stream.quality} \"||\"\"]}{stream.visualTags::exists[\"πŸ“Ί {stream.visualTags::join(' | ')} \"||\"\"]}\n" + - "{stream.audioTags::exists[\"🎧 {stream.audioTags::join(' | ')} \"||\"\"]}{stream.audioChannels::exists[\"πŸ”Š {stream.audioChannels::join(' | ')}\"||\"\"]}\n" + - "{stream.size::>0[\"πŸ“¦ {stream.size::bytes} \"||\"\"]}{stream.encode::exists[\"🎞️ {stream.encode} \"||\"\"]}{stream.indexer::exists[\"πŸ“‘{stream.indexer}\"||\"\"]}\n" + - "{service.cached::istrue[\"⚑Ready \"||\"\"]}{service.cached::isfalse[\"❌ Not Ready \"||\"\"]}{service.shortName::exists[\"({service.shortName}) \"||\"\"]}{stream.type::=Debrid[\"☁️ Debrid \"||\"\"]}πŸ”{addon.name}" - } -} 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 new file mode 100644 index 00000000..07877814 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt @@ -0,0 +1,103 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamBehaviorHints +import com.nuvio.app.features.streams.StreamDebridCacheState +import com.nuvio.app.features.streams.StreamDebridCacheStatus +import com.nuvio.app.features.streams.StreamItem +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +class DebridStreamPresentationTest { + @Test + fun `formats cached addon torrent streams with custom templates`() { + val stream = localTorboxStream( + filename = "Lost.S01E01.2160p.WEB-DL.H265.AAC-NAKSU.mkv", + size = 8_589_934_592, + ) + + val formatted = DebridStreamFormatter().format( + stream = stream, + settings = DebridSettings( + enabled = true, + torboxApiKey = "key", + streamNameTemplate = "{stream.resolution} {service.shortName} {service.cached::istrue[\"Ready\"||\"Not Ready\"]}", + streamDescriptionTemplate = "{stream.quality} {stream.encode}\n{stream.size::bytes}\n{stream.filename}", + ), + ) + + assertEquals("2160p TB Ready", formatted.name) + val description = formatted.description.orEmpty() + assertContains(description, "WEB-DL HEVC") + assertContains(description, "8 GB") + assertContains(description, "Lost.S01E01.2160p.WEB-DL.H265.AAC-NAKSU.mkv") + } + + @Test + fun `applies debrid sort filters and limits without removing normal urls`() { + val low = localTorboxStream( + name = "Low", + filename = "Movie.720p.BluRay.x264-GRP.mkv", + size = 4_000_000_000, + ) + val large = localTorboxStream( + name = "Large", + filename = "Movie.2160p.BluRay.REMUX.HEVC-GRP.mkv", + size = 40_000_000_000, + ) + val mid = localTorboxStream( + name = "Mid", + filename = "Movie.1080p.WEB-DL.HEVC-GRP.mkv", + size = 10_000_000_000, + ) + val urlStream = StreamItem( + name = "Resolved addon URL", + url = "https://example.test/video.m3u8", + addonName = "Addon", + addonId = "addon:test", + ) + + val group = AddonStreamGroup( + addonName = "Addon", + addonId = "addon:test", + streams = listOf(low, large, mid, urlStream), + ) + val presented = DebridStreamPresentation.apply( + groups = listOf(group), + settings = DebridSettings( + enabled = true, + torboxApiKey = "key", + streamMaxResults = 2, + streamSortMode = DebridStreamSortMode.QUALITY_DESC, + streamMinimumQuality = DebridStreamMinimumQuality.P1080, + streamCodecFilter = DebridStreamCodecFilter.HEVC, + ), + ).single().streams + + assertEquals(listOf("4K TB Ready", "FHD TB Ready", "Resolved addon URL"), presented.map { it.name }) + } + + private fun localTorboxStream( + name: String = "Torrent", + filename: String, + size: Long, + ): StreamItem = + StreamItem( + name = name, + infoHash = "abcdef1234567890abcdef1234567890abcdef12$size".take(40), + addonName = "Addon", + addonId = "addon:test", + behaviorHints = StreamBehaviorHints( + filename = filename, + videoSize = size, + ), + debridCacheStatus = StreamDebridCacheStatus( + providerId = DebridProviders.TORBOX_ID, + providerName = DebridProviders.Torbox.displayName, + state = StreamDebridCacheState.CACHED, + cachedName = filename, + cachedSize = size, + ), + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt deleted file mode 100644 index 7a670339..00000000 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.nuvio.app.features.debrid - -import kotlin.test.Test -import kotlin.test.assertEquals - -class DebridStreamTemplateEngineTest { - private val engine = DebridStreamTemplateEngine() - - @Test - fun `renders nested condition branches and transforms`() { - val rendered = engine.render( - "{stream.resolution::=2160p[\"4K {service.shortName} \"||\"\"]}{stream.title::title}", - mapOf( - "stream.resolution" to "2160p", - "stream.title" to "sample movie", - "service.shortName" to "RD", - ), - ) - - assertEquals("4K RD Sample Movie", rendered) - } - - @Test - fun `formats bytes and joins list values`() { - val rendered = engine.render( - "{stream.size::bytes} {stream.audioTags::join(' | ')}", - mapOf( - "stream.size" to 1_610_612_736L, - "stream.audioTags" to listOf("DTS", "Atmos"), - ), - ) - - assertEquals("1.5 GB DTS | Atmos", rendered) - } - - @Test - fun `renders Debrid size values as readable text while keeping numeric comparisons`() { - val rendered = engine.render( - "{stream.size::>0[\"{stream.size}\"||\"\"]}", - mapOf("stream.size" to DebridTemplateBytes(7_361_184_308L)), - ) - - assertEquals("6.9 GB", rendered) - } -} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt deleted file mode 100644 index 15fcf1e2..00000000 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.nuvio.app.features.debrid - -import kotlin.test.Test -import kotlin.test.assertEquals - -class DirectDebridConfigEncoderTest { - @Test - fun `encodes Torbox config exactly like TV`() { - val encoded = DirectDebridConfigEncoder().encodeTorbox("tb_key") - - assertEquals( - "eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InRvcmJveCIsImFwaUtleSI6InRiX2tleSJ9XSwiZW5hYmxlVG9ycmVudCI6ZmFsc2V9", - encoded, - ) - } - - @Test - fun `escapes API key before base64 encoding`() { - val encoded = DirectDebridConfigEncoder().encode( - DebridServiceCredential(DebridProviders.RealDebrid, "rd\"key\\line"), - ) - - val expected = "eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InJlYWxkZWJyaWQiLCJhcGlLZXkiOiJyZFwia2V5XFxsaW5lIn1dLCJlbmFibGVUb3JyZW50IjpmYWxzZX0=" - assertEquals(expected, encoded) - } -} - diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt deleted file mode 100644 index 593fa6af..00000000 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt +++ /dev/null @@ -1,210 +0,0 @@ -package com.nuvio.app.features.debrid - -import com.nuvio.app.features.streams.StreamClientResolve -import com.nuvio.app.features.streams.StreamClientResolveParsed -import com.nuvio.app.features.streams.StreamClientResolveRaw -import com.nuvio.app.features.streams.StreamClientResolveStream -import com.nuvio.app.features.streams.StreamItem -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class DirectDebridStreamFilterTest { - @Test - fun `keeps only cached supported debrid streams`() { - val torbox = stream(service = DebridProviders.TORBOX_ID, cached = true) - val uncached = stream(service = DebridProviders.TORBOX_ID, cached = false) - val unsupported = stream(service = "other", cached = true) - val torrent = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, type = "torrent") - - val filtered = DirectDebridStreamFilter.filterInstant(listOf(torbox, uncached, unsupported, torrent)) - - assertEquals(1, filtered.size) - assertEquals("Torbox Instant", filtered.single().addonName) - assertEquals("debrid:torbox", filtered.single().addonId) - } - - @Test - fun `dedupes by hash file and filename identity`() { - val first = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "ABC", fileIdx = 2) - val duplicate = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 2) - val otherFile = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 3) - - val filtered = DirectDebridStreamFilter.filterInstant(listOf(first, duplicate, otherFile)) - - assertEquals(2, filtered.size) - } - - @Test - fun `direct debrid stream is not treated as unsupported torrent`() { - val direct = stream(service = DebridProviders.TORBOX_ID, cached = true, infoHash = "hash") - val plainTorrent = StreamItem( - name = "Torrent", - infoHash = "hash", - addonName = "Addon", - addonId = "addon", - ) - - assertTrue(direct.isDirectDebridStream) - assertFalse(direct.isTorrentStream) - assertTrue(plainTorrent.isTorrentStream) - } - - @Test - fun `sorts and limits streams by quality and size`() { - val streams = listOf( - stream(resolution = "1080p", size = 20), - stream(resolution = "2160p", size = 10), - stream(resolution = "2160p", size = 30), - stream(resolution = "720p", size = 40), - ) - - val filtered = DirectDebridStreamFilter.filterInstant( - streams, - DebridSettings( - streamMaxResults = 2, - streamSortMode = DebridStreamSortMode.QUALITY_DESC, - ), - ) - - assertEquals(listOf(30L, 10L), filtered.map { it.clientResolve?.stream?.raw?.size }) - } - - @Test - fun `filters minimum quality dolby vision hdr and codec`() { - val hdrHevc = stream(resolution = "2160p", hdr = listOf("HDR10"), codec = "HEVC", size = 10) - val dvHevc = stream(resolution = "2160p", hdr = listOf("DV", "HDR10"), codec = "HEVC", size = 20) - val sdrAvc = stream(resolution = "1080p", codec = "AVC", size = 30) - val hdHevc = stream(resolution = "720p", codec = "HEVC", size = 40) - - val noDvHdrHevc4k = DirectDebridStreamFilter.filterInstant( - listOf(hdrHevc, dvHevc, sdrAvc, hdHevc), - DebridSettings( - streamMinimumQuality = DebridStreamMinimumQuality.P2160, - streamDolbyVisionFilter = DebridStreamFeatureFilter.EXCLUDE, - streamHdrFilter = DebridStreamFeatureFilter.ONLY, - streamCodecFilter = DebridStreamCodecFilter.HEVC, - ), - ) - - assertEquals(listOf(10L), noDvHdrHevc4k.map { it.clientResolve?.stream?.raw?.size }) - - val dvOnly = DirectDebridStreamFilter.filterInstant( - listOf(hdrHevc, dvHevc, sdrAvc, hdHevc), - DebridSettings(streamDolbyVisionFilter = DebridStreamFeatureFilter.ONLY), - ) - - assertEquals(listOf(20L), dvOnly.map { it.clientResolve?.stream?.raw?.size }) - } - - @Test - fun `applies stream preference filters and sort criteria`() { - val remuxAtmos = stream( - resolution = "2160p", - quality = "BluRay REMUX", - codec = "HEVC", - audio = listOf("Atmos", "TrueHD"), - channels = listOf("7.1"), - languages = listOf("en"), - group = "GOOD", - size = 40_000_000_000, - ) - val webAac = stream( - resolution = "2160p", - quality = "WEB-DL", - codec = "AVC", - audio = listOf("AAC"), - channels = listOf("2.0"), - languages = listOf("en"), - group = "NOPE", - size = 4_000_000_000, - ) - val blurayDts = stream( - resolution = "1080p", - quality = "BluRay", - codec = "AVC", - audio = listOf("DTS"), - channels = listOf("5.1"), - languages = listOf("hi"), - group = "GOOD", - size = 12_000_000_000, - ) - - val filtered = DirectDebridStreamFilter.filterInstant( - listOf(webAac, blurayDts, remuxAtmos), - DebridSettings( - streamPreferences = DebridStreamPreferences( - maxResults = 2, - maxPerResolution = 1, - sizeMinGb = 5, - requiredResolutions = listOf(DebridStreamResolution.P2160, DebridStreamResolution.P1080), - excludedQualities = listOf(DebridStreamQuality.WEB_DL), - requiredAudioChannels = listOf(DebridStreamAudioChannel.CH_7_1, DebridStreamAudioChannel.CH_5_1), - excludedEncodes = listOf(DebridStreamEncode.UNKNOWN), - excludedLanguages = listOf(DebridStreamLanguage.IT), - requiredReleaseGroups = listOf("GOOD"), - sortCriteria = listOf( - DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), - DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC), - ), - ), - ), - ) - - assertEquals(listOf(40_000_000_000L, 12_000_000_000L), filtered.map { it.clientResolve?.stream?.raw?.size }) - } - - private fun stream( - service: String? = DebridProviders.TORBOX_ID, - cached: Boolean? = true, - type: String = "debrid", - infoHash: String = "hash", - fileIdx: Int = 1, - resolution: String? = null, - quality: String? = null, - hdr: List = emptyList(), - codec: String? = null, - audio: List = emptyList(), - channels: List = emptyList(), - languages: List = emptyList(), - group: String? = null, - size: Long? = null, - ): StreamItem = - StreamItem( - name = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}", - description = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}", - addonName = "Direct Debrid", - addonId = "debrid", - clientResolve = StreamClientResolve( - type = type, - service = service, - isCached = cached, - infoHash = infoHash + size.orEmptyHashPart() + resolution.orEmpty() + quality.orEmpty() + codec.orEmpty(), - fileIdx = fileIdx, - filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv", - torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}", - stream = StreamClientResolveStream( - raw = StreamClientResolveRaw( - torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}", - filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv", - size = size, - folderSize = size, - parsed = StreamClientResolveParsed( - resolution = resolution, - quality = quality, - hdr = hdr, - codec = codec, - audio = audio, - channels = channels, - languages = languages, - group = group, - ), - ), - ), - ), - ) -} - -private fun Long?.orEmptyHashPart(): String = - this?.toString().orEmpty() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt index 9260e883..41519dac 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt @@ -158,7 +158,7 @@ class StreamParserTest { ] } """.trimIndent(), - addonName = "Direct Debrid", + addonName = "Debrid Fixture", addonId = "debrid:torbox", ) diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 10b019f6..48de7ef6 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ CURRENT_PROJECT_VERSION=64 -MARKETING_VERSION=0.1.22 +MARKETING_VERSION=0.1.0 From 41b0bd95ad85b53eb6bca850f43999a0940e004e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 20 May 2026 16:15:02 +0530 Subject: [PATCH 02/18] Updated DebridStreamPresentation to filter out uncached streams and apply custom formatting based on settings. --- .../app/features/debrid/DebridSettings.kt | 3 + .../debrid/DebridSettingsRepository.kt | 37 ++- .../debrid/DebridStreamFormatterDefaults.kt | 8 +- .../debrid/DebridStreamPresentation.kt | 24 +- .../features/debrid/DirectDebridResolver.kt | 4 +- .../debrid/DirectDebridStreamPreparer.kt | 5 +- .../debrid/TorboxAvailabilityService.kt | 34 +- .../app/features/details/MetaDetailsScreen.kt | 35 ++ .../player/PlayerStreamsRepository.kt | 30 +- .../features/settings/DebridSettingsPage.kt | 2 +- .../streams/AddonStreamWarmupRepository.kt | 304 ++++++++++++++++++ .../streams/StreamAutoPlaySelector.kt | 5 +- .../app/features/streams/StreamModels.kt | 8 +- .../app/features/streams/StreamsRepository.kt | 31 +- .../app/features/streams/StreamsScreen.kt | 132 +++++--- .../features/streams/StreamsTabletLayout.kt | 2 + .../debrid/DebridStreamPresentationTest.kt | 36 ++- 17 files changed, 600 insertions(+), 100 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt 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 6e48cc07..6fb882f8 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 @@ -19,6 +19,9 @@ data class DebridSettings( ) { val hasAnyApiKey: Boolean get() = DebridProviders.configuredServices(this).isNotEmpty() + + val hasCustomStreamFormatting: Boolean + get() = streamNameTemplate.isNotBlank() || streamDescriptionTemplate.isNotBlank() } 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 d8c7625b..475597fd 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 @@ -160,7 +160,7 @@ object DebridSettingsRepository { fun setStreamNameTemplate(value: String) { ensureLoaded() - val normalized = value.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } + val normalized = normalizeStreamTemplate(value, DebridTemplateKind.NAME) if (streamNameTemplate == normalized) return streamNameTemplate = normalized publish() @@ -169,7 +169,7 @@ object DebridSettingsRepository { fun setStreamDescriptionTemplate(value: String) { ensureLoaded() - val normalized = value.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } + val normalized = normalizeStreamTemplate(value, DebridTemplateKind.DESCRIPTION) if (streamDescriptionTemplate == normalized) return streamDescriptionTemplate = normalized publish() @@ -178,8 +178,8 @@ object DebridSettingsRepository { fun setStreamTemplates(nameTemplate: String, descriptionTemplate: String) { ensureLoaded() - streamNameTemplate = nameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } - streamDescriptionTemplate = descriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } + streamNameTemplate = normalizeStreamTemplate(nameTemplate, DebridTemplateKind.NAME) + streamDescriptionTemplate = normalizeStreamTemplate(descriptionTemplate, DebridTemplateKind.DESCRIPTION) publish() DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate) DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate) @@ -241,12 +241,14 @@ object DebridSettingsRepository { hdrFilter = streamHdrFilter, codecFilter = streamCodecFilter, ) - streamNameTemplate = DebridSettingsStorage.loadStreamNameTemplate() - ?.takeIf { it.isNotBlank() } - ?: DebridStreamFormatterDefaults.NAME_TEMPLATE - streamDescriptionTemplate = DebridSettingsStorage.loadStreamDescriptionTemplate() - ?.takeIf { it.isNotBlank() } - ?: DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE + streamNameTemplate = normalizeStreamTemplate( + DebridSettingsStorage.loadStreamNameTemplate().orEmpty(), + DebridTemplateKind.NAME, + ) + streamDescriptionTemplate = normalizeStreamTemplate( + DebridSettingsStorage.loadStreamDescriptionTemplate().orEmpty(), + DebridTemplateKind.DESCRIPTION, + ) publish() } @@ -285,6 +287,21 @@ object DebridSettingsRepository { null } } + + private enum class DebridTemplateKind { + NAME, + DESCRIPTION, + } + + private fun normalizeStreamTemplate(value: String, kind: DebridTemplateKind): String { + val trimmed = value.trim() + return when { + trimmed.isBlank() -> "" + kind == DebridTemplateKind.NAME && trimmed == DebridStreamFormatterDefaults.LEGACY_NAME_TEMPLATE -> "" + kind == DebridTemplateKind.DESCRIPTION && trimmed == DebridStreamFormatterDefaults.LEGACY_DESCRIPTION_TEMPLATE -> "" + else -> value + } + } } internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt index 585b020c..d6637fd4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt @@ -1,7 +1,11 @@ package com.nuvio.app.features.debrid object DebridStreamFormatterDefaults { - const val NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}{service.cached::istrue[\"Ready\"||\"Not Ready\"]}" + const val NAME_TEMPLATE = "" - const val DESCRIPTION_TEMPLATE = "{stream.title::exists[\"{stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year})\"||\"\"]}\n{stream.quality::exists[\"{stream.quality} \"||\"\"]}{stream.visualTags::exists[\"{stream.visualTags::join(' | ')} \"||\"\"]}{stream.encode::exists[\"{stream.encode} \"||\"\"]}\n{stream.audioTags::exists[\"{stream.audioTags::join(' | ')}\"||\"\"]}{stream.audioTags::exists::and::stream.audioChannels::exists[\" | \"||\"\"]}{stream.audioChannels::exists[\"{stream.audioChannels::join(' | ')}\"||\"\"]}\n{stream.size::>0[\"{stream.size::bytes} \"||\"\"]}{stream.releaseGroup::exists[\"{stream.releaseGroup} \"||\"\"]}{stream.indexer::exists[\"{stream.indexer}\"||\"\"]}\n{service.cached::istrue[\"Ready\"||\"Not Ready\"]}{service.shortName::exists[\" ({service.shortName})\"||\"\"]}{stream.filename::exists[\"\n{stream.filename}\"||\"\"]}" + const val DESCRIPTION_TEMPLATE = "" + + const val LEGACY_NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}{service.cached::istrue[\"Ready\"||\"Not Ready\"]}" + + const val LEGACY_DESCRIPTION_TEMPLATE = "{stream.title::exists[\"{stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year})\"||\"\"]}\n{stream.quality::exists[\"{stream.quality} \"||\"\"]}{stream.visualTags::exists[\"{stream.visualTags::join(' | ')} \"||\"\"]}{stream.encode::exists[\"{stream.encode} \"||\"\"]}\n{stream.audioTags::exists[\"{stream.audioTags::join(' | ')}\"||\"\"]}{stream.audioTags::exists::and::stream.audioChannels::exists[\" | \"||\"\"]}{stream.audioChannels::exists[\"{stream.audioChannels::join(' | ')}\"||\"\"]}\n{stream.size::>0[\"{stream.size::bytes} \"||\"\"]}{stream.releaseGroup::exists[\"{stream.releaseGroup} \"||\"\"]}{stream.indexer::exists[\"{stream.indexer}\"||\"\"]}\n{service.cached::istrue[\"Ready\"||\"Not Ready\"]}{service.shortName::exists[\" ({service.shortName})\"||\"\"]}{stream.filename::exists[\"\n{stream.filename}\"||\"\"]}" } 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 68505de3..1f9d6d2d 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 @@ -10,12 +10,19 @@ object DebridStreamPresentation { fun apply(groups: List, settings: DebridSettings): List { if (!settings.enabled) return groups return groups.map { group -> - val debridStreams = group.streams.filter { stream -> stream.isManagedDebridStream } - if (debridStreams.isEmpty()) return@map group + val visibleStreams = group.streams.filterNot { stream -> stream.isUncachedDebridStream } + val debridStreams = visibleStreams.filter { stream -> stream.isManagedDebridStream } + if (debridStreams.isEmpty()) return@map group.copy(streams = visibleStreams) val presentedDebridStreams = applyPreferences(debridStreams, settings) - .map { stream -> formatter.format(stream, settings) } - val passthroughStreams = group.streams.filterNot { stream -> stream.isManagedDebridStream } + .map { stream -> + if (settings.hasCustomStreamFormatting) { + formatter.format(stream, settings) + } else { + stream + } + } + val passthroughStreams = visibleStreams.filterNot { stream -> stream.isManagedDebridStream } group.copy(streams = presentedDebridStreams + passthroughStreams) } @@ -33,14 +40,19 @@ object DebridStreamPresentation { internal val StreamItem.isManagedDebridStream: Boolean get() { val status = debridCacheStatus - return isDirectDebridStream || ( + return isAddonDebridCandidate && (isDirectDebridStream || ( isTorrentStream && status != null && status.providerId == DebridProviders.TORBOX_ID && status.state != StreamDebridCacheState.CHECKING - ) + )) } + private val StreamItem.isUncachedDebridStream: Boolean + get() = isInstalledAddonStream && + debridCacheStatus?.providerId == DebridProviders.TORBOX_ID && + debridCacheStatus.state == StreamDebridCacheState.NOT_CACHED + private fun applyLimits( streams: List>, preferences: DebridStreamPreferences, 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 7aff970b..68968c35 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 @@ -115,9 +115,9 @@ object DirectDebridPlaybackResolver { val settings = DebridSettingsRepository.snapshot() if (!settings.enabled) return false if (stream.needsLocalDebridResolve) { - return settings.torboxApiKey.isNotBlank() + return stream.isInstalledAddonStream && settings.torboxApiKey.isNotBlank() } - if (!stream.isDirectDebridStream || stream.playableDirectUrl != null) { + if (!stream.isInstalledAddonStream || !stream.isDirectDebridStream || stream.playableDirectUrl != null) { return false } return when (DebridProviders.byId(stream.clientResolve?.service)?.id) { 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 7cd37661..f9e99075 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 @@ -73,6 +73,7 @@ object DirectDebridStreamPreparer { val candidates = streams .filter { stream -> stream.playableDirectUrl == null && + stream.isAddonDebridCandidate && (stream.isDirectDebridStream || stream.isCachedDebridTorrentStream) } .distinctBy { it.preparationKey() } @@ -88,7 +89,7 @@ object DirectDebridStreamPreparer { selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, ) - if (autoPlaySelection?.let { it.isDirectDebridStream || it.isCachedDebridTorrentStream } == true) { + if (autoPlaySelection?.let { it.isAddonDebridCandidate && (it.isDirectDebridStream || it.isCachedDebridTorrentStream) } == true) { candidates.firstOrNull { it.preparationKey() == autoPlaySelection.preparationKey() } ?.let(prioritized::add) } @@ -118,9 +119,11 @@ object DirectDebridStreamPreparer { groups: List, original: StreamItem, prepared: StreamItem, + eligibleGroupIds: Set? = null, ): List { val key = original.preparationKey() return groups.map { group -> + if (eligibleGroupIds != null && group.addonId !in eligibleGroupIds) return@map group var changed = false val updatedStreams = group.streams.map { stream -> if (stream.preparationKey() == key) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt index 9dbd3876..39cb0e07 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/TorboxAvailabilityService.kt @@ -7,10 +7,13 @@ import com.nuvio.app.features.streams.StreamItem import kotlinx.coroutines.CancellationException object TorboxAvailabilityService { - fun markChecking(groups: List): List { + fun markChecking( + groups: List, + eligibleGroupIds: Set? = null, + ): List { val settings = DebridSettingsRepository.snapshot() if (!settings.enabled || settings.torboxApiKey.isBlank()) return groups - return groups.updateAvailabilityStatus { stream -> + return groups.updateAvailabilityStatus(eligibleGroupIds) { stream -> if (stream.torboxAvailabilityHash() == null || stream.debridCacheStatus?.state == StreamDebridCacheState.CACHED) { stream } else { @@ -25,18 +28,27 @@ object TorboxAvailabilityService { } } - suspend fun annotateCachedAvailability(groups: List): List { + suspend fun annotateCachedAvailability( + groups: List, + eligibleGroupIds: Set? = null, + ): List { val settings = DebridSettingsRepository.snapshot() val apiKey = settings.torboxApiKey.trim() if (!settings.enabled || apiKey.isBlank()) return groups val hashes = groups - .flatMap { group -> group.streams.mapNotNull { stream -> stream.torboxAvailabilityHash() } } + .filter { group -> eligibleGroupIds == null || group.addonId in eligibleGroupIds } + .flatMap { group -> + group.streams.mapNotNull { stream -> + stream.torboxAvailabilityHash() + ?.takeUnless { stream.debridCacheStatus?.state in FINAL_CACHE_STATES } + } + } .distinct() if (hashes.isEmpty()) return groups val cached = checkCached(apiKey = apiKey, hashes = hashes) - ?: return groups.updateAvailabilityStatus { stream -> + ?: return groups.updateAvailabilityStatus(eligibleGroupIds) { stream -> val hash = stream.torboxAvailabilityHash() if (hash == null) { stream @@ -51,8 +63,9 @@ object TorboxAvailabilityService { } } - return groups.updateAvailabilityStatus { stream -> + return groups.updateAvailabilityStatus(eligibleGroupIds) { stream -> val hash = stream.torboxAvailabilityHash() ?: return@updateAvailabilityStatus stream + if (stream.debridCacheStatus?.state in FINAL_CACHE_STATES) return@updateAvailabilityStatus stream val cachedItem = cached[hash] stream.copy( debridCacheStatus = StreamDebridCacheStatus( @@ -91,16 +104,23 @@ object TorboxAvailabilityService { } } +private val FINAL_CACHE_STATES = setOf( + StreamDebridCacheState.CACHED, + StreamDebridCacheState.NOT_CACHED, +) + internal fun StreamItem.torboxAvailabilityHash(): String? = infoHash ?.trim() ?.lowercase() - ?.takeIf { needsLocalDebridResolve && it.isNotBlank() } + ?.takeIf { isInstalledAddonStream && needsLocalDebridResolve && it.isNotBlank() } private fun List.updateAvailabilityStatus( + eligibleGroupIds: Set?, transform: (StreamItem) -> StreamItem, ): List = map { group -> + if (eligibleGroupIds != null && group.addonId !in eligibleGroupIds) return@map group var changed = false val updatedStreams = group.streams.map { stream -> val updated = transform(stream) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index d8bfbf27..bad8527f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -80,6 +80,7 @@ import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.toLibraryItem import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.streams.AddonStreamWarmupRepository import com.nuvio.app.features.streams.StreamAutoPlayPolicy import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.trakt.TraktAuthRepository @@ -372,6 +373,29 @@ fun MetaDetailsScreen( seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId } val hasEpisodes = meta.videos.any { it.season != null || it.episode != null } + val debridWarmupTarget = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction) { + if (meta.isSeriesLikeForDebridWarmup(hasEpisodes)) { + DetailDebridWarmupTarget( + videoId = seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id, + season = seriesAction?.seasonNumber, + episode = seriesAction?.episodeNumber, + ) + } else { + DetailDebridWarmupTarget( + videoId = meta.id, + season = null, + episode = null, + ) + } + } + LaunchedEffect(meta.type, debridWarmupTarget) { + AddonStreamWarmupRepository.preload( + type = meta.type, + videoId = debridWarmupTarget.videoId, + season = debridWarmupTarget.season, + episode = debridWarmupTarget.episode, + ) + } val hasProductionSection = remember(meta) { meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty() } @@ -1259,3 +1283,14 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp = } else { (maxWidth * 0.6f).coerceIn(520.dp, 680.dp) } + +private data class DetailDebridWarmupTarget( + val videoId: String, + val season: Int?, + val episode: Int?, +) + +private fun MetaDetails.isSeriesLikeForDebridWarmup(hasEpisodes: Boolean): Boolean = + hasEpisodes || type.equals("series", ignoreCase = true) || + type.equals("show", ignoreCase = true) || + type.equals("tv", ignoreCase = true) 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 dffdff27..4a17a108 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 @@ -14,6 +14,7 @@ import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.pluginContentId import com.nuvio.app.features.plugins.PluginRuntimeResult import com.nuvio.app.features.plugins.PluginScraper +import com.nuvio.app.features.streams.AddonStreamWarmupRepository import com.nuvio.app.features.streams.AddonStreamGroup import com.nuvio.app.features.streams.StreamAutoPlaySelector import com.nuvio.app.features.streams.StreamItem @@ -203,8 +204,13 @@ object PlayerStreamsRepository { } val installedAddonOrder = streamAddons.map { it.addonName } + val warmedAddonGroups = AddonStreamWarmupRepository + .cachedGroups(type = type, videoId = videoId, season = season, episode = episode) + .orEmpty() + .associateBy { it.addonId } + val warmedAddonIds = warmedAddonGroups.keys val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> - AddonStreamGroup( + warmedAddonGroups[addon.addonId] ?: AddonStreamGroup( addonName = addon.addonName, addonId = addon.addonId, streams = emptyList(), @@ -218,14 +224,17 @@ object PlayerStreamsRepository { isLoading = true, ) }, installedAddonOrder) + val isInitiallyLoading = initialGroups.any { it.isLoading } stateFlow.value = StreamsUiState( groups = initialGroups, activeAddonIds = initialGroups.map { it.addonId }.toSet(), - isAnyLoading = true, + isAnyLoading = isInitiallyLoading, ) val job = scope.launch { - val addonJobs = streamAddons.map { addon -> + val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds } + val installedAddonIds = streamAddons.map { it.addonId }.toSet() + val addonJobs = pendingStreamAddons.map { addon -> async { val url = buildAddonResourceUrl( manifestUrl = addon.manifest.transportUrl, @@ -316,9 +325,15 @@ object PlayerStreamsRepository { } if (!debridPreparationLaunched) { debridPreparationLaunched = true - val checkingGroups = TorboxAvailabilityService.markChecking(stateFlow.value.groups) + val checkingGroups = TorboxAvailabilityService.markChecking( + groups = stateFlow.value.groups, + eligibleGroupIds = installedAddonIds, + ) stateFlow.update { current -> current.copy(groups = checkingGroups) } - val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability(stateFlow.value.groups) + val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability( + groups = stateFlow.value.groups, + eligibleGroupIds = installedAddonIds, + ) val presentedGroups = DebridStreamPresentation.apply( groups = availabilityGroups, settings = DebridSettingsRepository.snapshot(), @@ -326,7 +341,9 @@ object PlayerStreamsRepository { stateFlow.update { current -> current.copy(groups = presentedGroups) } launch { DirectDebridStreamPreparer.prepare( - streams = stateFlow.value.groups.flatMap { it.streams }, + streams = stateFlow.value.groups + .filter { it.addonId in installedAddonIds } + .flatMap { it.streams }, season = season, episode = episode, playerSettings = playerSettings, @@ -338,6 +355,7 @@ object PlayerStreamsRepository { groups = current.groups, original = original, prepared = prepared, + eligibleGroupIds = installedAddonIds, ), ) } 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 cbc9eece..86c8a4b7 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 @@ -351,7 +351,7 @@ private fun templatePreview(value: String): String { .lineSequence() .map { it.trim() } .firstOrNull { it.isNotBlank() } - ?: return "" + ?: return "Addon default" return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..." } 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 new file mode 100644 index 00000000..d5ab7be1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt @@ -0,0 +1,304 @@ +package com.nuvio.app.features.streams + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.AddonManifest +import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.addons.buildAddonResourceUrl +import com.nuvio.app.features.addons.httpGetText +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.player.PlayerSettingsRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private const val ADDON_STREAM_WARMUP_CACHE_TTL_MS = 5L * 60L * 1000L + +object AddonStreamWarmupRepository { + private val log = Logger.withTag("AddonStreamWarmup") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + private val cache = mutableMapOf() + private val inFlight = mutableMapOf>>() + + fun preload(type: String, videoId: String, season: Int? = null, episode: Int? = null) { + val key = currentKey(type = type, videoId = videoId, season = season, episode = episode) ?: return + scope.launch { + runCatching { fetchWarmup(key) } + .onFailure { error -> + if (error is CancellationException) throw error + log.d(error) { "Addon stream warmup failed" } + } + } + } + + fun cachedGroups(type: String, videoId: String, season: Int? = null, episode: Int? = null): List? { + val key = currentKey(type = type, videoId = videoId, season = season, episode = episode) ?: return null + if (!mutex.tryLock()) return null + return try { + cachedGroupsLocked(key) + } finally { + mutex.unlock() + } + } + + private suspend fun fetchWarmup(key: AddonStreamWarmupKey): List { + cachedGroups(key.type, key.videoId, key.season, key.episode)?.let { return it } + + var ownsFetch = false + val newFetch = scope.async(start = CoroutineStart.LAZY) { + fetchWarmupUncached(key) + } + val activeFetch = mutex.withLock { + cachedGroupsLocked(key)?.let { cached -> + return@withLock null to cached + } + val existing = inFlight[key] + if (existing != null) { + existing to null + } else { + inFlight[key] = newFetch + ownsFetch = true + newFetch to null + } + } + activeFetch.second?.let { + newFetch.cancel() + return it + } + val deferred = activeFetch.first ?: return emptyList() + if (!ownsFetch) newFetch.cancel() + if (ownsFetch) deferred.start() + + return try { + val result = deferred.await() + val cacheableGroups = result.filter { it.streams.isNotEmpty() } + if (ownsFetch && cacheableGroups.isNotEmpty()) { + mutex.withLock { + cache[key] = CachedAddonStreamWarmup( + groups = cacheableGroups, + createdAtMs = epochMs(), + ) + } + } + result + } finally { + if (ownsFetch) { + mutex.withLock { + if (inFlight[key] === deferred) { + inFlight.remove(key) + } + } + } + } + } + + private suspend fun fetchWarmupUncached(key: AddonStreamWarmupKey): List { + val targets = key.addonTargets + if (targets.isEmpty()) return emptyList() + + val orderedGroups = coroutineScope { + targets.map { target -> + async { + fetchAddonStreams( + target = target, + type = key.type, + videoId = key.videoId, + ) + } + }.awaitAll() + }.let { groups -> + StreamAutoPlaySelector.orderAddonStreams( + groups = groups, + installedOrder = targets.map { it.addonName }, + ) + } + + val addonIds = targets.map { it.addonId }.toSet() + val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability( + groups = TorboxAvailabilityService.markChecking( + groups = orderedGroups, + eligibleGroupIds = addonIds, + ), + eligibleGroupIds = addonIds, + ) + var preparedGroups = DebridStreamPresentation.apply( + groups = availabilityGroups, + settings = key.settings, + ) + + PlayerSettingsRepository.ensureLoaded() + DirectDebridStreamPreparer.prepare( + streams = preparedGroups.flatMap { it.streams }, + season = key.season, + episode = key.episode, + playerSettings = PlayerSettingsRepository.uiState.value, + installedAddonNames = targets.map { it.addonName }.toSet(), + ) { original, prepared -> + preparedGroups = DirectDebridStreamPreparer.replacePreparedStream( + groups = preparedGroups, + original = original, + prepared = prepared, + eligibleGroupIds = addonIds, + ) + } + + return preparedGroups + } + + private suspend fun fetchAddonStreams( + target: AddonStreamWarmupTarget, + type: String, + videoId: String, + ): AddonStreamGroup { + val url = buildAddonResourceUrl( + manifestUrl = target.manifest.transportUrl, + resource = "stream", + type = type, + id = videoId, + ) + return runCatchingUnlessCancelled { + val payload = httpGetText(url) + StreamParser.parse( + payload = payload, + addonName = target.addonName, + addonId = target.addonId, + ) + }.fold( + onSuccess = { streams -> + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = streams, + isLoading = false, + ) + }, + onFailure = { error -> + log.d(error) { "Failed to warm addon stream target ${target.addonName}" } + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = emptyList(), + isLoading = false, + error = error.message, + ) + }, + ) + } + + private fun currentKey(type: String, videoId: String, season: Int?, episode: Int?): AddonStreamWarmupKey? { + val normalizedType = type.trim().lowercase() + val normalizedVideoId = videoId.trim() + if (normalizedType.isBlank() || normalizedVideoId.isBlank()) return null + + DebridSettingsRepository.ensureLoaded() + val settings = DebridSettingsRepository.snapshot() + if (!settings.enabled || settings.torboxApiKey.isBlank()) return null + + AddonRepository.initialize() + val addonTargets = AddonRepository.uiState.value.addons + .mapNotNull { addon -> addon.toWarmupTarget(normalizedType, normalizedVideoId) } + if (addonTargets.isEmpty()) return null + + return AddonStreamWarmupKey( + type = normalizedType, + videoId = normalizedVideoId, + season = season, + episode = episode, + addonFingerprint = addonTargets.joinToString("|") { it.fingerprint }, + settingsFingerprint = settings.warmupFingerprint(), + settings = settings, + addonTargets = addonTargets, + ) + } + + private fun cachedGroupsLocked(key: AddonStreamWarmupKey): List? { + val cached = cache[key] ?: return null + val age = epochMs() - cached.createdAtMs + return if (age in 0..ADDON_STREAM_WARMUP_CACHE_TTL_MS) { + cached.groups + } else { + cache.remove(key) + null + } + } +} + +private data class AddonStreamWarmupKey( + val type: String, + val videoId: String, + val season: Int?, + val episode: Int?, + val addonFingerprint: String, + val settingsFingerprint: String, + val settings: DebridSettings, + val addonTargets: List, +) + +private data class AddonStreamWarmupTarget( + val addonName: String, + val addonId: String, + val manifest: AddonManifest, + val fingerprint: String, +) + +private data class CachedAddonStreamWarmup( + val groups: List, + val createdAtMs: Long, +) + +private fun ManagedAddon.toWarmupTarget(type: String, videoId: String): AddonStreamWarmupTarget? { + val manifest = manifest ?: return null + val supportsRequestedStream = manifest.resources.any { resource -> + resource.name == "stream" && + resource.types.contains(type) && + (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { videoId.startsWith(it) }) + } + if (!supportsRequestedStream) return null + + val addonName = displayTitle.ifBlank { manifest.name } + return AddonStreamWarmupTarget( + addonName = addonName, + addonId = "addon:${manifest.id}:$manifestUrl", + manifest = manifest, + fingerprint = "$manifestUrl:${manifest.id}:${manifest.version}:$addonName", + ) +} + +private fun DebridSettings.warmupFingerprint(): String = + listOf( + enabled, + torboxApiKey, + instantPlaybackPreparationLimit, + streamMaxResults, + streamSortMode, + streamMinimumQuality, + streamDolbyVisionFilter, + streamHdrFilter, + streamCodecFilter, + streamPreferences, + streamNameTemplate, + streamDescriptionTemplate, + ).joinToString("|") + +private suspend fun runCatchingUnlessCancelled(block: suspend () -> T): Result = + try { + Result.success(block()) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Result.failure(error) + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index f01c2392..a88faa7d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -17,7 +17,7 @@ object StreamAutoPlaySelector { val (directDebridEntries, remainingEntries) = groups.partition { group -> group.addonId.startsWith("debrid:") || - group.streams.any { stream -> stream.isDirectDebridStream } + group.streams.any { stream -> stream.isAddonDebridCandidate && stream.isDirectDebridStream } } if (installedOrder.isEmpty()) return directDebridEntries + remainingEntries @@ -117,5 +117,6 @@ object StreamAutoPlaySelector { } private fun StreamItem.isAutoPlayable(debridEnabled: Boolean): Boolean = - playableDirectUrl != null || (debridEnabled && (isDirectDebridStream || isCachedDebridTorrentStream)) + playableDirectUrl != null || + (debridEnabled && isAddonDebridCandidate && (isDirectDebridStream || isCachedDebridTorrentStream)) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index dd87cc15..55deebce 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -40,6 +40,9 @@ data class StreamItem( val isDirectDebridStream: Boolean get() = clientResolve?.isDirectDebridCandidate == true + val isInstalledAddonStream: Boolean + get() = addonId.startsWith("addon:") + val isTorrentStream: Boolean get() = !isDirectDebridStream && ( !infoHash.isNullOrBlank() || @@ -53,6 +56,9 @@ data class StreamItem( val needsLocalDebridResolve: Boolean get() = isTorrentStream && playableDirectUrl == null + val isAddonDebridCandidate: Boolean + get() = isInstalledAddonStream && (needsLocalDebridResolve || isDirectDebridStream) + val hasPlayableSource: Boolean get() = url != null || infoHash != null || externalUrl != null || clientResolve != null } @@ -61,7 +67,7 @@ private fun String?.isMagnetLink(): Boolean = this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true fun StreamItem.isSelectableForPlayback(debridEnabled: Boolean): Boolean = - playableDirectUrl != null || (debridEnabled && (needsLocalDebridResolve || isDirectDebridStream)) + playableDirectUrl != null || (debridEnabled && isAddonDebridCandidate) data class StreamBehaviorHints( val bingeGroup: String? = null, 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 52f8965b..d86c8220 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 @@ -187,8 +187,13 @@ object StreamsRepository { // Initialise loading placeholders val installedAddonOrder = streamAddons.map { it.addonName } + val warmedAddonGroups = AddonStreamWarmupRepository + .cachedGroups(type = type, videoId = videoId, season = season, episode = episode) + .orEmpty() + .associateBy { it.addonId } + val warmedAddonIds = warmedAddonGroups.keys val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> - AddonStreamGroup( + warmedAddonGroups[addon.addonId] ?: AddonStreamGroup( addonName = addon.addonName, addonId = addon.addonId, streams = emptyList(), @@ -202,26 +207,29 @@ object StreamsRepository { isLoading = true, ) }, installedAddonOrder) + val isInitiallyLoading = initialGroups.any { it.isLoading } _uiState.value = StreamsUiState( requestToken = requestToken, groups = initialGroups, activeAddonIds = initialGroups.map { it.addonId }.toSet(), - isAnyLoading = true, + isAnyLoading = isInitiallyLoading, emptyStateReason = null, isDirectAutoPlayFlow = isDirectAutoPlayFlow, showDirectAutoPlayOverlay = isDirectAutoPlayFlow, ) activeJob = scope.launch { + val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds } val completions = Channel(capacity = Channel.BUFFERED) val pluginRemainingByAddonId = pluginProviderGroups .associate { it.addonId to it.scrapers.size } .toMutableMap() val pluginFirstErrorByAddonId = mutableMapOf() - val totalTasks = streamAddons.size + + val totalTasks = pendingStreamAddons.size + pluginProviderGroups.sumOf { it.scrapers.size } val installedAddonNames = installedAddonOrder.toSet() + val installedAddonIds = streamAddons.map { it.addonId }.toSet() var autoSelectTriggered = false var timeoutElapsed = false var debridPreparationLaunched = false @@ -273,7 +281,7 @@ object StreamsRepository { null } - streamAddons.forEach { addon -> + pendingStreamAddons.forEach { addon -> launch { val url = buildAddonResourceUrl( manifestUrl = addon.manifest.transportUrl, @@ -425,9 +433,15 @@ object StreamsRepository { if (!debridPreparationLaunched) { debridPreparationLaunched = true - val checkingGroups = TorboxAvailabilityService.markChecking(_uiState.value.groups) + val checkingGroups = TorboxAvailabilityService.markChecking( + groups = _uiState.value.groups, + eligibleGroupIds = installedAddonIds, + ) _uiState.update { current -> current.copy(groups = checkingGroups) } - val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability(_uiState.value.groups) + val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability( + groups = _uiState.value.groups, + eligibleGroupIds = installedAddonIds, + ) val presentedGroups = DebridStreamPresentation.apply( groups = availabilityGroups, settings = debridSettings, @@ -435,7 +449,9 @@ object StreamsRepository { _uiState.update { current -> current.copy(groups = presentedGroups) } launch { DirectDebridStreamPreparer.prepare( - streams = _uiState.value.groups.flatMap { it.streams }, + streams = _uiState.value.groups + .filter { it.addonId in installedAddonIds } + .flatMap { it.streams }, season = season, episode = episode, playerSettings = playerSettings, @@ -447,6 +463,7 @@ object StreamsRepository { groups = current.groups, original = original, prepared = prepared, + eligibleGroupIds = installedAddonIds, ), ) } 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 8b0cf9d3..c843a164 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 @@ -3,9 +3,12 @@ package com.nuvio.app.features.streams import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -85,6 +88,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState import coil3.compose.AsyncImage import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -221,6 +225,7 @@ fun StreamsScreen( episodeTitle = episodeTitle, uiState = uiState, debridEnabled = debridSettings.enabled, + appendInstantServiceToDefaultName = !debridSettings.hasCustomStreamFormatting, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = { stream, positionMs, progressFraction -> @@ -239,6 +244,7 @@ fun StreamsScreen( episodeTitle = episodeTitle, uiState = uiState, debridEnabled = debridSettings.enabled, + appendInstantServiceToDefaultName = !debridSettings.hasCustomStreamFormatting, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = { stream, positionMs, progressFraction -> @@ -385,6 +391,7 @@ private fun MobileStreamsLayout( episodeTitle: String?, uiState: StreamsUiState, debridEnabled: Boolean, + appendInstantServiceToDefaultName: Boolean, resumePositionMs: Long?, resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, @@ -466,6 +473,7 @@ private fun MobileStreamsLayout( StreamList( uiState = uiState, debridEnabled = debridEnabled, + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, @@ -760,6 +768,7 @@ private fun FilterChip( internal fun StreamList( uiState: StreamsUiState, debridEnabled: Boolean, + appendInstantServiceToDefaultName: Boolean, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, resumePositionMs: Long?, @@ -799,6 +808,7 @@ internal fun StreamList( group = group, showHeader = uiState.selectedFilter == null, debridEnabled = debridEnabled, + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, @@ -823,6 +833,7 @@ private fun LazyListScope.streamSection( group: AddonStreamGroup, showHeader: Boolean, debridEnabled: Boolean, + appendInstantServiceToDefaultName: Boolean, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, resumePositionMs: Long?, @@ -867,6 +878,7 @@ private fun LazyListScope.streamSection( StreamCard( stream = stream, enabled = stream.isSelectableForPlayback(debridEnabled), + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, onClick = { if (stream.isSelectableForPlayback(debridEnabled)) { onStreamSelected(stream, resumePositionMs, resumeProgressFraction) @@ -971,6 +983,7 @@ private fun StreamSourceHeader( private fun StreamCard( stream: StreamItem, enabled: Boolean, + appendInstantServiceToDefaultName: Boolean, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, modifier: Modifier = Modifier, @@ -997,15 +1010,9 @@ private fun StreamCard( verticalAlignment = Alignment.Top, ) { Column(modifier = Modifier.weight(1f)) { - Text( - text = stream.streamLabel, - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - lineHeight = 20.sp, - letterSpacing = 0.1.sp, - ), - color = MaterialTheme.colorScheme.onSurface, + StreamNameWithInstantService( + stream = stream, + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, ) val subtitle = stream.streamSubtitle @@ -1022,14 +1029,68 @@ private fun StreamCard( } Spacer(modifier = Modifier.height(6.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - StreamAvailabilityBadge(stream = stream) + Row(verticalAlignment = Alignment.CenterVertically) { StreamFileSizeBadge(stream = stream) } } } } +@Composable +private fun StreamNameWithInstantService( + stream: StreamItem, + appendInstantServiceToDefaultName: Boolean, +) { + val nameStyle = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + lineHeight = 20.sp, + letterSpacing = 0.sp, + ) + val instantLabel = if (appendInstantServiceToDefaultName) { + stream.instantServiceLabel() + } else { + null + } + val showInstantLabel = instantLabel != null + val visibleState = remember(stream.streamLabel) { + MutableTransitionState(showInstantLabel) + } + visibleState.targetState = showInstantLabel + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stream.streamLabel, + modifier = Modifier.weight(1f, fill = false), + style = nameStyle, + color = MaterialTheme.colorScheme.onSurface, + ) + AnimatedVisibility( + visibleState = visibleState, + enter = fadeIn(animationSpec = tween(durationMillis = 260)) + + expandHorizontally( + animationSpec = tween(durationMillis = 260), + expandFrom = Alignment.Start, + ), + exit = fadeOut(animationSpec = tween(durationMillis = 120)) + + shrinkHorizontally( + animationSpec = tween(durationMillis = 120), + shrinkTowards = Alignment.Start, + ), + label = "streamNameInstantService", + ) { + Text( + text = " ${instantLabel.orEmpty()}", + style = nameStyle, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun StreamActionsSheet( @@ -1128,48 +1189,13 @@ private fun StreamActionsSheet( } } -@Composable -private fun StreamAvailabilityBadge(stream: StreamItem) { - val status = stream.debridCacheStatus ?: return - val (label, background, foreground) = when (status.state) { - StreamDebridCacheState.CHECKING -> Triple( - "${status.providerName} checking", - MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), - MaterialTheme.colorScheme.primary, - ) - StreamDebridCacheState.CACHED -> Triple( - "${status.providerName} ready", - Color(0xFF123D2B), - Color(0xFFB8F6D3), - ) - StreamDebridCacheState.NOT_CACHED -> Triple( - "${status.providerName} not cached", - Color(0xFF3D2424), - Color(0xFFFFC9C9), - ) - StreamDebridCacheState.UNKNOWN -> Triple( - "${status.providerName} unknown", - Color(0xFF2C3033), - MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Box( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background(background) - .padding(horizontal = 8.dp, vertical = 3.dp), - ) { - Text( - text = label, - style = MaterialTheme.typography.labelSmall.copy( - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.2.sp, - ), - color = foreground, - ) - } +private fun StreamItem.instantServiceLabel(): String? { + val status = debridCacheStatus ?: return null + if (status.state != StreamDebridCacheState.CACHED) return null + val providerLabel = DebridProviders.shortName(status.providerId) + .ifBlank { status.providerName.trim() } + .ifBlank { DebridProviders.displayName(status.providerId) } + return "- $providerLabel Instant" } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt index 9b7ffbda..5ed13ae7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -61,6 +61,7 @@ internal fun TabletStreamsLayout( episodeTitle: String?, uiState: StreamsUiState, debridEnabled: Boolean, + appendInstantServiceToDefaultName: Boolean, resumePositionMs: Long?, resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, @@ -201,6 +202,7 @@ internal fun TabletStreamsLayout( StreamList( uiState = uiState, debridEnabled = debridEnabled, + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, 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 07877814..6abf6975 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 @@ -75,13 +75,45 @@ class DebridStreamPresentationTest { ), ).single().streams - assertEquals(listOf("4K TB Ready", "FHD TB Ready", "Resolved addon URL"), presented.map { it.name }) + assertEquals(listOf("Large", "Mid", "Resolved addon URL"), presented.map { it.name }) + } + + @Test + fun `hides addon torrent streams that are not cached`() { + val cached = localTorboxStream( + name = "Cached", + filename = "Movie.1080p.WEB-DL.HEVC-GRP.mkv", + size = 10_000_000_000, + ) + 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(cached, uncached), + ), + ), + settings = DebridSettings( + enabled = true, + torboxApiKey = "key", + ), + ).single().streams + + assertEquals(listOf("Cached"), presented.map { it.name }) } private fun localTorboxStream( name: String = "Torrent", filename: String, size: Long, + cacheState: StreamDebridCacheState = StreamDebridCacheState.CACHED, ): StreamItem = StreamItem( name = name, @@ -95,7 +127,7 @@ class DebridStreamPresentationTest { debridCacheStatus = StreamDebridCacheStatus( providerId = DebridProviders.TORBOX_ID, providerName = DebridProviders.Torbox.displayName, - state = StreamDebridCacheState.CACHED, + state = cacheState, cachedName = filename, cachedSize = size, ), From 782f65aaff9849610ce87618c315c18a78c5fe1f Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 20 May 2026 23:26:08 +0530 Subject: [PATCH 03/18] feat: parallel cache check --- .../player/PlayerStreamsRepository.kt | 135 +++++++++++------- .../streams/AddonStreamWarmupRepository.kt | 29 ++-- .../app/features/streams/StreamsRepository.kt | 118 ++++++++------- 3 files changed, 163 insertions(+), 119 deletions(-) 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 4a17a108..7db0dc80 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 @@ -162,6 +162,7 @@ object PlayerStreamsRepository { val installedAddonNames = installedAddons.map { it.displayTitle }.toSet() PlayerSettingsRepository.ensureLoaded() val playerSettings = PlayerSettingsRepository.uiState.value + val debridSettings = DebridSettingsRepository.snapshot() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.initialize() PluginRepository.getEnabledScrapersForType(type) @@ -234,6 +235,61 @@ object PlayerStreamsRepository { val job = scope.launch { val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds } val installedAddonIds = streamAddons.map { it.addonId }.toSet() + val debridAvailabilityJobs = mutableListOf() + fun emptyStateReason(groups: List, anyLoading: Boolean) = + if (!anyLoading && groups.all { it.streams.isEmpty() }) { + if (groups.all { !it.error.isNullOrBlank() }) { + com.nuvio.app.features.streams.StreamsEmptyStateReason.StreamFetchFailed + } else { + com.nuvio.app.features.streams.StreamsEmptyStateReason.NoStreamsFound + } + } else { + null + } + + fun presentDebridGroup(group: AddonStreamGroup): AddonStreamGroup = + DebridStreamPresentation.apply( + groups = listOf(group), + settings = debridSettings, + ).firstOrNull() ?: group + + fun publishStreamGroup(group: AddonStreamGroup) { + stateFlow.update { current -> + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { currentGroup -> + if (currentGroup.addonId == group.addonId) group else currentGroup + }, + installedOrder = installedAddonOrder, + ) + val anyLoading = updated.any { it.isLoading } + current.copy( + groups = updated, + isAnyLoading = anyLoading, + emptyStateReason = emptyStateReason(updated, anyLoading), + ) + } + } + + fun launchDebridAvailability(group: AddonStreamGroup) { + if (group.addonId !in installedAddonIds || group.streams.isEmpty()) return + + val eligibleGroupIds = setOf(group.addonId) + val checkingGroup = TorboxAvailabilityService.markChecking( + groups = listOf(group), + eligibleGroupIds = eligibleGroupIds, + ).firstOrNull() ?: group + publishStreamGroup(checkingGroup) + + val availabilityJob = launch { + val availabilityGroup = TorboxAvailabilityService.annotateCachedAvailability( + groups = listOf(checkingGroup), + eligibleGroupIds = eligibleGroupIds, + ).firstOrNull() ?: checkingGroup + publishStreamGroup(presentDebridGroup(availabilityGroup)) + } + debridAvailabilityJobs += availabilityJob + } + val addonJobs = pendingStreamAddons.map { addon -> async { val url = buildAddonResourceUrl( @@ -301,64 +357,33 @@ object PlayerStreamsRepository { completions.send(deferred.await()) } } - var debridPreparationLaunched = false repeat(jobs.size) { val result = completions.receive() - stateFlow.update { current -> - val updated = StreamAutoPlaySelector.orderAddonStreams( - groups = current.groups.map { g -> if (g.addonId == result.addonId) result else g }, - installedOrder = installedAddonOrder, - ) - val anyLoading = updated.any { it.isLoading } - current.copy( - groups = updated, - isAnyLoading = anyLoading, - emptyStateReason = if (!anyLoading && updated.all { it.streams.isEmpty() }) { - if (updated.all { !it.error.isNullOrBlank() }) { - com.nuvio.app.features.streams.StreamsEmptyStateReason.StreamFetchFailed - } else { - com.nuvio.app.features.streams.StreamsEmptyStateReason.NoStreamsFound - } - } else null, - ) - } + publishStreamGroup(result) + launchDebridAvailability(result) } - if (!debridPreparationLaunched) { - debridPreparationLaunched = true - val checkingGroups = TorboxAvailabilityService.markChecking( - groups = stateFlow.value.groups, - eligibleGroupIds = installedAddonIds, - ) - stateFlow.update { current -> current.copy(groups = checkingGroups) } - val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability( - groups = stateFlow.value.groups, - eligibleGroupIds = installedAddonIds, - ) - val presentedGroups = DebridStreamPresentation.apply( - groups = availabilityGroups, - settings = DebridSettingsRepository.snapshot(), - ) - stateFlow.update { current -> current.copy(groups = presentedGroups) } - launch { - DirectDebridStreamPreparer.prepare( - streams = stateFlow.value.groups - .filter { it.addonId in installedAddonIds } - .flatMap { it.streams }, - season = season, - episode = episode, - playerSettings = playerSettings, - installedAddonNames = installedAddonNames, - ) { original, prepared -> - stateFlow.update { current -> - current.copy( - groups = DirectDebridStreamPreparer.replacePreparedStream( - groups = current.groups, - original = original, - prepared = prepared, - eligibleGroupIds = installedAddonIds, - ), - ) - } + for (availabilityJob in debridAvailabilityJobs) { + availabilityJob.join() + } + launch { + DirectDebridStreamPreparer.prepare( + streams = stateFlow.value.groups + .filter { it.addonId in installedAddonIds } + .flatMap { it.streams }, + season = season, + episode = episode, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) { original, prepared -> + stateFlow.update { current -> + current.copy( + groups = DirectDebridStreamPreparer.replacePreparedStream( + groups = current.groups, + original = original, + prepared = prepared, + eligibleGroupIds = installedAddonIds, + ), + ) } } } 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 d5ab7be1..e9cd4f2d 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 @@ -110,14 +110,28 @@ object AddonStreamWarmupRepository { val targets = key.addonTargets if (targets.isEmpty()) return emptyList() + val addonIds = targets.map { it.addonId }.toSet() val orderedGroups = coroutineScope { targets.map { target -> async { - fetchAddonStreams( + val group = fetchAddonStreams( target = target, type = key.type, videoId = key.videoId, ) + val eligibleGroupIds = setOf(group.addonId) + val checkingGroup = TorboxAvailabilityService.markChecking( + groups = listOf(group), + eligibleGroupIds = eligibleGroupIds, + ).firstOrNull() ?: group + val availabilityGroup = TorboxAvailabilityService.annotateCachedAvailability( + groups = listOf(checkingGroup), + eligibleGroupIds = eligibleGroupIds, + ).firstOrNull() ?: checkingGroup + DebridStreamPresentation.apply( + groups = listOf(availabilityGroup), + settings = key.settings, + ).firstOrNull() ?: availabilityGroup } }.awaitAll() }.let { groups -> @@ -127,18 +141,7 @@ object AddonStreamWarmupRepository { ) } - val addonIds = targets.map { it.addonId }.toSet() - val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability( - groups = TorboxAvailabilityService.markChecking( - groups = orderedGroups, - eligibleGroupIds = addonIds, - ), - eligibleGroupIds = addonIds, - ) - var preparedGroups = DebridStreamPresentation.apply( - groups = availabilityGroups, - settings = key.settings, - ) + var preparedGroups = orderedGroups PlayerSettingsRepository.ensureLoaded() DirectDebridStreamPreparer.prepare( 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 d86c8220..6ffe3299 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 @@ -230,14 +230,56 @@ object StreamsRepository { val installedAddonNames = installedAddonOrder.toSet() val installedAddonIds = streamAddons.map { it.addonId }.toSet() + val debridAvailabilityJobs = mutableListOf() var autoSelectTriggered = false var timeoutElapsed = false - var debridPreparationLaunched = false fun publishCompletion(completion: StreamLoadCompletion) { if (completions.trySend(completion).isFailure) { log.d { "Ignoring late stream load completion after channel close" } } } + fun presentDebridGroup(group: AddonStreamGroup): AddonStreamGroup = + DebridStreamPresentation.apply( + groups = listOf(group), + settings = debridSettings, + ).firstOrNull() ?: group + + fun publishAddonGroup(group: AddonStreamGroup) { + _uiState.update { current -> + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { currentGroup -> + if (currentGroup.addonId == group.addonId) group else currentGroup + }, + installedOrder = installedAddonOrder, + ) + val anyLoading = updated.any { it.isLoading } + current.copy( + groups = updated, + isAnyLoading = anyLoading, + emptyStateReason = updated.toEmptyStateReason(anyLoading), + ) + } + } + + fun launchDebridAvailability(group: AddonStreamGroup) { + if (group.addonId !in installedAddonIds || group.streams.isEmpty()) return + + val eligibleGroupIds = setOf(group.addonId) + val checkingGroup = TorboxAvailabilityService.markChecking( + groups = listOf(group), + eligibleGroupIds = eligibleGroupIds, + ).firstOrNull() ?: group + publishAddonGroup(checkingGroup) + + val availabilityJob = launch { + val availabilityGroup = TorboxAvailabilityService.annotateCachedAvailability( + groups = listOf(checkingGroup), + eligibleGroupIds = eligibleGroupIds, + ).firstOrNull() ?: checkingGroup + publishAddonGroup(presentDebridGroup(availabilityGroup)) + } + debridAvailabilityJobs += availabilityJob + } val timeoutJob = if (isAutoPlayEnabled) { val timeoutMs = playerSettings.streamAutoPlayTimeoutSeconds * 1_000L @@ -370,20 +412,8 @@ object StreamsRepository { when (val completion = completions.receive()) { is StreamLoadCompletion.Addon -> { val result = completion.group - _uiState.update { current -> - val updated = StreamAutoPlaySelector.orderAddonStreams( - groups = current.groups.map { group -> - if (group.addonId == result.addonId) result else group - }, - installedOrder = installedAddonOrder, - ) - val anyLoading = updated.any { it.isLoading } - current.copy( - groups = updated, - isAnyLoading = anyLoading, - emptyStateReason = updated.toEmptyStateReason(anyLoading), - ) - } + publishAddonGroup(result) + launchDebridAvailability(result) } is StreamLoadCompletion.PluginScraper -> { @@ -431,42 +461,28 @@ object StreamsRepository { } } - if (!debridPreparationLaunched) { - debridPreparationLaunched = true - val checkingGroups = TorboxAvailabilityService.markChecking( - groups = _uiState.value.groups, - eligibleGroupIds = installedAddonIds, - ) - _uiState.update { current -> current.copy(groups = checkingGroups) } - val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability( - groups = _uiState.value.groups, - eligibleGroupIds = installedAddonIds, - ) - val presentedGroups = DebridStreamPresentation.apply( - groups = availabilityGroups, - settings = debridSettings, - ) - _uiState.update { current -> current.copy(groups = presentedGroups) } - launch { - DirectDebridStreamPreparer.prepare( - streams = _uiState.value.groups - .filter { it.addonId in installedAddonIds } - .flatMap { it.streams }, - season = season, - episode = episode, - playerSettings = playerSettings, - installedAddonNames = installedAddonNames, - ) { original, prepared -> - _uiState.update { current -> - current.copy( - groups = DirectDebridStreamPreparer.replacePreparedStream( - groups = current.groups, - original = original, - prepared = prepared, - eligibleGroupIds = installedAddonIds, - ), - ) - } + for (availabilityJob in debridAvailabilityJobs) { + availabilityJob.join() + } + launch { + DirectDebridStreamPreparer.prepare( + streams = _uiState.value.groups + .filter { it.addonId in installedAddonIds } + .flatMap { it.streams }, + season = season, + episode = episode, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) { original, prepared -> + _uiState.update { current -> + current.copy( + groups = DirectDebridStreamPreparer.replacePreparedStream( + groups = current.groups, + original = original, + prepared = prepared, + eligibleGroupIds = installedAddonIds, + ), + ) } } } From 075c5f8f518410b503ffd19cbe925d8dbbf32938 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 21 May 2026 11:39:54 +0530 Subject: [PATCH 04/18] feat(debrid): neutralize architecture --- .../debrid/DebridSettingsStorage.android.kt | 72 +++-- .../composeResources/values-no/strings.xml | 5 +- .../composeResources/values/strings.xml | 8 +- .../app/features/debrid/DebridApiClients.kt | 6 +- .../app/features/debrid/DebridProvider.kt | 28 +- .../app/features/debrid/DebridProviderApis.kt | 198 +++++++++++++ .../app/features/debrid/DebridSettings.kt | 16 +- .../debrid/DebridSettingsRepository.kt | 46 ++-- .../features/debrid/DebridSettingsStorage.kt | 2 + .../debrid/DebridStreamPresentation.kt | 6 +- .../features/debrid/DirectDebridResolver.kt | 260 ++++-------------- .../debrid/DirectDebridStreamPreparer.kt | 2 +- ...e.kt => LocalDebridAvailabilityService.kt} | 63 ++--- .../app/features/debrid/LocalDebridService.kt | 45 +++ .../player/PlayerStreamsRepository.kt | 6 +- .../features/settings/DebridSettingsPage.kt | 50 ++-- .../streams/AddonStreamWarmupRepository.kt | 6 +- .../app/features/streams/StreamsRepository.kt | 6 +- .../app/features/debrid/DebridProviderTest.kt | 22 ++ .../app/features/debrid/DebridSettingsTest.kt | 36 +++ .../debrid/DebridStreamPresentationTest.kt | 6 +- .../debrid/DirectDebridStreamPreparerTest.kt | 4 +- .../debrid/DebridSettingsStorage.ios.kt | 72 +++-- 23 files changed, 594 insertions(+), 371 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt rename composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/{TorboxAvailabilityService.kt => LocalDebridAvailabilityService.kt} (60%) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridService.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridSettingsTest.kt 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" + } + } } From 20f8717cf0d3900bb837eab5346acf2d2c7e0d7e Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 21 May 2026 12:07:29 +0530 Subject: [PATCH 05/18] feat(torbox): link device --- .../composeResources/values-no/strings.xml | 17 +- .../composeResources/values/strings.xml | 17 +- .../app/features/debrid/DebridApiClients.kt | 43 +++ .../app/features/debrid/DebridApiModels.kt | 21 ++ .../app/features/debrid/DebridProvider.kt | 7 + .../app/features/debrid/DebridProviderApis.kt | 72 +++++ .../features/settings/DebridSettingsPage.kt | 276 +++++++++++++++++- .../app/features/debrid/DebridProviderTest.kt | 2 + 8 files changed, 445 insertions(+), 10 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml index 57e7b185..0fa7a5bd 100644 --- a/composeApp/src/commonMain/composeResources/values-no/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml @@ -593,12 +593,25 @@ Debrid-stΓΈtte er eksperimentell og kan endres eller fjernes senere. Aktiver kilder Vis spillbare resultater fra tilkoblede kontoer. - Legg til en API-nΓΈkkel fΓΈrst. + Koble til en Debrid-konto fΓΈrst. Konto Koble til %1$s-kontoen din. + Koble til %1$s-kontoen din i nettleseren. %1$s API-nΓΈkkel Skriv inn API-nΓΈkkelen din for %1$s. Skriv inn %1$s API-nΓΈkkel + Tilkoblet + Koble til %1$s + Koble fra %1$s + Koble fra + %1$s er koblet til pΓ₯ denne enheten. + Starter sikker innlogging... + Γ…pne lenken og skriv inn denne koden for Γ₯ godkjenne Nuvio. + Kode kopiert. + Γ…pne lenke + Venter pΓ₯ godkjenning... + Kunne ikke starte innlogging. + Denne koden er utlΓΈpt. PrΓΈv igjen. Umiddelbar avspilling Forbered lenker LΓΈs fΓΈrste kilder fΓΈr avspilling starter. @@ -1147,7 +1160,7 @@ Gjenoppta fra %1$s STØRRELSE %1$s Denne strΓΈmtypen stΓΈttes ikke - Legg til en Debrid API-nΓΈkkel i Innstillinger. + Koble til en Debrid-konto i Innstillinger. Dette Debrid-resultatet er utgΓ₯tt. Oppdaterer strΓΈmmer. Kunne ikke lΓΈse denne Debrid-strΓΈmmen. Kunne ikke Γ₯pne ekstern avspiller diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 210c3fb9..01a8c8a7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -594,13 +594,26 @@ Debrid support is experimental and may be kept, changed, or removed later. Enable sources Show playable results from connected accounts. - Add an API key first. + Connect a Debrid account first. Account Connect your %1$s account. + Link your %1$s account in the browser. %1$s API Key Enter your %1$s API key. Enter %1$s API key Not set + Connected + Connect %1$s + Disconnect %1$s + Disconnect + %1$s is connected on this device. + Starting secure sign-in... + Open the link and enter this code to approve Nuvio. + Code copied. + Open link + Waiting for approval... + Could not start sign-in. + This code expired. Try again. Instant Playback Prepare links Resolve the first sources before playback starts. @@ -1152,7 +1165,7 @@ Resume from %1$s SIZE %1$s This stream type is not supported - Add a Debrid API key in Settings. + Connect a Debrid account in Settings. Not cached on Torbox. This Debrid result expired. Refreshing streams. Could not resolve this Debrid stream. 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 fb67eadb..47ddac07 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 @@ -28,6 +28,26 @@ internal object DebridApiJson { internal object TorboxApiClient { private const val BASE_URL = "https://api.torbox.app" + suspend fun startDeviceAuthorization( + appName: String, + ): DebridApiResponse> = + requestWithoutAuth( + method = "GET", + url = "$BASE_URL/v1/api/user/auth/device/start?${ + queryString("app" to appName) + }", + ) + + suspend fun redeemDeviceAuthorization( + deviceCode: String, + ): DebridApiResponse> = + requestWithoutAuth( + method = "POST", + url = "$BASE_URL/v1/api/user/auth/device/token", + body = DebridApiJson.json.encodeToString(TorboxDeviceTokenRequestDto(deviceCode = deviceCode)), + contentType = "application/json", + ) + suspend fun validateApiKey(apiKey: String): Boolean = getUser(apiKey.trim()).status in 200..299 @@ -139,6 +159,29 @@ internal object TorboxApiClient { ) } + private suspend inline fun requestWithoutAuth( + method: String, + url: String, + body: String = "", + contentType: String? = null, + ): DebridApiResponse { + val headers = listOfNotNull( + contentType?.let { "Content-Type" to it }, + "Accept" to "application/json", + ).toMap() + val response = httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + ) + return DebridApiResponse( + status = response.status, + body = response.decodeBody(), + rawBody = response.body, + ) + } + private fun authHeaders(apiKey: String): Map = mapOf("Authorization" to "Bearer $apiKey") } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt index ff74b44d..909fd46a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt @@ -49,6 +49,27 @@ internal data class TorboxCheckCachedRequestDto( val hashes: List, ) +@Serializable +internal data class TorboxDeviceAuthorizationDto( + @SerialName("device_code") val deviceCode: String? = null, + val code: String? = null, + @SerialName("verification_url") val verificationUrl: String? = null, + @SerialName("friendly_verification_url") val friendlyVerificationUrl: String? = null, + val interval: Int? = null, + @SerialName("expires_at") val expiresAt: String? = null, +) + +@Serializable +internal data class TorboxDeviceTokenRequestDto( + @SerialName("device_code") val deviceCode: String, +) + +@Serializable +internal data class TorboxDeviceTokenDto( + @SerialName("access_token") val accessToken: String? = null, + @SerialName("token_type") val tokenType: String? = null, +) + @Serializable internal data class TorboxCachedItemDto( val name: String? = null, 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 7176188d..ac6aa352 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 authMethod: DebridProviderAuthMethod = DebridProviderAuthMethod.ApiKey, val capabilities: Set = emptySet(), ) @@ -19,6 +20,11 @@ enum class DebridProviderCapability { LocalTorrentResolve, } +enum class DebridProviderAuthMethod { + ApiKey, + DeviceCode, +} + object DebridProviders { const val TORBOX_ID = "torbox" const val REAL_DEBRID_ID = "realdebrid" @@ -27,6 +33,7 @@ object DebridProviders { id = TORBOX_ID, displayName = "Torbox", shortName = "TB", + authMethod = DebridProviderAuthMethod.DeviceCode, capabilities = setOf( DebridProviderCapability.ClientResolve, DebridProviderCapability.LocalTorrentCacheCheck, 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 index b04c3538..b3bd1ac1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt @@ -9,6 +9,11 @@ internal interface DebridProviderApi { suspend fun validateApiKey(apiKey: String): Boolean + suspend fun startDeviceAuthorization(appName: String): DebridDeviceAuthorization? = null + + suspend fun redeemDeviceAuthorization(deviceCode: String): DebridDeviceAuthorizationTokenResult = + DebridDeviceAuthorizationTokenResult.Unsupported + suspend fun resolveClientStream( stream: StreamItem, apiKey: String, @@ -29,6 +34,24 @@ internal object DebridProviderApis { } } +internal data class DebridDeviceAuthorization( + val providerId: String, + val deviceCode: String, + val userCode: String, + val verificationUrl: String, + val friendlyVerificationUrl: String, + val intervalSeconds: Int, + val expiresAt: String?, +) + +internal sealed interface DebridDeviceAuthorizationTokenResult { + data class Authorized(val accessToken: String) : DebridDeviceAuthorizationTokenResult + data object Pending : DebridDeviceAuthorizationTokenResult + data object Expired : DebridDeviceAuthorizationTokenResult + data object Unsupported : DebridDeviceAuthorizationTokenResult + data class Failed(val message: String?) : DebridDeviceAuthorizationTokenResult +} + private class TorboxDebridProviderApi( private val fileSelector: TorboxFileSelector = TorboxFileSelector(), ) : DebridProviderApi { @@ -37,6 +60,55 @@ private class TorboxDebridProviderApi( override suspend fun validateApiKey(apiKey: String): Boolean = TorboxApiClient.validateApiKey(apiKey) + override suspend fun startDeviceAuthorization(appName: String): DebridDeviceAuthorization? { + val response = TorboxApiClient.startDeviceAuthorization(appName = appName) + val data = response.body?.takeIf { response.isSuccessful && it.success != false }?.data + ?: return null + val deviceCode = data.deviceCode?.takeIf { it.isNotBlank() } ?: return null + val userCode = data.code?.takeIf { it.isNotBlank() } ?: return null + val verificationUrl = data.verificationUrl?.takeIf { it.isNotBlank() } ?: return null + return DebridDeviceAuthorization( + providerId = provider.id, + deviceCode = deviceCode, + userCode = userCode, + verificationUrl = verificationUrl, + friendlyVerificationUrl = data.friendlyVerificationUrl?.takeIf { it.isNotBlank() } + ?: verificationUrl, + intervalSeconds = data.interval?.coerceAtLeast(1) ?: 5, + expiresAt = data.expiresAt?.takeIf { it.isNotBlank() }, + ) + } + + override suspend fun redeemDeviceAuthorization(deviceCode: String): DebridDeviceAuthorizationTokenResult { + val normalized = deviceCode.trim() + if (normalized.isBlank()) return DebridDeviceAuthorizationTokenResult.Failed(null) + val response = TorboxApiClient.redeemDeviceAuthorization(deviceCode = normalized) + val envelope = response.body + val accessToken = envelope + ?.takeIf { response.isSuccessful && it.success != false } + ?.data + ?.accessToken + ?.takeIf { it.isNotBlank() } + if (accessToken != null) { + return DebridDeviceAuthorizationTokenResult.Authorized(accessToken) + } + val message = listOfNotNull(envelope?.error, envelope?.detail, response.rawBody) + .joinToString(" ") + .lowercase() + return when { + message.contains("pending") || message.contains("not authorized") -> + DebridDeviceAuthorizationTokenResult.Pending + message.contains("expired") -> + DebridDeviceAuthorizationTokenResult.Expired + response.status == 404 || response.status == 409 || response.status == 425 -> + DebridDeviceAuthorizationTokenResult.Pending + response.status == 410 -> + DebridDeviceAuthorizationTokenResult.Expired + else -> + DebridDeviceAuthorizationTokenResult.Failed(envelope?.detail ?: envelope?.error) + } + } + override suspend fun resolveClientStream( stream: StreamItem, apiKey: String, 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 43633e1e..93db2c73 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 @@ -21,6 +21,7 @@ import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -30,6 +31,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -38,10 +40,18 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.nuvio.app.features.debrid.DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT import com.nuvio.app.features.debrid.DebridCredentialValidator +import com.nuvio.app.features.debrid.DebridDeviceAuthorization +import com.nuvio.app.features.debrid.DebridDeviceAuthorizationTokenResult +import com.nuvio.app.features.debrid.DebridProvider +import com.nuvio.app.features.debrid.DebridProviderApis +import com.nuvio.app.features.debrid.DebridProviderAuthMethod import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettings import com.nuvio.app.features.debrid.DebridSettingsRepository @@ -58,16 +68,30 @@ import com.nuvio.app.features.debrid.DebridStreamSortDirection import com.nuvio.app.features.debrid.DebridStreamSortKey import com.nuvio.app.features.debrid.DebridStreamVisualTag import kotlinx.coroutines.launch +import kotlinx.coroutines.delay import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.action_clear +import nuvio.composeapp.generated.resources.action_retry 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_connected +import nuvio.composeapp.generated.resources.settings_debrid_connect_provider +import nuvio.composeapp.generated.resources.settings_debrid_disconnect_provider +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_code_copied +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_connected +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_expired +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_failed +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_instructions +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_open +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_starting +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_waiting import nuvio.composeapp.generated.resources.settings_debrid_dialog_placeholder import nuvio.composeapp.generated.resources.settings_debrid_dialog_subtitle import nuvio.composeapp.generated.resources.settings_debrid_dialog_title +import nuvio.composeapp.generated.resources.settings_debrid_disconnect import nuvio.composeapp.generated.resources.settings_debrid_enable import nuvio.composeapp.generated.resources.settings_debrid_enable_description import nuvio.composeapp.generated.resources.settings_debrid_experimental_notice @@ -86,6 +110,7 @@ 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_description +import nuvio.composeapp.generated.resources.settings_debrid_provider_device_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,8 +152,11 @@ internal fun LazyListScope.debridSettingsContent( } item { - var activeProviderId by rememberSaveable { mutableStateOf(null) } + var activeApiKeyProviderId by rememberSaveable { mutableStateOf(null) } + var activeDeviceAuthProviderId by rememberSaveable { mutableStateOf(null) } val providers = remember { DebridProviders.visible() } + val notSetLabel = stringResource(Res.string.settings_debrid_not_set) + val connectedLabel = stringResource(Res.string.settings_debrid_connected) SettingsSection( title = stringResource(Res.string.settings_debrid_section_providers), @@ -142,16 +170,42 @@ internal fun LazyListScope.debridSettingsContent( 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)), + description = if (provider.authMethod == DebridProviderAuthMethod.DeviceCode) { + stringResource(Res.string.settings_debrid_provider_device_description, provider.displayName) + } else { + stringResource(Res.string.settings_debrid_provider_description, provider.displayName) + }, + value = providerCredentialStatus( + provider = provider, + credential = settings.apiKeyFor(provider.id), + notSetLabel = notSetLabel, + connectedLabel = connectedLabel, + ), enabled = true, - onClick = { activeProviderId = provider.id }, + onClick = { + when (provider.authMethod) { + DebridProviderAuthMethod.DeviceCode -> activeDeviceAuthProviderId = provider.id + DebridProviderAuthMethod.ApiKey -> activeApiKeyProviderId = provider.id + } + }, ) } } } - activeProviderId + activeDeviceAuthProviderId + ?.let(DebridProviders::byId) + ?.let { provider -> + DebridDeviceAuthDialog( + provider = provider, + currentValue = settings.apiKeyFor(provider.id), + onConnected = { token -> DebridSettingsRepository.setProviderApiKey(provider.id, token) }, + onDisconnect = { DebridSettingsRepository.setProviderApiKey(provider.id, "") }, + onDismiss = { activeDeviceAuthProviderId = null }, + ) + } + + activeApiKeyProviderId ?.let(DebridProviders::byId) ?.let { provider -> DebridApiKeyDialog( @@ -161,7 +215,7 @@ internal fun LazyListScope.debridSettingsContent( 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 }, + onDismiss = { activeApiKeyProviderId = null }, ) } } @@ -1182,6 +1236,205 @@ private enum class DebridStreamPicker { EXCLUDED_RELEASE_GROUPS, } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridDeviceAuthDialog( + provider: DebridProvider, + currentValue: String, + onConnected: (String) -> Unit, + onDisconnect: () -> Unit, + onDismiss: () -> Unit, +) { + val uriHandler = LocalUriHandler.current + val clipboardManager = LocalClipboardManager.current + val isConnected = currentValue.isNotBlank() + var restartNonce by rememberSaveable(provider.id) { mutableStateOf(0) } + var session by remember(provider.id, restartNonce, isConnected) { mutableStateOf(null) } + var isStarting by remember(provider.id, restartNonce, isConnected) { mutableStateOf(!isConnected) } + var isPolling by remember(provider.id, restartNonce, isConnected) { mutableStateOf(false) } + var statusMessage by remember(provider.id, restartNonce, isConnected) { mutableStateOf(null) } + + val startingMessage = stringResource(Res.string.settings_debrid_device_auth_starting) + val waitingMessage = stringResource(Res.string.settings_debrid_device_auth_waiting) + val failedMessage = stringResource(Res.string.settings_debrid_device_auth_failed) + val expiredMessage = stringResource(Res.string.settings_debrid_device_auth_expired) + val codeCopiedMessage = stringResource(Res.string.settings_debrid_device_auth_code_copied) + + LaunchedEffect(provider.id, restartNonce, isConnected) { + if (isConnected) { + isStarting = false + isPolling = false + statusMessage = null + session = null + return@LaunchedEffect + } + isStarting = true + isPolling = false + statusMessage = null + session = runCatching { + DebridProviderApis.apiFor(provider.id)?.startDeviceAuthorization("Nuvio") + }.getOrNull() + isStarting = false + statusMessage = if (session == null) failedMessage else waitingMessage + } + + LaunchedEffect(session?.deviceCode, restartNonce, isConnected) { + if (isConnected) return@LaunchedEffect + val activeSession = session ?: return@LaunchedEffect + while (true) { + delay(activeSession.intervalSeconds.coerceAtLeast(1) * 1_000L) + isPolling = true + val result = runCatching { + DebridProviderApis.apiFor(provider.id) + ?.redeemDeviceAuthorization(activeSession.deviceCode) + ?: DebridDeviceAuthorizationTokenResult.Unsupported + }.getOrElse { + DebridDeviceAuthorizationTokenResult.Failed(it.message) + } + isPolling = false + when (result) { + is DebridDeviceAuthorizationTokenResult.Authorized -> { + onConnected(result.accessToken) + onDismiss() + return@LaunchedEffect + } + + DebridDeviceAuthorizationTokenResult.Pending -> { + statusMessage = waitingMessage + } + + DebridDeviceAuthorizationTokenResult.Expired -> { + statusMessage = expiredMessage + return@LaunchedEffect + } + + is DebridDeviceAuthorizationTokenResult.Failed, + DebridDeviceAuthorizationTokenResult.Unsupported -> { + statusMessage = failedMessage + return@LaunchedEffect + } + } + } + } + + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface( + title = stringResource( + if (isConnected) Res.string.settings_debrid_disconnect_provider else Res.string.settings_debrid_connect_provider, + provider.displayName, + ), + ) { + if (isConnected) { + Text( + text = stringResource(Res.string.settings_debrid_device_auth_connected, provider.displayName), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (isStarting) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + Text( + text = startingMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + session?.let { activeSession -> + Text( + text = stringResource(Res.string.settings_debrid_device_auth_instructions), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { + clipboardManager.setText(AnnotatedString(activeSession.userCode)) + statusMessage = codeCopiedMessage + }, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = activeSession.userCode, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = activeSession.friendlyVerificationUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + statusMessage?.let { message -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isPolling) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(16.dp)) + } + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = if (message == failedMessage || message == expiredMessage) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_cancel)) + } + if (isConnected) { + Button( + onClick = { + onDisconnect() + onDismiss() + }, + ) { + Text(stringResource(Res.string.settings_debrid_disconnect)) + } + } + if (!isConnected && !isStarting && session == null) { + TextButton(onClick = { restartNonce += 1 }) { + Text(stringResource(Res.string.action_retry)) + } + } + if (!isConnected) session?.let { activeSession -> + Button( + onClick = { + runCatching { uriHandler.openUri(activeSession.verificationUrl) } + .onFailure { statusMessage = failedMessage } + }, + enabled = !isStarting, + ) { + Text(stringResource(Res.string.settings_debrid_device_auth_open)) + } + } + } + } + } +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun DebridApiKeyDialog( @@ -1287,6 +1540,17 @@ private fun maskDebridApiKey(key: String, notSetLabel: String): String { return if (trimmed.length <= 4) "****" else "******${trimmed.takeLast(4)}" } +private fun providerCredentialStatus( + provider: DebridProvider, + credential: String, + notSetLabel: String, + connectedLabel: String, +): String = + when (provider.authMethod) { + DebridProviderAuthMethod.DeviceCode -> if (credential.isBlank()) notSetLabel else connectedLabel + DebridProviderAuthMethod.ApiKey -> maskDebridApiKey(credential, notSetLabel) + } + @Composable private fun DebridInfoRow( isTablet: Boolean, 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 index a8499b58..2f0eccd2 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt @@ -7,6 +7,7 @@ import kotlin.test.assertTrue class DebridProviderTest { @Test fun `torbox exposes local addon capabilities`() { + assertTrue(DebridProviders.Torbox.authMethod == DebridProviderAuthMethod.DeviceCode) assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.ClientResolve)) assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentCacheCheck)) assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentResolve)) @@ -14,6 +15,7 @@ class DebridProviderTest { @Test fun `real debrid stays hidden from local addon capability paths`() { + assertTrue(DebridProviders.RealDebrid.authMethod == DebridProviderAuthMethod.ApiKey) assertFalse(DebridProviders.RealDebrid.visibleInUi) assertTrue(DebridProviders.RealDebrid.supports(DebridProviderCapability.ClientResolve)) assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentCacheCheck)) From 8eb9f2c83baa9edfc5bd1db85b2b387d8bde713b Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 21 May 2026 12:56:53 +0530 Subject: [PATCH 06/18] feat: Add cloud library support --- .../composeResources/values/strings.xml | 22 + .../commonMain/kotlin/com/nuvio/app/App.kt | 57 ++ .../app/features/cloud/CloudLibraryModels.kt | 75 ++ .../features/cloud/CloudLibraryProviderApi.kt | 29 + .../features/cloud/CloudLibraryRepository.kt | 134 +++ .../cloud/TorboxCloudLibraryProviderApi.kt | 169 ++++ .../app/features/debrid/DebridApiClients.kt | 81 ++ .../app/features/debrid/DebridApiModels.kt | 27 + .../app/features/debrid/DebridProvider.kt | 2 + .../app/features/library/LibraryScreen.kt | 849 ++++++++++++++++-- .../app/features/settings/SettingsScreen.kt | 11 + .../features/cloud/CloudLibraryStoreTest.kt | 110 +++ .../TorboxCloudLibraryProviderApiTest.kt | 131 +++ .../app/features/debrid/DebridProviderTest.kt | 2 + 14 files changed, 1647 insertions(+), 52 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 01a8c8a7..528d11ab 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1320,11 +1320,33 @@ Your library is empty Couldn't load library Other + Cloud + Saved Library Connect Trakt and save titles to your watchlist or personal lists. Your Trakt library is empty Couldn't load Trakt library Trakt Library + Connect account + Connect Torbox in Debrid settings to browse playable files from your cloud library. + No cloud account connected + No playable cloud files match the current filters. + Nothing here yet + Choose a file to play + Couldn't load %1$s cloud library + This item does not expose a playable video file. + No playable files + No playable files + Couldn't play this cloud file. + Play file + %1$d playable files + All + Refresh cloud library + Ready to play + All + Torrents + Usenet + Web Anime Channels Movies diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 3d5e7c37..6cd778a3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -106,6 +106,10 @@ import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL +import com.nuvio.app.features.cloud.CloudLibraryFile +import com.nuvio.app.features.cloud.CloudLibraryItem +import com.nuvio.app.features.cloud.CloudLibraryPlaybackResult +import com.nuvio.app.features.cloud.CloudLibraryRepository import com.nuvio.app.features.debrid.DirectDebridPlayableResult import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver import com.nuvio.app.features.debrid.toastMessage @@ -565,6 +569,7 @@ private fun MainAppContent( var showExitConfirmation by rememberSaveable { mutableStateOf(false) } var selectedPosterActionTarget by remember { mutableStateOf(null) } var selectedContinueWatchingForActions by remember { mutableStateOf(null) } + var requestedSettingsPageName by rememberSaveable { mutableStateOf(null) } var showLibraryListPicker by remember { mutableStateOf(false) } var pickerItem by remember { mutableStateOf(null) } var pickerTitle by remember { mutableStateOf("") } @@ -601,6 +606,7 @@ private fun MainAppContent( val externalPlayerNotConfiguredText = stringResource(Res.string.external_player_not_configured) val externalPlayerUnavailableText = stringResource(Res.string.external_player_unavailable) val externalPlayerFailedText = stringResource(Res.string.external_player_failed) + val cloudLibraryPlayFailedText = stringResource(Res.string.cloud_library_play_failed) val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT var initialHomeReady by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } @@ -1157,6 +1163,45 @@ private fun MainAppContent( ) }, onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, + onCloudFilePlay = { item, file -> + coroutineScope.launch { + when ( + val resolved = CloudLibraryRepository.resolvePlayback( + item = item, + file = file, + ) + ) { + is CloudLibraryPlaybackResult.Success -> { + val playbackTitle = file.name.ifBlank { item.name } + val playerLaunch = PlayerLaunch( + title = playbackTitle, + sourceUrl = resolved.url, + streamTitle = playbackTitle, + streamSubtitle = item.name.takeIf { it != playbackTitle }, + providerName = item.providerName, + providerAddonId = "cloud:${item.providerId}", + contentType = "cloud", + videoId = "${item.stableKey}:${file.stableKey}", + parentMetaId = item.stableKey, + parentMetaType = "cloud", + ) + if (playerSettingsUiState.externalPlayerEnabled) { + openExternalPlayback(playerLaunch) + return@launch + } + val launchId = PlayerLaunchStore.put(playerLaunch) + navController.navigate(PlayerRoute(launchId = launchId)) + } + else -> { + NuvioToastController.show(cloudLibraryPlayFailedText) + } + } + } + }, + onConnectCloudClick = { + requestedSettingsPageName = "Debrid" + selectedTab = AppScreenTab.Settings + }, onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingLongPress = onContinueWatchingLongPress, onSwitchProfile = onSwitchProfile, @@ -1191,6 +1236,10 @@ private fun MainAppContent( onFolderClick = { collectionId, folderId -> navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId)) }, + requestedSettingsPageName = requestedSettingsPageName, + onRequestedSettingsPageConsumed = { + requestedSettingsPageName = null + }, onInitialHomeContentRendered = { initialHomeReady = true }, ) } @@ -2282,6 +2331,8 @@ private fun AppTabHost( onLibraryPosterClick: ((LibraryItem) -> Unit)? = null, onLibraryPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null, onLibrarySectionViewAllClick: ((LibrarySection) -> Unit)? = null, + onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null, + onConnectCloudClick: (() -> Unit)? = null, onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null, onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null, onSwitchProfile: (() -> Unit)? = null, @@ -2297,6 +2348,8 @@ private fun AppTabHost( onCheckForUpdatesClick: (() -> Unit)? = null, onCollectionsSettingsClick: () -> Unit = {}, onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null, + requestedSettingsPageName: String? = null, + onRequestedSettingsPageConsumed: () -> Unit = {}, onInitialHomeContentRendered: () -> Unit = {}, ) { val tabStateHolder = rememberSaveableStateHolder() @@ -2336,6 +2389,8 @@ private fun AppTabHost( onPosterClick = onLibraryPosterClick, onPosterLongClick = onLibraryPosterLongClick, onSectionViewAllClick = onLibrarySectionViewAllClick, + onCloudFilePlay = onCloudFilePlay, + onConnectCloudClick = onConnectCloudClick, ) } @@ -2343,6 +2398,8 @@ private fun AppTabHost( SettingsScreen( modifier = Modifier.fillMaxSize(), rootActionRequests = settingsRootActionRequests, + requestedPageName = requestedSettingsPageName, + onRequestedPageConsumed = onRequestedSettingsPageConsumed, rootActionsEnabled = rootActionsEnabled, onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenSettingsClick, 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 new file mode 100644 index 00000000..635192ee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt @@ -0,0 +1,75 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProvider + +enum class CloudLibraryItemType { + Torrent, + Usenet, + WebDownload, +} + +data class CloudLibraryFile( + val id: String?, + val name: String, + val sizeBytes: Long? = null, + val mimeType: String? = null, + val playable: Boolean = true, +) { + val stableKey: String + get() = id ?: name +} + +data class CloudLibraryItem( + val providerId: String, + val providerName: String, + val id: String, + val type: CloudLibraryItemType, + val name: String, + val status: String? = null, + val sizeBytes: Long? = null, + val progressFraction: Float? = null, + val files: List = emptyList(), +) { + val stableKey: String + get() = "$providerId:${type.name}:$id" + + val playableFiles: List + get() = files.filter { it.playable } +} + +data class CloudLibraryProviderState( + val provider: DebridProvider, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val items: List = emptyList(), +) { + val providerId: String + get() = provider.id + + val providerName: String + get() = provider.displayName +} + +data class CloudLibraryUiState( + val isLoaded: Boolean = false, + val isRefreshing: Boolean = false, + val providers: List = emptyList(), +) { + val items: List + get() = providers.flatMap { it.items } + + val hasConnectedProvider: Boolean + get() = providers.isNotEmpty() +} + +sealed interface CloudLibraryPlaybackResult { + data class Success( + val url: String, + val filename: String? = null, + val videoSizeBytes: Long? = null, + ) : CloudLibraryPlaybackResult + + data object MissingCredentials : CloudLibraryPlaybackResult + data object NotPlayable : CloudLibraryPlaybackResult + data class Failed(val message: String? = null) : CloudLibraryPlaybackResult +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt new file mode 100644 index 00000000..f87ca151 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt @@ -0,0 +1,29 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProvider +import com.nuvio.app.features.debrid.DebridProviders + +internal interface CloudLibraryProviderApi { + val provider: DebridProvider + + suspend fun listItems(apiKey: String): Result> + + suspend fun resolvePlayback( + apiKey: String, + item: CloudLibraryItem, + file: CloudLibraryFile, + ): CloudLibraryPlaybackResult +} + +internal object CloudLibraryProviderApis { + private val registered = listOf( + TorboxCloudLibraryProviderApi(), + ) + + fun all(): List = registered + + fun apiFor(providerId: String?): CloudLibraryProviderApi? { + val normalized = DebridProviders.byId(providerId)?.id ?: return null + return registered.firstOrNull { it.provider.id == normalized } + } +} 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 new file mode 100644 index 00000000..0a2e63fe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt @@ -0,0 +1,134 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProviderCapability +import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.debrid.DebridServiceCredential +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.supports +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal class CloudLibraryStore( + private val credentialsProvider: suspend () -> List, + private val providerApis: List, +) { + suspend fun refresh(): CloudLibraryUiState { + val credentials = credentialsProvider() + .filter { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) } + + val providerStates = credentials.map { credential -> + val api = providerApis.firstOrNull { it.provider.id == credential.provider.id } + if (api == null) { + return@map CloudLibraryProviderState( + provider = credential.provider, + errorMessage = "Cloud library is not available for ${credential.provider.displayName}.", + ) + } + + api.listItems(credential.apiKey) + .fold( + onSuccess = { items -> + CloudLibraryProviderState( + provider = credential.provider, + items = items, + ) + }, + onFailure = { error -> + CloudLibraryProviderState( + provider = credential.provider, + errorMessage = error.message, + ) + }, + ) + } + + return CloudLibraryUiState( + isLoaded = true, + isRefreshing = false, + providers = providerStates, + ) + } + + suspend fun resolvePlayback( + item: CloudLibraryItem, + file: CloudLibraryFile, + ): CloudLibraryPlaybackResult { + if (!file.playable) return CloudLibraryPlaybackResult.NotPlayable + val credential = credentialsProvider() + .firstOrNull { credential -> credential.provider.id == item.providerId } + ?: return CloudLibraryPlaybackResult.MissingCredentials + val api = providerApis.firstOrNull { it.provider.id == item.providerId } + ?: return CloudLibraryPlaybackResult.Failed() + return api.resolvePlayback( + apiKey = credential.apiKey, + item = item, + file = file, + ) + } +} + +object CloudLibraryRepository { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val store = CloudLibraryStore( + credentialsProvider = { + DebridSettingsRepository.ensureLoaded() + DebridProviders.configuredServices(DebridSettingsRepository.snapshot()) + }, + providerApis = CloudLibraryProviderApis.all(), + ) + private val _uiState = MutableStateFlow(CloudLibraryUiState()) + private var loadedConnectionKeys: List = emptyList() + val uiState = _uiState.asStateFlow() + + fun ensureLoaded() { + DebridSettingsRepository.ensureLoaded() + val current = _uiState.value + if (current.isRefreshing) return + val connectedKeys = connectedCloudConnectionKeys() + if (!current.isLoaded || connectedKeys != loadedConnectionKeys) { + refresh() + } + } + + fun refresh() { + _uiState.update { current -> + current.copy( + isRefreshing = true, + providers = current.providers.map { it.copy(isLoading = true, errorMessage = null) }, + ) + } + scope.launch { + val refreshed = store.refresh() + loadedConnectionKeys = connectedCloudConnectionKeys() + _uiState.value = refreshed + } + } + + suspend fun resolvePlayback( + item: CloudLibraryItem, + file: CloudLibraryFile, + ): CloudLibraryPlaybackResult = + store.resolvePlayback(item, file) + + private fun connectedCloudCredentials(): List = + DebridProviders.configuredServices(DebridSettingsRepository.snapshot()) + .filter { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) } + + private fun connectedCloudConnectionKeys(): List = + connectedCloudCredentials().map { credential -> + CloudConnectionKey( + providerId = credential.provider.id, + apiKeyHash = credential.apiKey.hashCode(), + ) + }.sortedBy { it.providerId } + + private data class CloudConnectionKey( + val providerId: String, + val apiKeyHash: Int, + ) +} 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 new file mode 100644 index 00000000..43007dce --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt @@ -0,0 +1,169 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.debrid.TorboxApiClient +import com.nuvio.app.features.debrid.TorboxCloudFileDto +import com.nuvio.app.features.debrid.TorboxCloudItemDto +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.JsonPrimitive + +internal class TorboxCloudLibraryProviderApi : CloudLibraryProviderApi { + override val provider = DebridProviders.Torbox + + override suspend fun listItems(apiKey: String): Result> = + runCatching { + val torrents = TorboxApiClient.listCloudTorrents(apiKey).itemsOrThrow(CloudLibraryItemType.Torrent) + val usenet = TorboxApiClient.listCloudUsenet(apiKey).itemsOrThrow(CloudLibraryItemType.Usenet) + val web = TorboxApiClient.listCloudWebDownloads(apiKey).itemsOrThrow(CloudLibraryItemType.WebDownload) + torrents + usenet + web + } + + override suspend fun resolvePlayback( + apiKey: String, + item: CloudLibraryItem, + file: CloudLibraryFile, + ): CloudLibraryPlaybackResult { + if (!file.playable) return CloudLibraryPlaybackResult.NotPlayable + + return try { + val response = when (item.type) { + CloudLibraryItemType.Torrent -> TorboxApiClient.requestCloudTorrentDownloadLink( + apiKey = apiKey, + torrentId = item.id, + fileId = file.id, + ) + CloudLibraryItemType.Usenet -> TorboxApiClient.requestCloudUsenetDownloadLink( + apiKey = apiKey, + usenetId = item.id, + fileId = file.id, + ) + CloudLibraryItemType.WebDownload -> TorboxApiClient.requestCloudWebDownloadLink( + apiKey = apiKey, + webId = item.id, + fileId = file.id, + ) + } + if (!response.isSuccessful || response.body?.success == false) { + return CloudLibraryPlaybackResult.Failed(response.body?.detail ?: response.body?.error) + } + val url = response.body?.data?.takeIf { it.isNotBlank() } + ?: return CloudLibraryPlaybackResult.Failed() + CloudLibraryPlaybackResult.Success( + url = url, + filename = file.name.takeIf { it.isNotBlank() }, + videoSizeBytes = file.sizeBytes, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + CloudLibraryPlaybackResult.Failed(error.message) + } + } + + private fun com.nuvio.app.features.debrid.DebridApiResponse>>.itemsOrThrow( + type: CloudLibraryItemType, + ): List { + if (!isSuccessful || body?.success == false) { + throw IllegalStateException(body?.detail ?: body?.error ?: rawBody.takeIf { it.isNotBlank() }) + } + return body?.data.orEmpty().mapNotNull { dto -> + dto.toCloudLibraryItem( + providerId = provider.id, + providerName = provider.displayName, + type = type, + ) + } + } +} + +internal fun TorboxCloudItemDto.toCloudLibraryItem( + providerId: String, + providerName: String, + type: CloudLibraryItemType, +): CloudLibraryItem? { + val itemId = id.scalarString() + ?: hash?.trim()?.takeIf { it.isNotBlank() } + ?: return null + val mappedFiles = files.orEmpty().mapNotNull { file -> + file.toCloudLibraryFile() + } + val filesSize = mappedFiles + .mapNotNull { it.sizeBytes } + .takeIf { it.isNotEmpty() } + ?.sum() + return CloudLibraryItem( + providerId = providerId, + providerName = providerName, + id = itemId, + type = type, + name = name?.trim()?.takeIf { it.isNotBlank() } ?: itemId, + status = listOf(status, downloadState, state) + .firstNonBlank(), + sizeBytes = size ?: totalSize ?: filesSize, + progressFraction = listOfNotNull(progress, downloadProgress).firstOrNull()?.toProgressFraction(), + files = mappedFiles, + ) +} + +internal fun TorboxCloudFileDto.toCloudLibraryFile(): CloudLibraryFile? { + val name = listOf(name, shortName, absolutePath) + .firstNonBlank() + ?: return null + val fileId = id.scalarString() + val mime = listOf(mimeType, mimeTypeAlt).firstNonBlank() + return CloudLibraryFile( + id = fileId, + name = name, + sizeBytes = size, + mimeType = mime, + playable = fileId != null && isPlayableCloudFile(name = name, mimeType = mime), + ) +} + +internal fun torboxRequestIdParameterName(type: CloudLibraryItemType): String = + when (type) { + CloudLibraryItemType.Torrent -> "torrent_id" + CloudLibraryItemType.Usenet -> "usenet_id" + CloudLibraryItemType.WebDownload -> "web_id" + } + +private fun List.firstNonBlank(): String? = + firstOrNull { !it.isNullOrBlank() }?.trim() + +private fun kotlinx.serialization.json.JsonElement?.scalarString(): String? { + val primitive = this as? JsonPrimitive ?: return null + return primitive.content.trim().takeIf { it.isNotBlank() } +} + +private fun Double.toProgressFraction(): Float { + val normalized = if (this > 1.0) this / 100.0 else this + return normalized.toFloat().coerceIn(0f, 1f) +} + +private fun isPlayableCloudFile(name: String, mimeType: String?): Boolean { + val normalizedMime = mimeType?.lowercase().orEmpty() + if (normalizedMime.startsWith("video/")) return true + val extension = name.substringAfterLast('.', missingDelimiterValue = "") + .lowercase() + return extension in playableVideoExtensions +} + +private val playableVideoExtensions = setOf( + "3g2", + "3gp", + "avi", + "divx", + "flv", + "m2ts", + "m4v", + "mkv", + "mov", + "mp4", + "mpeg", + "mpg", + "mts", + "ogm", + "ogv", + "ts", + "webm", + "wmv", +) 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 47ddac07..485d59dc 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 @@ -115,6 +115,27 @@ internal object TorboxApiClient { apiKey = apiKey, ) + suspend fun listCloudTorrents(apiKey: String): DebridApiResponse>> = + request( + method = "GET", + url = "$BASE_URL/v1/api/torrents/mylist", + apiKey = apiKey, + ) + + suspend fun listCloudUsenet(apiKey: String): DebridApiResponse>> = + request( + method = "GET", + url = "$BASE_URL/v1/api/usenet/mylist", + apiKey = apiKey, + ) + + suspend fun listCloudWebDownloads(apiKey: String): DebridApiResponse>> = + request( + method = "GET", + url = "$BASE_URL/v1/api/webdl/mylist", + apiKey = apiKey, + ) + suspend fun requestDownloadLink( apiKey: String, torrentId: Int, @@ -135,6 +156,66 @@ internal object TorboxApiClient { apiKey = apiKey, ) + suspend fun requestCloudTorrentDownloadLink( + apiKey: String, + torrentId: String, + fileId: String?, + ): DebridApiResponse> = + request( + method = "GET", + url = "$BASE_URL/v1/api/torrents/requestdl?${ + queryString( + "token" to apiKey, + "torrent_id" to torrentId, + "file_id" to fileId, + "zip_link" to "false", + "redirect" to "false", + "append_name" to "false", + ) + }", + apiKey = apiKey, + ) + + suspend fun requestCloudUsenetDownloadLink( + apiKey: String, + usenetId: String, + fileId: String?, + ): DebridApiResponse> = + request( + method = "GET", + url = "$BASE_URL/v1/api/usenet/requestdl?${ + queryString( + "token" to apiKey, + "usenet_id" to usenetId, + "file_id" to fileId, + "zip_link" to "false", + "redirect" to "false", + "append_name" to "false", + ) + }", + apiKey = apiKey, + ) + + suspend fun requestCloudWebDownloadLink( + apiKey: String, + webId: String, + fileId: String?, + ): DebridApiResponse> = + request( + method = "GET", + url = "$BASE_URL/v1/api/webdl/requestdl?${ + queryString( + "token" to apiKey, + "web_id" to webId, + "file_id" to fileId, + "zip_link" to "false", + "redirect" to "false", + "append_name" to "false", + ) + }", + apiKey = apiKey, + ) + private suspend inline fun request( method: String, url: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt index 909fd46a..a2c27de2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.debrid import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement @Serializable internal data class TorboxEnvelopeDto( @@ -44,6 +45,32 @@ internal data class TorboxTorrentFileDto( .orEmpty() } +@Serializable +internal data class TorboxCloudItemDto( + val id: JsonElement? = null, + val hash: String? = null, + val name: String? = null, + val status: String? = null, + val state: String? = null, + @SerialName("download_state") val downloadState: String? = null, + val progress: Double? = null, + @SerialName("download_progress") val downloadProgress: Double? = null, + val size: Long? = null, + @SerialName("total_size") val totalSize: Long? = null, + val files: List? = null, +) + +@Serializable +internal data class TorboxCloudFileDto( + val id: JsonElement? = null, + val name: String? = null, + @SerialName("short_name") val shortName: String? = null, + @SerialName("absolute_path") val absolutePath: String? = null, + @SerialName("mimetype") val mimeType: String? = null, + @SerialName("mime_type") val mimeTypeAlt: String? = null, + val size: Long? = null, +) + @Serializable internal data class TorboxCheckCachedRequestDto( val hashes: List, 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 ac6aa352..93e75362 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 @@ -18,6 +18,7 @@ enum class DebridProviderCapability { ClientResolve, LocalTorrentCacheCheck, LocalTorrentResolve, + CloudLibrary, } enum class DebridProviderAuthMethod { @@ -38,6 +39,7 @@ object DebridProviders { DebridProviderCapability.ClientResolve, DebridProviderCapability.LocalTorrentCacheCheck, DebridProviderCapability.LocalTorrentResolve, + DebridProviderCapability.CloudLibrary, ), ) 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 863fa3b4..61ca78cd 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 @@ -1,25 +1,60 @@ package com.nuvio.app.features.library +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.NuvioScreen @@ -28,6 +63,11 @@ import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioViewAllPillSize import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough +import com.nuvio.app.features.cloud.CloudLibraryFile +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.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow @@ -47,17 +87,30 @@ fun LibraryScreen( onPosterClick: ((LibraryItem) -> Unit)? = null, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null, onSectionViewAllClick: ((LibrarySection) -> Unit)? = null, + onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null, + onConnectCloudClick: (() -> Unit)? = null, ) { val uiState by remember { LibraryRepository.ensureLoaded() LibraryRepository.uiState }.collectAsStateWithLifecycle() + val cloudUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by remember { WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() var observedOfflineState by remember { mutableStateOf(false) } + var sourceModeName by rememberSaveable { mutableStateOf(LibraryViewMode.Saved.name) } + val sourceMode = remember(sourceModeName) { + runCatching { LibraryViewMode.valueOf(sourceModeName) }.getOrDefault(LibraryViewMode.Saved) + } + var selectedProviderId by rememberSaveable { mutableStateOf(null) } + var selectedTypeName by rememberSaveable { mutableStateOf(null) } + val selectedType = remember(selectedTypeName) { + selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() } + } + var selectedCloudItemKey by rememberSaveable { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() val listState = rememberLazyListState() val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT @@ -98,6 +151,13 @@ fun LibraryScreen( } } + LaunchedEffect(sourceMode) { + if (sourceMode == LibraryViewMode.Cloud) { + CloudLibraryRepository.ensureLoaded() + selectedCloudItemKey = null + } + } + NuvioScreen( modifier = modifier, horizontalPadding = 0.dp, @@ -111,87 +171,772 @@ fun LibraryScreen( .background(MaterialTheme.colorScheme.background), ) { NuvioScreenHeader( - title = if (isTraktSource) { + title = if (sourceMode == LibraryViewMode.Cloud) { + stringResource(Res.string.library_title) + } else if (isTraktSource) { stringResource(Res.string.library_trakt_title) } else { stringResource(Res.string.library_title) }, modifier = Modifier.padding(horizontal = 16.dp), ) + LibrarySourceSwitch( + selectedMode = sourceMode, + onModeSelected = { mode -> + sourceModeName = mode.name + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) Spacer(modifier = Modifier.height(6.dp)) } } - when { - !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> { - items(3) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp)) + if (sourceMode == LibraryViewMode.Cloud) { + cloudLibraryContent( + uiState = cloudUiState, + selectedProviderId = selectedProviderId, + selectedType = selectedType, + selectedCloudItemKey = selectedCloudItemKey, + onProviderSelected = { + selectedProviderId = it + selectedCloudItemKey = null + }, + onTypeSelected = { + selectedTypeName = it?.name + selectedCloudItemKey = null + }, + onItemSelected = { item -> + val playableFiles = item.playableFiles + when { + playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first()) + playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey + } + }, + onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) }, + onBackToItems = { selectedCloudItemKey = null }, + onRefresh = { CloudLibraryRepository.refresh() }, + onConnectCloudClick = onConnectCloudClick, + ) + } else { + when { + !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> { + items(3) { + HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp)) + } + } + + !uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> { + item { + if (networkStatusUiState.isOfflineLike) { + NuvioNetworkOfflineCard( + condition = networkStatusUiState.condition, + modifier = Modifier.padding(horizontal = 16.dp), + onRetry = retryLibraryLoad, + ) + } else { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_load_failed) + } else { + stringResource(Res.string.library_load_failed) + }, + message = uiState.errorMessage.orEmpty(), + actionLabel = stringResource(Res.string.action_retry), + onActionClick = retryLibraryLoad, + ) + } + } + } + + uiState.sections.isEmpty() -> { + item { + if (networkStatusUiState.isOfflineLike && isTraktSource) { + NuvioNetworkOfflineCard( + condition = networkStatusUiState.condition, + modifier = Modifier.padding(horizontal = 16.dp), + onRetry = retryLibraryLoad, + ) + } else { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_empty_title) + } else { + stringResource(Res.string.library_empty_title) + }, + message = if (isTraktSource) { + stringResource(Res.string.library_trakt_empty_message) + } else { + stringResource(Res.string.library_empty_message) + }, + ) + } + } + } + + else -> { + librarySections( + sections = uiState.sections, + watchedKeys = watchedUiState.watchedKeys, + onPosterClick = onPosterClick, + onSectionViewAllClick = onSectionViewAllClick, + onPosterLongClick = onPosterLongClick, + ) } } + } + } +} - !uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> { +private fun LazyListScope.cloudLibraryContent( + uiState: CloudLibraryUiState, + selectedProviderId: String?, + selectedType: CloudLibraryItemType?, + selectedCloudItemKey: String?, + onProviderSelected: (String?) -> Unit, + onTypeSelected: (CloudLibraryItemType?) -> Unit, + onItemSelected: (CloudLibraryItem) -> Unit, + onFileSelected: (CloudLibraryItem, CloudLibraryFile) -> Unit, + onBackToItems: () -> Unit, + onRefresh: () -> Unit, + onConnectCloudClick: (() -> Unit)?, +) { + when { + !uiState.isLoaded -> { + cloudLibrarySkeletonItems() + } + + !uiState.hasConnectedProvider -> { + item { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = stringResource(Res.string.cloud_library_connect_title), + message = stringResource(Res.string.cloud_library_connect_message), + actionLabel = stringResource(Res.string.cloud_library_connect_action), + onActionClick = onConnectCloudClick, + ) + } + } + + else -> { + val filteredItems = uiState.items + .filter { item -> selectedProviderId == null || item.providerId == selectedProviderId } + .filter { item -> selectedType == null || item.type == selectedType } + val selectedItem = filteredItems.firstOrNull { it.stableKey == selectedCloudItemKey } + + if (selectedItem != null) { item { - if (networkStatusUiState.isOfflineLike) { - NuvioNetworkOfflineCard( - condition = networkStatusUiState.condition, - modifier = Modifier.padding(horizontal = 16.dp), - onRetry = retryLibraryLoad, - ) - } else { + CloudLibraryFilePicker( + item = selectedItem, + onBack = onBackToItems, + onFileSelected = { file -> onFileSelected(selectedItem, file) }, + ) + } + } else { + item { + CloudLibraryToolbar( + uiState = uiState, + selectedProviderId = selectedProviderId, + selectedType = selectedType, + onProviderSelected = onProviderSelected, + onTypeSelected = onTypeSelected, + onRefresh = onRefresh, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + uiState.providers + .filter { providerState -> selectedProviderId == null || providerState.providerId == selectedProviderId } + .filter { providerState -> !providerState.errorMessage.isNullOrBlank() && providerState.items.isEmpty() } + .forEach { providerState -> + item(key = "cloud-error-${providerState.providerId}") { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = stringResource(Res.string.cloud_library_load_failed, providerState.providerName), + message = providerState.errorMessage.orEmpty(), + actionLabel = stringResource(Res.string.action_retry), + onActionClick = onRefresh, + ) + } + } + + if (uiState.isRefreshing && filteredItems.isEmpty()) { + cloudLibrarySkeletonItems() + } else if (filteredItems.isEmpty()) { + item { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) { - stringResource(Res.string.library_trakt_load_failed) - } else { - stringResource(Res.string.library_load_failed) - }, - message = uiState.errorMessage.orEmpty(), + title = stringResource(Res.string.cloud_library_empty_title), + message = stringResource(Res.string.cloud_library_empty_message), actionLabel = stringResource(Res.string.action_retry), - onActionClick = retryLibraryLoad, + onActionClick = onRefresh, + ) + } + } else { + items( + items = filteredItems, + key = { item -> item.stableKey }, + ) { item -> + CloudLibraryRow( + item = item, + onClick = { onItemSelected(item) }, ) } } } + } + } +} - uiState.sections.isEmpty() -> { +private fun LazyListScope.cloudLibrarySkeletonItems() { + item(key = "cloud-library-skeleton-toolbar") { + CloudLibrarySkeletonToolbar( + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + items(4) { + CloudLibrarySkeletonRow() + } +} + +@Composable +private fun LibrarySourceSwitch( + selectedMode: LibraryViewMode, + onModeSelected: (LibraryViewMode) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + LibraryChip( + label = stringResource(Res.string.library_source_saved), + selected = selectedMode == LibraryViewMode.Saved, + onClick = { onModeSelected(LibraryViewMode.Saved) }, + ) + LibraryChip( + label = stringResource(Res.string.library_source_cloud), + selected = selectedMode == LibraryViewMode.Cloud, + onClick = { onModeSelected(LibraryViewMode.Cloud) }, + ) + } +} + +@Composable +private fun CloudLibraryToolbar( + uiState: CloudLibraryUiState, + selectedProviderId: String?, + selectedType: CloudLibraryItemType?, + onProviderSelected: (String?) -> Unit, + onTypeSelected: (CloudLibraryItemType?) -> Unit, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + LazyRow( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(end = 8.dp), + ) { item { - if (networkStatusUiState.isOfflineLike && isTraktSource) { - NuvioNetworkOfflineCard( - condition = networkStatusUiState.condition, - modifier = Modifier.padding(horizontal = 16.dp), - onRetry = retryLibraryLoad, - ) - } else { - HomeEmptyStateCard( - modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) { - stringResource(Res.string.library_trakt_empty_title) - } else { - stringResource(Res.string.library_empty_title) - }, - message = if (isTraktSource) { - stringResource(Res.string.library_trakt_empty_message) - } else { - stringResource(Res.string.library_empty_message) - }, - ) - } + LibraryChip( + label = stringResource(Res.string.cloud_library_provider_all), + selected = selectedProviderId == null, + onClick = { onProviderSelected(null) }, + ) + } + items( + items = uiState.providers, + key = { provider -> provider.providerId }, + ) { provider -> + LibraryChip( + label = provider.providerName, + selected = selectedProviderId == provider.providerId, + loading = provider.isLoading, + error = !provider.errorMessage.isNullOrBlank(), + onClick = { onProviderSelected(provider.providerId) }, + ) } } - - else -> { - librarySections( - sections = uiState.sections, - watchedKeys = watchedUiState.watchedKeys, - onPosterClick = onPosterClick, - onSectionViewAllClick = onSectionViewAllClick, - onPosterLongClick = onPosterLongClick, + IconButton(onClick = onRefresh) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = stringResource(Res.string.cloud_library_refresh), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(end = 16.dp), + ) { + item { + LibraryChip( + label = stringResource(Res.string.cloud_library_type_all), + selected = selectedType == null, + onClick = { onTypeSelected(null) }, + ) + } + items( + items = CloudLibraryItemType.entries, + key = { type -> type.name }, + ) { type -> + LibraryChip( + label = cloudLibraryTypeLabel(type), + selected = selectedType == type, + onClick = { onTypeSelected(type) }, ) } } } } +@Composable +private fun LibraryChip( + label: String, + selected: Boolean, + loading: Boolean = false, + error: Boolean = false, + onClick: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + Surface( + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = if (selected) colorScheme.primaryContainer else colorScheme.surfaceContainerLow, + border = if (selected) BorderStroke(1.dp, colorScheme.primary.copy(alpha = 0.45f)) else null, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = colorScheme.primary, + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = when { + error -> colorScheme.error + selected -> colorScheme.onPrimaryContainer + else -> colorScheme.onSurfaceVariant + }, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun CloudLibraryRow( + item: CloudLibraryItem, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val playableCount = item.playableFiles.size + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp) + .clickable(enabled = playableCount > 0, onClick = onClick), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = item.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = cloudLibrarySubtitle(item), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = cloudLibraryStatusLine(item), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (playableCount > 0) { + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = stringResource(Res.string.action_play), + ) + } + } + } + item.progressFraction?.takeIf { it in 0f..0.999f }?.let { progress -> + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + } +} + +@Composable +private fun CloudLibraryFilePicker( + item: CloudLibraryItem, + onBack: () -> Unit, + onFileSelected: (CloudLibraryFile) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(Res.string.action_back), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(Res.string.cloud_library_file_picker_title), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + val files = item.playableFiles + if (files.isEmpty()) { + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = stringResource(Res.string.cloud_library_no_files_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.cloud_library_no_files_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + files.forEach { file -> + CloudLibraryFileRow( + file = file, + onClick = { onFileSelected(file) }, + ) + } + } + } + } +} + +@Composable +private fun CloudLibraryFileRow( + file: CloudLibraryFile, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + 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), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.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 -> + Text( + text = formatCloudBytes(size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = stringResource(Res.string.cloud_library_play_file), + tint = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +private fun cloudLibrarySubtitle(item: CloudLibraryItem): String { + val fileLine = when (val playableCount = item.playableFiles.size) { + 0 -> stringResource(Res.string.cloud_library_no_playable_files) + 1 -> item.playableFiles.first().name + else -> stringResource(Res.string.cloud_library_playable_file_count, playableCount) + } + return listOf(item.providerName, cloudLibraryTypeLabel(item.type), fileLine).joinToString(" β€’ ") +} + +@Composable +private fun cloudLibraryStatusLine(item: CloudLibraryItem): String { + val fallback = if (item.playableFiles.isEmpty()) { + stringResource(Res.string.cloud_library_no_playable_files) + } else { + stringResource(Res.string.cloud_library_status_ready) + } + return listOfNotNull( + item.status?.toDisplayStatus(), + item.sizeBytes?.let(::formatCloudBytes), + item.progressFraction?.let { "${(it * 100f).toInt()}%" }, + ).joinToString(" β€’ ").ifBlank { fallback } +} + +@Composable +private fun cloudLibraryTypeLabel(type: CloudLibraryItemType): String = + when (type) { + CloudLibraryItemType.Torrent -> stringResource(Res.string.cloud_library_type_torrents) + CloudLibraryItemType.Usenet -> stringResource(Res.string.cloud_library_type_usenet) + CloudLibraryItemType.WebDownload -> stringResource(Res.string.cloud_library_type_web) + } + +private fun formatCloudBytes(bytes: Long): String { + if (bytes <= 0L) return "0 ${localizedByteUnit("B")}" + val kib = 1024.0 + val mib = kib * 1024.0 + val gib = mib * 1024.0 + val value = bytes.toDouble() + return when { + value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} ${localizedByteUnit("GB")}" + value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} ${localizedByteUnit("MB")}" + value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} ${localizedByteUnit("KB")}" + else -> "$bytes ${localizedByteUnit("B")}" + } +} + +private fun String.toDisplayStatus(): String = + replace('_', ' ') + .lowercase() + .replaceFirstChar { it.titlecase() } + +@Composable +private fun CloudLibrarySkeletonToolbar( + modifier: Modifier = Modifier, +) { + val brush = rememberCloudLibrarySkeletonBrush() + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CloudSkeletonBlock(brush = brush, width = 52.dp, height = 34.dp, cornerRadius = 18.dp) + CloudSkeletonBlock(brush = brush, width = 86.dp, height = 34.dp, cornerRadius = 18.dp) + CloudSkeletonBlock( + brush = brush, + modifier = Modifier.weight(1f), + height = 34.dp, + cornerRadius = 18.dp, + ) + CloudSkeletonBlock(brush = brush, width = 40.dp, height = 40.dp, cornerRadius = 20.dp) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CloudSkeletonBlock(brush = brush, width = 52.dp, height = 34.dp, cornerRadius = 18.dp) + CloudSkeletonBlock(brush = brush, width = 82.dp, height = 34.dp, cornerRadius = 18.dp) + CloudSkeletonBlock(brush = brush, width = 72.dp, height = 34.dp, cornerRadius = 18.dp) + CloudSkeletonBlock(brush = brush, width = 60.dp, height = 34.dp, cornerRadius = 18.dp) + } + } +} + +@Composable +private fun CloudLibrarySkeletonRow( + modifier: Modifier = Modifier, +) { + val brush = rememberCloudLibrarySkeletonBrush() + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + CloudSkeletonBlock( + brush = brush, + modifier = Modifier.fillMaxWidth(0.74f), + height = 17.dp, + cornerRadius = 6.dp, + ) + CloudSkeletonBlock( + brush = brush, + modifier = Modifier.fillMaxWidth(), + height = 12.dp, + cornerRadius = 6.dp, + ) + CloudSkeletonBlock( + brush = brush, + modifier = Modifier.fillMaxWidth(0.58f), + height = 12.dp, + cornerRadius = 6.dp, + ) + } + CloudSkeletonBlock(brush = brush, width = 32.dp, height = 32.dp, cornerRadius = 16.dp) + } + CloudSkeletonBlock( + brush = brush, + modifier = Modifier.fillMaxWidth(0.44f), + height = 4.dp, + cornerRadius = 999.dp, + ) + } + } +} + +@Composable +private fun rememberCloudLibrarySkeletonBrush(): Brush { + val shimmerColors = listOf( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f), + ) + val transition = rememberInfiniteTransition() + val translateAnim by transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + ) + return Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnim - 200f, 0f), + end = Offset(translateAnim, 0f), + ) +} + +@Composable +private fun CloudSkeletonBlock( + brush: Brush, + modifier: Modifier = Modifier, + width: Dp? = null, + height: Dp, + cornerRadius: Dp, +) { + val sizeModifier = if (width != null) { + modifier.size(width = width, height = height) + } else { + modifier.height(height) + } + Box( + modifier = sizeModifier + .clip(RoundedCornerShape(cornerRadius)) + .background(brush), + ) +} + +private enum class LibraryViewMode { + Saved, + Cloud, +} + private fun LazyListScope.librarySections( sections: List, watchedKeys: Set, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 21442208..fb85bc73 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -94,6 +94,8 @@ private const val SettingsSearchRevealHapticDelayMillis = 90L fun SettingsScreen( modifier: Modifier = Modifier, rootActionRequests: Flow = emptyFlow(), + requestedPageName: String? = null, + onRequestedPageConsumed: () -> Unit = {}, rootActionsEnabled: Boolean = true, onSwitchProfile: (() -> Unit)? = null, onHomescreenClick: () -> Unit = {}, @@ -221,6 +223,15 @@ fun SettingsScreen( } } + LaunchedEffect(requestedPageName, rootActionsEnabled) { + val targetPage = requestedPageName + ?.let { runCatching { SettingsPage.valueOf(it) }.getOrNull() } + ?: return@LaunchedEffect + if (!rootActionsEnabled) return@LaunchedEffect + currentPage = targetPage.name + onRequestedPageConsumed() + } + PlatformBackHandler( enabled = rootActionsEnabled && previousPage != null, onBack = { previousPage?.let { currentPage = it.name } }, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt new file mode 100644 index 00000000..297daf49 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt @@ -0,0 +1,110 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProvider +import com.nuvio.app.features.debrid.DebridProviderCapability +import com.nuvio.app.features.debrid.DebridServiceCredential +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CloudLibraryStoreTest { + @Test + fun `refresh aggregates multiple providers without provider-specific assumptions`() = runBlocking { + val firstProvider = cloudProvider(id = "alpha", name = "Alpha") + val secondProvider = cloudProvider(id = "beta", name = "Beta") + val store = CloudLibraryStore( + credentialsProvider = { + listOf( + DebridServiceCredential(firstProvider, "alpha-token"), + DebridServiceCredential(secondProvider, "beta-token"), + ) + }, + providerApis = listOf( + FakeCloudProviderApi( + provider = firstProvider, + items = listOf(cloudItem(firstProvider, "one")), + ), + FakeCloudProviderApi( + provider = secondProvider, + items = listOf(cloudItem(secondProvider, "two")), + ), + ), + ) + + val state = store.refresh() + + assertTrue(state.isLoaded) + assertEquals(listOf("alpha", "beta"), state.providers.map { it.providerId }) + assertEquals(listOf("one", "two"), state.items.map { it.id }) + } + + @Test + fun `refresh ignores connected providers without cloud library capability`() = runBlocking { + val cloudProvider = cloudProvider(id = "cloud", name = "Cloud") + val unsupportedProvider = DebridProvider( + id = "plain", + displayName = "Plain", + shortName = "P", + capabilities = setOf(DebridProviderCapability.ClientResolve), + ) + val store = CloudLibraryStore( + credentialsProvider = { + listOf( + DebridServiceCredential(cloudProvider, "cloud-token"), + DebridServiceCredential(unsupportedProvider, "plain-token"), + ) + }, + providerApis = listOf( + FakeCloudProviderApi( + provider = cloudProvider, + items = listOf(cloudItem(cloudProvider, "cloud-item")), + ), + ), + ) + + val state = store.refresh() + + assertEquals(listOf("cloud"), state.providers.map { it.providerId }) + assertEquals(listOf("cloud-item"), state.items.map { it.id }) + } +} + +private class FakeCloudProviderApi( + override val provider: DebridProvider, + private val items: List, +) : CloudLibraryProviderApi { + override suspend fun listItems(apiKey: String): Result> = + Result.success(items) + + override suspend fun resolvePlayback( + apiKey: String, + item: CloudLibraryItem, + file: CloudLibraryFile, + ): CloudLibraryPlaybackResult = + CloudLibraryPlaybackResult.Success(url = "https://example.test/${item.id}/${file.id}") +} + +private fun cloudProvider(id: String, name: String): DebridProvider = + DebridProvider( + id = id, + displayName = name, + shortName = name.take(1), + capabilities = setOf(DebridProviderCapability.CloudLibrary), + ) + +private fun cloudItem(provider: DebridProvider, id: String): CloudLibraryItem = + CloudLibraryItem( + providerId = provider.id, + providerName = provider.displayName, + id = id, + type = CloudLibraryItemType.Torrent, + name = id, + files = listOf( + CloudLibraryFile( + id = "file-$id", + name = "$id.mkv", + playable = true, + ), + ), + ) 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 new file mode 100644 index 00000000..7648b8d3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt @@ -0,0 +1,131 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.debrid.TorboxCloudFileDto +import com.nuvio.app.features.debrid.TorboxCloudItemDto +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TorboxCloudLibraryProviderApiTest { + @Test + fun `maps torrent dto with status progress size and playable files`() { + val item = TorboxCloudItemDto( + id = JsonPrimitive(42), + name = "Movie Pack", + status = "completed", + progress = 75.0, + size = 1_024L, + files = listOf( + TorboxCloudFileDto( + id = JsonPrimitive(8), + name = "movie.mkv", + mimeType = "video/x-matroska", + size = 512L, + ), + ), + ).toCloudLibraryItem( + providerId = DebridProviders.Torbox.id, + providerName = DebridProviders.Torbox.displayName, + type = CloudLibraryItemType.Torrent, + ) + + assertNotNull(item) + assertEquals("42", item.id) + assertEquals(CloudLibraryItemType.Torrent, item.type) + assertEquals("completed", item.status) + assertEquals(0.75f, item.progressFraction) + assertEquals(1_024L, item.sizeBytes) + assertEquals(listOf("8"), item.files.map { it.id }) + assertTrue(item.files.single().playable) + } + + @Test + fun `mapping falls back to hash and file absolute path when friendly fields are missing`() { + val item = TorboxCloudItemDto( + hash = "abc123", + files = listOf( + TorboxCloudFileDto( + id = JsonPrimitive("file-1"), + absolutePath = "/downloads/show.mp4", + size = 256L, + ), + ), + ).toCloudLibraryItem( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.Usenet, + ) + + assertNotNull(item) + assertEquals("abc123", item.id) + assertEquals("abc123", item.name) + assertEquals("/downloads/show.mp4", item.files.single().name) + assertTrue(item.files.single().playable) + } + + @Test + fun `mapping handles missing item ids and empty file lists`() { + assertNull( + TorboxCloudItemDto(name = "No ID").toCloudLibraryItem( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.WebDownload, + ), + ) + + val item = TorboxCloudItemDto( + id = JsonPrimitive(7), + name = "Empty", + files = emptyList(), + ).toCloudLibraryItem( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.WebDownload, + ) + + assertNotNull(item) + assertTrue(item.files.isEmpty()) + assertTrue(item.playableFiles.isEmpty()) + } + + @Test + fun `mapping keeps non-playable files but excludes them from playable files`() { + val item = TorboxCloudItemDto( + id = JsonPrimitive(9), + name = "Mixed", + files = listOf( + TorboxCloudFileDto( + id = JsonPrimitive(1), + name = "readme.txt", + mimeType = "text/plain", + ), + TorboxCloudFileDto( + name = "missing-id.mkv", + mimeType = "video/x-matroska", + ), + ), + ).toCloudLibraryItem( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.Torrent, + ) + + assertNotNull(item) + assertEquals(2, item.files.size) + assertFalse(item.files[0].playable) + assertFalse(item.files[1].playable) + assertTrue(item.playableFiles.isEmpty()) + } + + @Test + fun `request download parameter names match Torbox item type`() { + assertEquals("torrent_id", torboxRequestIdParameterName(CloudLibraryItemType.Torrent)) + assertEquals("usenet_id", torboxRequestIdParameterName(CloudLibraryItemType.Usenet)) + assertEquals("web_id", torboxRequestIdParameterName(CloudLibraryItemType.WebDownload)) + } +} 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 index 2f0eccd2..31c65109 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt @@ -11,6 +11,7 @@ class DebridProviderTest { assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.ClientResolve)) assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentCacheCheck)) assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentResolve)) + assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.CloudLibrary)) } @Test @@ -20,5 +21,6 @@ class DebridProviderTest { assertTrue(DebridProviders.RealDebrid.supports(DebridProviderCapability.ClientResolve)) assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentCacheCheck)) assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentResolve)) + assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.CloudLibrary)) } } From b0906b7b19968efbca5844fd734ae52c683ba13d Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 21 May 2026 13:09:01 +0530 Subject: [PATCH 07/18] ref: adjust phrasing --- .../composeResources/values-no/strings.xml | 34 ++++++++-------- .../composeResources/values/strings.xml | 40 +++++++++---------- .../features/settings/DebridSettingsPage.kt | 24 +++++------ 3 files changed, 49 insertions(+), 49 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml index 0fa7a5bd..64646871 100644 --- a/composeApp/src/commonMain/composeResources/values-no/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml @@ -379,7 +379,7 @@ Utseende Innhold & oppdagelse Fortsett Γ₯ se - Debrid + Skytjenester Hjemmeoppsett Integrasjoner Lisenser & attribusjon @@ -588,13 +588,13 @@ Integrasjoner Metadata-berikelse-kontroller Eksterne vurderingsleverandΓΈrer - Eksperimentelle sky-konto-kilder - Debrid - Debrid-stΓΈtte er eksperimentell og kan endres eller fjernes senere. - Aktiver kilder - Vis spillbare resultater fra tilkoblede kontoer. - Koble til en Debrid-konto fΓΈrst. - Konto + 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. + Koble til en skytjenestekonto fΓΈrst. + Skytjenester Koble til %1$s-kontoen din. Koble til %1$s-kontoen din i nettleseren. %1$s API-nΓΈkkel @@ -614,15 +614,15 @@ Denne koden er utlΓΈpt. PrΓΈv igjen. Umiddelbar avspilling Forbered lenker - LΓΈs fΓΈrste kilder fΓΈr avspilling starter. - Kilder Γ₯ forberede - 1 kilde - %1$d kilder + LΓΈs spillbare lenker fΓΈr avspilling starter. + Lenker Γ₯ forberede + 1 lenke + %1$d lenker Formatering Navnemal - Styrer hvordan kildenavn vises. + Styrer hvordan navn pΓ₯ skyresultater vises. Beskrivelsesmal - Styrer metadata vist under hver kilde. + Styrer metadata vist under hvert skyresultat. API-nΓΈkkel validert. Kunne ikke validere denne API-nΓΈkkelen. Legg til MDBList API-nΓΈkkel fΓΈr du skrur pΓ₯ vurderinger. @@ -1160,9 +1160,9 @@ Gjenoppta fra %1$s STØRRELSE %1$s Denne strΓΈmtypen stΓΈttes ikke - Koble til en Debrid-konto i Innstillinger. - Dette Debrid-resultatet er utgΓ₯tt. Oppdaterer strΓΈmmer. - Kunne ikke lΓΈse denne Debrid-strΓΈmmen. + Koble til en skytjenestekonto i Innstillinger. + Denne skytjenestelenken er utgΓ₯tt. Oppdaterer resultater. + Kunne ikke Γ₯pne denne skytjenestelenken. Kunne ikke Γ₯pne ekstern avspiller Velg en ekstern avspiller i innstillinger fΓΈrst Ingen ekstern avspiller er tilgjengelig diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 528d11ab..1d410fb7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -380,7 +380,7 @@ Layout Content & Discovery Continue Watching - Debrid + Cloud Services Home Layout Integrations Licenses & Attribution @@ -589,13 +589,13 @@ Integrations Metadata enrichment controls External ratings providers - Experimental cloud account sources - Debrid - Debrid support is experimental and may be kept, changed, or removed later. - Enable sources - Show playable results from connected accounts. - Connect a Debrid account first. - Account + 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. + Connect a cloud service account first. + Cloud Services Connect your %1$s account. Link your %1$s account in the browser. %1$s API Key @@ -616,18 +616,18 @@ This code expired. Try again. Instant Playback Prepare links - Resolve the first sources before playback starts. - Sources to prepare - Use a lower count when possible. Debrid services may rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time. - 1 source - %1$d sources + Resolve playable links before playback starts. + Links to prepare + Use a lower count when possible. Cloud services may rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time. + 1 link + %1$d links Formatting Name template - Controls how source names appear. + Controls how cloud result names appear. Description template - Controls the metadata shown under each source. + Controls the metadata shown under each cloud result. Reset formatting - Restore default source formatting. + Restore default cloud result formatting. API key validated. Could not validate this API key. Add your MDBList API key below before turning ratings on. @@ -1165,10 +1165,10 @@ Resume from %1$s SIZE %1$s This stream type is not supported - Connect a Debrid account in Settings. + Connect a cloud service account in Settings. Not cached on Torbox. - This Debrid result expired. Refreshing streams. - Could not resolve this Debrid stream. + This cloud service link expired. Refreshing results. + Could not open this cloud service link. Couldn't open external player Choose an external player in settings first No external player is available @@ -1328,7 +1328,7 @@ Couldn't load Trakt library Trakt Library Connect account - Connect Torbox in Debrid settings to browse playable files from your cloud library. + Connect Torbox in Cloud Services settings to browse playable files from your cloud library. No cloud account connected No playable cloud files match the current filters. Nothing here yet 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 93db2c73..0d60c6b3 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 @@ -272,14 +272,14 @@ internal fun LazyListScope.debridSettingsContent( val rows = debridRuleRows(preferences) SettingsSection( - title = "Filters & Sorting", + title = "Result Management", isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { DebridPreferenceRow( isTablet = isTablet, title = "Max results", - description = "Limit how many debrid-ready addon streams appear.", + description = "Limit how many cloud-service results appear.", value = streamMaxResultsLabel(preferences.maxResults), enabled = settings.enabled, onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS }, @@ -287,8 +287,8 @@ internal fun LazyListScope.debridSettingsContent( SettingsGroupDivider(isTablet = isTablet) DebridPreferenceRow( isTablet = isTablet, - title = "Sort streams", - description = "Choose how debrid-ready addon streams are ordered.", + title = "Sort results", + description = "Choose how cloud-service results are ordered.", value = sortProfileLabel(preferences.sortCriteria), enabled = settings.enabled, onClick = { activeStreamPicker = DebridStreamPicker.SORT_MODE }, @@ -315,7 +315,7 @@ internal fun LazyListScope.debridSettingsContent( DebridPreferenceRow( isTablet = isTablet, title = "Size range", - description = "Filter streams by file size.", + description = "Filter cloud-service results by file size.", value = sizeRangeLabel(preferences), enabled = settings.enabled, onClick = { activeStreamPicker = DebridStreamPicker.SIZE_RANGE }, @@ -413,7 +413,7 @@ private fun templatePreview(value: String): String { .lineSequence() .map { it.trim() } .firstOrNull { it.isNotBlank() } - ?: return "Addon default" + ?: return "Default format" return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..." } @@ -657,7 +657,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.SORT_MODE -> DebridSingleChoiceDialog( - title = "Sort streams", + title = "Sort results", selectedValue = sortProfileFor(preferences.sortCriteria), options = listOf( DebridSortProfile.DEFAULT, @@ -1101,7 +1101,7 @@ private fun DebridDialogOptionRow( @Composable private fun streamMaxResultsLabel(value: Int): String = - if (value <= 0) "All streams" else "$value streams" + if (value <= 0) "All results" else "$value results" private fun sortProfileLabel(value: DebridSortProfile): String = when (value) { @@ -1118,8 +1118,8 @@ private fun debridRuleRows(preferences: DebridStreamPreferences): List Date: Thu, 21 May 2026 14:01:03 +0530 Subject: [PATCH 08/18] ref(torbox): short_name extraction --- .../debrid/DebridSettingsStorage.android.kt | 10 +++ .../composeResources/values-no/strings.xml | 8 +- .../composeResources/values/strings.xml | 11 ++- .../app/features/cloud/CloudLibraryModels.kt | 1 + .../features/cloud/CloudLibraryRepository.kt | 26 +++++- .../cloud/TorboxCloudLibraryProviderApi.kt | 63 +++++++++++-- .../app/features/debrid/DebridSettings.kt | 14 +++ .../debrid/DebridSettingsRepository.kt | 16 ++++ .../features/debrid/DebridSettingsStorage.kt | 2 + .../debrid/DebridStreamPresentation.kt | 2 +- .../features/debrid/DirectDebridResolver.kt | 2 +- .../debrid/DirectDebridStreamPreparer.kt | 2 +- .../debrid/LocalDebridAvailabilityService.kt | 2 +- .../app/features/library/LibraryScreen.kt | 90 ++++++++++++------- .../features/player/PlayerEpisodesPanel.kt | 2 +- .../nuvio/app/features/player/PlayerScreen.kt | 2 +- .../app/features/player/PlayerSourcesPanel.kt | 2 +- .../features/settings/DebridSettingsPage.kt | 39 +++++--- .../streams/AddonStreamWarmupRepository.kt | 2 +- .../app/features/streams/StreamsRepository.kt | 4 +- .../app/features/streams/StreamsScreen.kt | 8 +- .../TorboxCloudLibraryProviderApiTest.kt | 64 ++++++++++++- .../app/features/debrid/DebridSettingsTest.kt | 12 +++ .../debrid/DebridStreamPresentationTest.kt | 26 ++++++ .../debrid/DebridSettingsStorage.ios.kt | 10 +++ 25 files changed, 348 insertions(+), 72 deletions(-) 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) From 8c25cca724125cf415d05516b9deb0b177864a0f Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 21 May 2026 14:37:44 +0530 Subject: [PATCH 09/18] feat(cloud): adding premimize --- composeApp/build.gradle.kts | 13 ++ .../debrid/DebridSettingsStorage.android.kt | 10 + .../composeResources/values-no/strings.xml | 3 + .../composeResources/values/strings.xml | 6 +- .../app/features/cloud/CloudLibraryModels.kt | 2 + .../features/cloud/CloudLibraryProviderApi.kt | 1 + .../PremiumizeCloudLibraryProviderApi.kt | 164 +++++++++++++++++ .../cloud/TorboxCloudLibraryProviderApi.kt | 2 + .../app/features/debrid/DebridApiClients.kt | 174 ++++++++++++++++++ .../app/features/debrid/DebridApiModels.kt | 99 ++++++++++ .../features/debrid/DebridFileSelectors.kt | 51 +++++ .../app/features/debrid/DebridProvider.kt | 29 ++- .../app/features/debrid/DebridProviderApis.kt | 142 ++++++++++++++ .../app/features/debrid/DebridSettings.kt | 18 +- .../debrid/DebridSettingsRepository.kt | 68 ++++++- .../features/debrid/DebridSettingsStorage.kt | 2 + .../debrid/DebridStreamPresentation.kt | 10 +- .../features/debrid/DirectDebridResolver.kt | 29 ++- .../debrid/DirectDebridStreamPreparer.kt | 2 +- .../debrid/LocalDebridAvailabilityService.kt | 4 +- .../app/features/debrid/LocalDebridService.kt | 37 ++++ .../app/features/library/LibraryScreen.kt | 1 + .../features/settings/DebridSettingsPage.kt | 89 ++++++++- .../PremiumizeCloudLibraryProviderApiTest.kt | 67 +++++++ .../features/debrid/DebridFileSelectorTest.kt | 36 ++++ .../app/features/debrid/DebridProviderTest.kt | 10 + .../app/features/debrid/DebridSettingsTest.kt | 22 ++- .../debrid/PremiumizeDeviceAuthTest.kt | 62 +++++++ .../debrid/DebridSettingsStorage.ios.kt | 10 + 29 files changed, 1132 insertions(+), 31 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApi.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApiTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/PremiumizeDeviceAuthTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 5c5811e4..98455633 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -90,6 +90,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { ) } + outDir.resolve("com/nuvio/app/features/debrid").apply { + mkdirs() + resolve("PremiumizeConfig.kt").writeText( + """ + |package com.nuvio.app.features.debrid + | + |object PremiumizeConfig { + | const val CLIENT_ID = "${props.getProperty("PREMIUMIZE_CLIENT_ID", "")}" + |} + """.trimMargin() + ) + } + outDir.resolve("com/nuvio/app/core/build").apply { mkdirs() resolve("AppVersionConfig.kt").writeText( 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 f7cd154a..de8e76b1 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 @@ -17,6 +17,7 @@ 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 preferredResolverProviderIdKey = "debrid_preferred_resolver_provider_id" 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" @@ -33,6 +34,7 @@ actual object DebridSettingsStorage { listOf( enabledKey, cloudLibraryEnabledKey, + preferredResolverProviderIdKey, instantPlaybackPreparationLimitKey, streamMaxResultsKey, streamSortModeKey, @@ -63,6 +65,12 @@ actual object DebridSettingsStorage { saveBoolean(cloudLibraryEnabledKey, enabled) } + actual fun loadPreferredResolverProviderId(): String? = loadString(preferredResolverProviderIdKey) + + actual fun savePreferredResolverProviderId(providerId: String) { + saveString(preferredResolverProviderIdKey, providerId) + } + actual fun loadProviderApiKey(providerId: String): String? = loadString(providerApiKeyKey(providerId)) @@ -189,6 +197,7 @@ actual object DebridSettingsStorage { actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) } + loadPreferredResolverProviderId()?.let { put(preferredResolverProviderIdKey, encodeSyncString(it)) } DebridProviders.all().forEach { provider -> loadProviderApiKey(provider.id)?.let { put(providerApiKeyKey(provider.id), encodeSyncString(it)) @@ -213,6 +222,7 @@ actual object DebridSettingsStorage { payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) payload.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled) + payload.decodeSyncString(preferredResolverProviderIdKey)?.let(::savePreferredResolverProviderId) 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 e8d5de08..c80318d5 100644 --- a/composeApp/src/commonMain/composeResources/values-no/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml @@ -595,6 +595,8 @@ 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. + LΓΈs med + Velg hvilken tilkoblet skytjeneste som hΓ₯ndterer spillbare lenker. Koble til en skytjenestekonto fΓΈrst. Skytjenester Koble til %1$s-kontoen din. @@ -613,6 +615,7 @@ Γ…pne lenke Venter pΓ₯ godkjenning... Kunne ikke starte innlogging. + Denne innloggingsmetoden er ikke konfigurert i denne versjonen. Denne koden er utlΓΈpt. PrΓΈv igjen. Lenkeforberedelse Forbered lenker diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f26a2718..e75ecbdf 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -596,6 +596,8 @@ 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. + Resolve with + Choose which connected cloud service manages playable links. Connect a cloud service account first. Cloud Services Connect your %1$s account. @@ -615,6 +617,7 @@ Open link Waiting for approval... Could not start sign-in. + This sign-in method is not configured in this build. This code expired. Try again. Link Preparation Prepare links @@ -1330,7 +1333,7 @@ Couldn't load Trakt library Trakt Library Connect account - Connect Torbox in Cloud Services settings to browse playable files from your cloud library. + Connect a cloud service 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. @@ -1352,6 +1355,7 @@ Torrents Usenet Web + Files Anime Channels Movies 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 4ee81729..dfdbcdfd 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 @@ -6,6 +6,7 @@ enum class CloudLibraryItemType { Torrent, Usenet, WebDownload, + File, } data class CloudLibraryFile( @@ -14,6 +15,7 @@ data class CloudLibraryFile( val sizeBytes: Long? = null, val mimeType: String? = null, val playable: Boolean = true, + val playbackUrl: String? = null, ) { val stableKey: String get() = id ?: name diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt index f87ca151..d9e98970 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt @@ -18,6 +18,7 @@ internal interface CloudLibraryProviderApi { internal object CloudLibraryProviderApis { private val registered = listOf( TorboxCloudLibraryProviderApi(), + PremiumizeCloudLibraryProviderApi(), ) fun all(): List = registered diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApi.kt new file mode 100644 index 00000000..b6802543 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApi.kt @@ -0,0 +1,164 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.debrid.PremiumizeApiClient +import com.nuvio.app.features.debrid.PremiumizeCloudFileDto +import kotlinx.coroutines.CancellationException + +internal class PremiumizeCloudLibraryProviderApi : CloudLibraryProviderApi { + override val provider = DebridProviders.Premiumize + + override suspend fun listItems(apiKey: String): Result> = + runCatching { + val response = PremiumizeApiClient.listAllItems(apiKey) + if (!response.isSuccessful || response.body?.status.equals("error", ignoreCase = true)) { + throw IllegalStateException(response.body?.message ?: response.body?.code ?: response.rawBody.takeIf { it.isNotBlank() }) + } + premiumizeCloudItemsFromFiles( + files = response.body?.files.orEmpty(), + providerId = provider.id, + providerName = provider.displayName, + ) + } + + override suspend fun resolvePlayback( + apiKey: String, + item: CloudLibraryItem, + file: CloudLibraryFile, + ): CloudLibraryPlaybackResult { + if (!file.playable) return CloudLibraryPlaybackResult.NotPlayable + file.playbackUrl?.takeIf { it.isNotBlank() }?.let { url -> + return CloudLibraryPlaybackResult.Success( + url = url, + filename = file.name.takeIf { it.isNotBlank() }, + videoSizeBytes = file.sizeBytes, + ) + } + + val fileId = file.id?.takeIf { it.isNotBlank() } ?: return CloudLibraryPlaybackResult.Failed() + return try { + val response = PremiumizeApiClient.itemDetails(apiKey = apiKey, itemId = fileId) + if (!response.isSuccessful || response.body?.status.equals("error", ignoreCase = true)) { + return CloudLibraryPlaybackResult.Failed(response.body?.message ?: response.body?.code) + } + val url = response.body?.link?.takeIf { it.isNotBlank() } + ?: return CloudLibraryPlaybackResult.Failed() + CloudLibraryPlaybackResult.Success( + url = url, + filename = response.body.name?.takeIf { it.isNotBlank() } ?: file.name.takeIf { it.isNotBlank() }, + videoSizeBytes = response.body.size ?: file.sizeBytes, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + CloudLibraryPlaybackResult.Failed(error.message) + } + } +} + +internal fun premiumizeCloudItemsFromFiles( + files: List, + providerId: String, + providerName: String, +): List { + val mappedFiles = files.mapNotNull { it.toPremiumizeCloudFile() } + val groups = mappedFiles.groupBy { file -> + file.groupKey + } + return groups.values + .mapNotNull { group -> + val first = group.firstOrNull() ?: return@mapNotNull null + val cloudFiles = group + .map { it.file } + .sortedWith(compareBy { !it.playable }.thenBy { it.name.lowercase() }) + val size = cloudFiles + .mapNotNull { it.sizeBytes } + .takeIf { it.isNotEmpty() } + ?.sum() + CloudLibraryItem( + providerId = providerId, + providerName = providerName, + id = first.itemId, + type = CloudLibraryItemType.File, + name = first.itemName, + status = "Ready", + sizeBytes = size, + files = cloudFiles, + ) + } + .sortedBy { it.name.lowercase() } +} + +private data class PremiumizeMappedCloudFile( + val groupKey: String, + val itemId: String, + val itemName: String, + val file: CloudLibraryFile, +) + +private fun PremiumizeCloudFileDto.toPremiumizeCloudFile(): PremiumizeMappedCloudFile? { + val normalizedPath = path?.trim()?.trim('/')?.takeIf { it.isNotBlank() } + val fileName = name?.trim()?.takeIf { it.isNotBlank() } + ?: normalizedPath?.pathBasename()?.takeIf { it.isNotBlank() } + ?: return null + val fileId = id?.trim()?.takeIf { it.isNotBlank() } + val playable = isPlayablePremiumizeCloudFile(name = fileName, mimeType = mimeType) + val segments = normalizedPath + ?.split('/') + ?.map { it.trim() } + ?.filter { it.isNotBlank() } + .orEmpty() + val topLevel = segments.firstOrNull() + val isRootFile = segments.size <= 1 + val itemName = if (isRootFile) fileName else topLevel ?: fileName + val itemId = if (isRootFile) { + "file:${fileId ?: normalizedPath ?: fileName}" + } else { + "folder:${topLevel ?: itemName}" + } + val groupKey = if (isRootFile) itemId else "folder:${topLevel ?: itemName}" + return PremiumizeMappedCloudFile( + groupKey = groupKey, + itemId = itemId, + itemName = itemName, + file = CloudLibraryFile( + id = fileId, + name = fileName, + sizeBytes = size, + mimeType = mimeType, + playable = playable, + playbackUrl = link?.takeIf { playable && it.isNotBlank() }, + ), + ) +} + +private fun String.pathBasename(): String = + substringAfterLast('/').substringAfterLast('\\') + +private fun isPlayablePremiumizeCloudFile(name: String, mimeType: String?): Boolean { + val normalizedMime = mimeType?.lowercase().orEmpty() + if (normalizedMime.startsWith("video/")) return true + val extension = name.substringAfterLast('.', missingDelimiterValue = "") + .lowercase() + return extension in premiumizePlayableVideoExtensions +} + +private val premiumizePlayableVideoExtensions = setOf( + "3g2", + "3gp", + "avi", + "divx", + "flv", + "m2ts", + "m4v", + "mkv", + "mov", + "mp4", + "mpeg", + "mpg", + "mts", + "ogm", + "ogv", + "ts", + "webm", + "wmv", +) 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 33ba2608..ab098ffd 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 @@ -42,6 +42,7 @@ internal class TorboxCloudLibraryProviderApi : CloudLibraryProviderApi { webId = item.id, fileId = file.id, ) + CloudLibraryItemType.File -> return CloudLibraryPlaybackResult.Failed() } if (!response.isSuccessful || response.body?.success == false) { return CloudLibraryPlaybackResult.Failed(response.body?.detail ?: response.body?.error) @@ -150,6 +151,7 @@ internal fun torboxRequestIdParameterName(type: CloudLibraryItemType): String = CloudLibraryItemType.Torrent -> "torrent_id" CloudLibraryItemType.Usenet -> "usenet_id" CloudLibraryItemType.WebDownload -> "web_id" + CloudLibraryItemType.File -> "file_id" } private fun List.firstNonBlank(): String? = 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 485d59dc..f27870be 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 @@ -362,6 +362,180 @@ internal object RealDebridApiClient { mapOf("Authorization" to "Bearer $apiKey") } +internal object PremiumizeApiClient { + private const val BASE_URL = "https://www.premiumize.me" + + suspend fun validateApiKey(apiKey: String): Boolean { + val response = accountInfo(apiKey.trim()) + return response.isSuccessful && response.body?.isSuccess == true + } + + suspend fun startDeviceAuthorization( + clientId: String, + ): DebridApiResponse = + formRequestWithoutAuth( + method = "POST", + url = "$BASE_URL/token", + fields = listOf( + "response_type" to "device_code", + "client_id" to clientId, + ), + ) + + suspend fun redeemDeviceAuthorization( + clientId: String, + deviceCode: String, + ): DebridApiResponse = + formRequestWithoutAuth( + method = "POST", + url = "$BASE_URL/token", + fields = listOf( + "grant_type" to "device_code", + "code" to deviceCode, + "client_id" to clientId, + ), + ) + + suspend fun accountInfo(apiKey: String): DebridApiResponse = + request( + method = "GET", + url = "$BASE_URL/api/account/info", + apiKey = apiKey, + ) + + suspend fun listAllItems(apiKey: String): DebridApiResponse = + request( + method = "GET", + url = "$BASE_URL/api/item/listall", + apiKey = apiKey, + ) + + suspend fun itemDetails( + apiKey: String, + itemId: String, + ): DebridApiResponse = + request( + method = "GET", + url = "$BASE_URL/api/item/details?${queryString("id" to itemId)}", + apiKey = apiKey, + ) + + suspend fun directDownload( + apiKey: String, + source: String, + ): DebridApiResponse = + formRequest( + method = "POST", + url = "$BASE_URL/api/transfer/directdl", + apiKey = apiKey, + fields = listOf("src" to source), + ) + + suspend fun checkCache( + apiKey: String, + items: List, + ): DebridApiResponse { + val normalizedItems = items.map { it.trim() }.filter { it.isNotBlank() } + if (normalizedItems.isEmpty()) { + return DebridApiResponse( + status = 200, + body = PremiumizeCacheCheckDto(status = "success", response = emptyList()), + rawBody = "", + ) + } + return formRequest( + method = "POST", + url = "$BASE_URL/api/cache/check", + apiKey = apiKey, + fields = normalizedItems.map { "items[]" to it }, + ) + } + + private suspend inline fun formRequestWithoutAuth( + method: String, + url: String, + fields: List>, + ): DebridApiResponse = + requestWithoutAuth( + method = method, + url = url, + body = formBody(fields), + contentType = "application/x-www-form-urlencoded", + ) + + private suspend inline fun formRequest( + method: String, + url: String, + apiKey: String, + fields: List>, + ): DebridApiResponse = + request( + method = method, + url = url, + apiKey = apiKey, + body = formBody(fields), + contentType = "application/x-www-form-urlencoded", + ) + + private suspend inline fun requestWithoutAuth( + method: String, + url: String, + body: String = "", + contentType: String? = null, + ): DebridApiResponse { + val headers = listOfNotNull( + contentType?.let { "Content-Type" to it }, + "Accept" to "application/json", + ).toMap() + val response = httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + ) + return DebridApiResponse( + status = response.status, + body = response.decodeBody(), + rawBody = response.body, + ) + } + + private suspend inline fun request( + method: String, + url: String, + apiKey: String, + body: String = "", + contentType: String? = null, + ): DebridApiResponse { + val headers = authHeaders(apiKey) + listOfNotNull( + contentType?.let { "Content-Type" to it }, + "Accept" to "application/json", + ) + val response = httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + ) + return DebridApiResponse( + status = response.status, + body = response.decodeBody(), + rawBody = response.body, + ) + } + + private fun formBody(fields: List>): String = + fields.joinToString("&") { (key, value) -> + "${encodeFormValue(key)}=${encodeFormValue(value)}" + } + + private fun authHeaders(apiKey: String): Map = + mapOf("Authorization" to "Bearer $apiKey") + + private val PremiumizeAccountInfoDto.isSuccess: Boolean + get() = status.equals("success", ignoreCase = true) +} + object DebridCredentialValidator { suspend fun validateProvider(providerId: String, apiKey: String): Boolean { val normalized = apiKey.trim() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt index a2c27de2..4c484212 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt @@ -151,3 +151,102 @@ internal data class RealDebridUnrestrictLinkDto( val streamable: Int? = null, val type: String? = null, ) + +@Serializable +internal data class PremiumizeDeviceAuthorizationDto( + @SerialName("device_code") val deviceCode: String? = null, + @SerialName("user_code") val userCode: String? = null, + @SerialName("verification_uri") val verificationUri: String? = null, + @SerialName("verification_uri_complete") val verificationUriComplete: String? = null, + @SerialName("expires_in") val expiresIn: Int? = null, + val interval: Int? = null, + val error: String? = null, + @SerialName("error_description") val errorDescription: String? = null, +) + +@Serializable +internal data class PremiumizeDeviceTokenDto( + @SerialName("access_token") val accessToken: String? = null, + @SerialName("token_type") val tokenType: String? = null, + @SerialName("expires_in") val expiresIn: Int? = null, + val scope: String? = null, + val error: String? = null, + @SerialName("error_description") val errorDescription: String? = null, +) + +@Serializable +internal data class PremiumizeApiEnvelopeDto( + val status: String? = null, + val message: String? = null, + val code: String? = null, +) + +@Serializable +internal data class PremiumizeAccountInfoDto( + val status: String? = null, + val message: String? = null, + val code: String? = null, + @SerialName("customer_id") val customerId: String? = null, + @SerialName("premium_until") val premiumUntil: Long? = null, + @SerialName("limit_used") val limitUsed: Double? = null, + @SerialName("booster_points") val boosterPoints: Int? = null, +) + +@Serializable +internal data class PremiumizeDirectDownloadDto( + val status: String? = null, + val message: String? = null, + val code: String? = null, + val content: List? = null, +) + +@Serializable +internal data class PremiumizeDirectDownloadFileDto( + val path: String? = null, + val size: Long? = null, + val link: String? = null, +) + +@Serializable +internal data class PremiumizeCacheCheckDto( + val status: String? = null, + val message: String? = null, + val code: String? = null, + val response: List? = null, + val filename: List? = null, + val filesize: List? = null, +) + +@Serializable +internal data class PremiumizeItemListAllDto( + val status: String? = null, + val message: String? = null, + val code: String? = null, + val files: List? = null, +) + +@Serializable +internal data class PremiumizeCloudFileDto( + val id: String? = null, + val name: String? = null, + val path: String? = null, + val type: String? = null, + val size: Long? = null, + @SerialName("created_at") val createdAt: Long? = null, + @SerialName("mime_type") val mimeType: String? = null, + val link: String? = null, +) + +@Serializable +internal data class PremiumizeItemDetailsDto( + val status: String? = null, + val message: String? = null, + val code: String? = null, + val id: String? = null, + val name: String? = null, + val size: Long? = null, + @SerialName("created_at") val createdAt: Long? = null, + @SerialName("folder_id") val folderId: String? = null, + @SerialName("mime_type") val mimeType: String? = null, + val link: String? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt index 0718df7a..d70a7001 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt @@ -107,6 +107,57 @@ internal class RealDebridFileSelector { displayName().lowercase().hasVideoExtension() } +internal class PremiumizeDirectDownloadFileSelector { + fun selectFile( + files: List, + resolve: StreamClientResolve, + season: Int?, + episode: Int?, + ): PremiumizeDirectDownloadFileDto? { + val playable = files.filter { it.isPlayableVideo() } + if (playable.isEmpty()) return null + + val episodePatterns = buildEpisodePatterns( + season = season ?: resolve.season, + episode = episode ?: resolve.episode, + ) + val names = resolve.specificFileNames(episodePatterns) + if (names.isNotEmpty()) { + playable.firstNameMatch(names) { it.displayName() }?.let { + return it + } + } + + if (episodePatterns.isNotEmpty()) { + playable.firstOrNull { file -> + val fileName = file.displayName().lowercase() + episodePatterns.any { pattern -> fileName.contains(pattern) } + }?.let { + return it + } + } + + resolve.fileIdx?.let { fileIdx -> + files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let { + return it + } + if (fileIdx > 0) { + files.getOrNull(fileIdx - 1)?.takeIf { it.isPlayableVideo() }?.let { + return it + } + } + } + + return playable.maxByOrNull { it.size ?: 0L } + } + + private fun PremiumizeDirectDownloadFileDto.isPlayableVideo(): Boolean = + !link.isNullOrBlank() && displayName().lowercase().hasVideoExtension() +} + +internal fun PremiumizeDirectDownloadFileDto.displayName(): String = + path.orEmpty().substringAfterLast('/').substringAfterLast('\\').ifBlank { path.orEmpty() } + private fun String.normalizedName(): String = substringAfterLast('/') .substringBeforeLast('.') 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 93e75362..62b6b0fa 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 @@ -28,6 +28,7 @@ enum class DebridProviderAuthMethod { object DebridProviders { const val TORBOX_ID = "torbox" + const val PREMIUMIZE_ID = "premiumize" const val REAL_DEBRID_ID = "realdebrid" val Torbox = DebridProvider( @@ -43,6 +44,19 @@ object DebridProviders { ), ) + val Premiumize = DebridProvider( + id = PREMIUMIZE_ID, + displayName = "Premiumize", + shortName = "PM", + authMethod = DebridProviderAuthMethod.DeviceCode, + capabilities = setOf( + DebridProviderCapability.ClientResolve, + DebridProviderCapability.LocalTorrentCacheCheck, + DebridProviderCapability.LocalTorrentResolve, + DebridProviderCapability.CloudLibrary, + ), + ) + val RealDebrid = DebridProvider( id = REAL_DEBRID_ID, displayName = "Real-Debrid", @@ -51,7 +65,7 @@ object DebridProviders { capabilities = setOf(DebridProviderCapability.ClientResolve), ) - private val registered = listOf(Torbox, RealDebrid) + private val registered = listOf(Torbox, Premiumize, RealDebrid) fun all(): List = registered @@ -85,6 +99,19 @@ object DebridProviders { ?.let { apiKey -> DebridServiceCredential(provider, apiKey) } } + fun configuredResolverServices(settings: DebridSettings): List = + configuredServices(settings).filter { credential -> + credential.provider.supports(DebridProviderCapability.ClientResolve) || + credential.provider.supports(DebridProviderCapability.LocalTorrentResolve) + } + + fun preferredResolverService(settings: DebridSettings): DebridServiceCredential? { + val services = configuredResolverServices(settings) + if (services.isEmpty()) return null + val preferredId = byId(settings.preferredResolverProviderId)?.id + return services.firstOrNull { it.provider.id == preferredId } ?: services.firstOrNull() + } + fun configuredSourceNames(settings: DebridSettings): List = configuredServices(settings).map { instantName(it.provider.id) } 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 index b3bd1ac1..9ebe9616 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt @@ -25,6 +25,7 @@ internal interface DebridProviderApi { internal object DebridProviderApis { private val registered = listOf( TorboxDebridProviderApi(), + PremiumizeDebridProviderApi(), RealDebridProviderApi(), ) @@ -157,6 +158,60 @@ private class TorboxDebridProviderApi( } } +internal class PremiumizeDebridProviderApi( + private val fileSelector: PremiumizeDirectDownloadFileSelector = PremiumizeDirectDownloadFileSelector(), + private val clientIdProvider: () -> String = { PremiumizeConfig.CLIENT_ID }, +) : DebridProviderApi { + override val provider: DebridProvider = DebridProviders.Premiumize + + override suspend fun validateApiKey(apiKey: String): Boolean = + PremiumizeApiClient.validateApiKey(apiKey) + + override suspend fun startDeviceAuthorization(appName: String): DebridDeviceAuthorization? { + val clientId = premiumizeClientIdOrThrow() + val response = PremiumizeApiClient.startDeviceAuthorization(clientId = clientId) + return premiumizeDeviceAuthorizationFromResponse(response, provider.id) + } + + override suspend fun redeemDeviceAuthorization(deviceCode: String): DebridDeviceAuthorizationTokenResult { + val clientId = premiumizeClientIdOrThrow() + val normalized = deviceCode.trim() + if (normalized.isBlank()) return DebridDeviceAuthorizationTokenResult.Failed(null) + val response = PremiumizeApiClient.redeemDeviceAuthorization( + clientId = clientId, + deviceCode = normalized, + ) + return premiumizeDeviceAuthorizationTokenResult(response) + } + + override suspend fun resolveClientStream( + stream: StreamItem, + apiKey: String, + season: Int?, + episode: Int?, + ): DirectDebridResolveResult { + val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error + val source = resolve.magnetUri?.takeIf { it.isNotBlank() } + ?: buildMagnetUri(resolve) + ?: stream.playableDirectUrl?.takeIf { it.isNotBlank() } + ?: return DirectDebridResolveResult.Stale + return resolvePremiumizeDirectDownload( + apiKey = apiKey, + source = source, + resolve = resolve, + season = season, + episode = episode, + fallbackFilename = stream.behaviorHints.filename, + fallbackSize = stream.behaviorHints.videoSize, + fileSelector = fileSelector, + ) + } + + private fun premiumizeClientIdOrThrow(): String = + clientIdProvider().trim().takeIf { it.isNotBlank() } + ?: throw IllegalStateException("Premiumize sign-in is missing PREMIUMIZE_CLIENT_ID.") +} + private class RealDebridProviderApi( private val fileSelector: RealDebridFileSelector = RealDebridFileSelector(), ) : DebridProviderApi { @@ -245,6 +300,93 @@ private fun buildMagnetUri(resolve: StreamClientResolve): String? { } } +internal fun premiumizeDeviceAuthorizationFromResponse( + response: DebridApiResponse, + providerId: String, +): DebridDeviceAuthorization? { + val data = response.body?.takeIf { response.isSuccessful } ?: return null + val deviceCode = data.deviceCode?.takeIf { it.isNotBlank() } ?: return null + val userCode = data.userCode?.takeIf { it.isNotBlank() } ?: return null + val verificationUrl = data.verificationUri?.takeIf { it.isNotBlank() } ?: return null + return DebridDeviceAuthorization( + providerId = providerId, + deviceCode = deviceCode, + userCode = userCode, + verificationUrl = verificationUrl, + friendlyVerificationUrl = data.verificationUriComplete?.takeIf { it.isNotBlank() } + ?: verificationUrl, + intervalSeconds = data.interval?.coerceAtLeast(1) ?: 5, + expiresAt = data.expiresIn?.takeIf { it > 0 }?.let { "${it}s" }, + ) +} + +internal fun premiumizeDeviceAuthorizationTokenResult( + response: DebridApiResponse, +): DebridDeviceAuthorizationTokenResult { + val body = response.body + body?.accessToken?.takeIf { response.isSuccessful && it.isNotBlank() }?.let { accessToken -> + return DebridDeviceAuthorizationTokenResult.Authorized(accessToken) + } + return when (body?.error?.lowercase()) { + "authorization_pending", "slow_down" -> DebridDeviceAuthorizationTokenResult.Pending + "invalid_grant", "expired_token" -> DebridDeviceAuthorizationTokenResult.Expired + "access_denied" -> DebridDeviceAuthorizationTokenResult.Failed(body.errorDescription) + else -> { + if (response.status == 400 && body?.error.isNullOrBlank()) { + DebridDeviceAuthorizationTokenResult.Pending + } else { + DebridDeviceAuthorizationTokenResult.Failed(body?.errorDescription ?: body?.error ?: response.rawBody) + } + } + } +} + +internal suspend fun resolvePremiumizeDirectDownload( + apiKey: String, + source: String, + resolve: StreamClientResolve, + season: Int?, + episode: Int?, + fallbackFilename: String? = null, + fallbackSize: Long? = null, + fileSelector: PremiumizeDirectDownloadFileSelector = PremiumizeDirectDownloadFileSelector(), +): DirectDebridResolveResult { + val normalizedSource = source.trim().takeIf { it.isNotBlank() } ?: return DirectDebridResolveResult.Stale + return try { + val response = PremiumizeApiClient.directDownload(apiKey = apiKey, source = normalizedSource) + if (!response.isSuccessful) { + return when (response.status) { + 401, 403 -> DirectDebridResolveResult.Error + else -> DirectDebridResolveResult.Stale + } + } + val body = response.body ?: return DirectDebridResolveResult.Stale + if (body.status.equals("error", ignoreCase = true)) { + val message = listOfNotNull(body.message, body.code).joinToString(" ").lowercase() + return if (message.contains("cache") || message.contains("not found")) { + DirectDebridResolveResult.NotCached + } else { + DirectDebridResolveResult.Stale + } + } + val file = fileSelector.selectFile( + files = body.content.orEmpty(), + resolve = resolve, + season = season, + episode = episode, + ) ?: return DirectDebridResolveResult.Stale + val url = file.link?.takeIf { it.isNotBlank() } ?: return DirectDebridResolveResult.Stale + DirectDebridResolveResult.Success( + url = url, + filename = file.displayName().takeIf { it.isNotBlank() } ?: fallbackFilename, + videoSize = file.size ?: fallbackSize, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + DirectDebridResolveResult.Error + } +} + private fun String.toTrackerUrlOrNull(): String? { val value = trim() if (value.isBlank() || value.startsWith("dht:", ignoreCase = true)) return null 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 81582aa5..472cdbfa 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 @@ -6,6 +6,7 @@ data class DebridSettings( val enabled: Boolean = false, val cloudLibraryEnabled: Boolean = true, val providerApiKeys: Map = emptyMap(), + val preferredResolverProviderId: String = "", val instantPlaybackPreparationLimit: Int = 0, val streamMaxResults: Int = 0, val streamSortMode: DebridStreamSortMode = DebridStreamSortMode.DEFAULT, @@ -23,14 +24,29 @@ data class DebridSettings( val realDebridApiKey: String get() = apiKeyFor(DebridProviders.REAL_DEBRID_ID) + val premiumizeApiKey: String + get() = apiKeyFor(DebridProviders.PREMIUMIZE_ID) + val hasAnyApiKey: Boolean get() = DebridProviders.configuredServices(this).isNotEmpty() + val resolverServices: List + get() = DebridProviders.configuredResolverServices(this) + + val activeResolverCredential: DebridServiceCredential? + get() = DebridProviders.preferredResolverService(this) + + val activeResolverProviderId: String? + get() = activeResolverCredential?.provider?.id + + val hasResolverProvider: Boolean + get() = activeResolverCredential != null + val linkResolvingEnabled: Boolean get() = enabled val canResolvePlayableLinks: Boolean - get() = linkResolvingEnabled && hasAnyApiKey + get() = linkResolvingEnabled && hasResolverProvider val hasCloudLibraryProvider: Boolean get() = DebridProviders.configuredServices(this) 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 321fff52..a23c52f6 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 @@ -23,6 +23,7 @@ object DebridSettingsRepository { private var enabled = false private var cloudLibraryEnabled = true private var providerApiKeys = emptyMap() + private var preferredResolverProviderId = "" private var instantPlaybackPreparationLimit = 0 private var streamMaxResults = 0 private var streamSortMode = DebridStreamSortMode.DEFAULT @@ -50,7 +51,7 @@ object DebridSettingsRepository { fun setEnabled(value: Boolean) { ensureLoaded() - if (value && !hasVisibleApiKey()) return + if (value && !hasResolverProvider()) return if (enabled == value) return enabled = value publish() @@ -63,7 +64,7 @@ object DebridSettingsRepository { fun setCloudLibraryEnabled(value: Boolean) { ensureLoaded() - if (value && !hasVisibleApiKey()) return + if (value && !hasCloudLibraryProvider()) return if (cloudLibraryEnabled == value) return cloudLibraryEnabled = value publish() @@ -80,7 +81,8 @@ object DebridSettingsRepository { } else { providerApiKeys + (provider.id to normalized) } - disableIfNoKeys() + normalizePreferredResolverProviderId(save = true) + disableIfNoResolver() publish() DebridSettingsStorage.saveProviderApiKey(provider.id, normalized) } @@ -93,6 +95,22 @@ object DebridSettingsRepository { setProviderApiKey(DebridProviders.REAL_DEBRID_ID, value) } + fun setPremiumizeApiKey(value: String) { + setProviderApiKey(DebridProviders.PREMIUMIZE_ID, value) + } + + fun setPreferredResolverProviderId(providerId: String) { + ensureLoaded() + val normalized = DebridProviders.byId(providerId)?.id.orEmpty() + val next = connectedResolverProviderIds() + .firstOrNull { it == normalized } + ?: connectedResolverProviderIds().firstOrNull().orEmpty() + if (preferredResolverProviderId == next) return + preferredResolverProviderId = next + publish() + DebridSettingsStorage.savePreferredResolverProviderId(next) + } + fun setInstantPlaybackPreparationLimit(value: Int) { ensureLoaded() val normalized = normalizeDebridInstantPlaybackPreparationLimit(value) @@ -208,18 +226,46 @@ object DebridSettingsRepository { ) } - private fun disableIfNoKeys() { - if (!hasVisibleApiKey()) { + private fun disableIfNoResolver() { + if (!hasResolverProvider()) { enabled = false DebridSettingsStorage.saveEnabled(false) } } - private fun hasVisibleApiKey(): Boolean = + private fun hasCloudLibraryProvider(): Boolean = DebridProviders.visible().any { provider -> - providerApiKeys[provider.id].orEmpty().isNotBlank() + provider.supports(DebridProviderCapability.CloudLibrary) && + providerApiKeys[provider.id].orEmpty().isNotBlank() } + private fun hasResolverProvider(): Boolean = connectedResolverProviderIds().isNotEmpty() + + private fun connectedResolverProviderIds(): List = + DebridProviders.visible().filter { provider -> + ( + provider.supports(DebridProviderCapability.ClientResolve) || + provider.supports(DebridProviderCapability.LocalTorrentResolve) + ) && + providerApiKeys[provider.id].orEmpty().isNotBlank() + }.map { it.id } + + private fun normalizePreferredResolverProviderId(save: Boolean = false) { + val providerId = DebridProviders.byId(preferredResolverProviderId)?.id.orEmpty() + val connectedResolverIds = connectedResolverProviderIds() + val normalized = if (providerId in connectedResolverIds) { + providerId + } else { + connectedResolverIds.firstOrNull().orEmpty() + } + if (preferredResolverProviderId != normalized) { + preferredResolverProviderId = normalized + if (save) { + DebridSettingsStorage.savePreferredResolverProviderId(normalized) + } + } + } + private fun loadFromDisk() { hasLoaded = true providerApiKeys = DebridProviders.all() @@ -230,7 +276,12 @@ object DebridSettingsRepository { ?.let { apiKey -> provider.id to apiKey } } .toMap() - enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasVisibleApiKey() + preferredResolverProviderId = DebridSettingsStorage.loadPreferredResolverProviderId() + ?.let(DebridProviders::byId) + ?.id + .orEmpty() + normalizePreferredResolverProviderId(save = true) + enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasResolverProvider() cloudLibraryEnabled = DebridSettingsStorage.loadCloudLibraryEnabled() ?: true instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit( DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0, @@ -281,6 +332,7 @@ object DebridSettingsRepository { enabled = enabled, cloudLibraryEnabled = cloudLibraryEnabled, providerApiKeys = providerApiKeys, + preferredResolverProviderId = preferredResolverProviderId, 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 31286bb1..7d68db48 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 @@ -7,6 +7,8 @@ internal expect object DebridSettingsStorage { fun saveEnabled(enabled: Boolean) fun loadCloudLibraryEnabled(): Boolean? fun saveCloudLibraryEnabled(enabled: Boolean) + fun loadPreferredResolverProviderId(): String? + fun savePreferredResolverProviderId(providerId: String) 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 85c5cca1..f9eedc46 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 @@ -10,7 +10,9 @@ object DebridStreamPresentation { fun apply(groups: List, settings: DebridSettings): List { if (!settings.canResolvePlayableLinks) return groups return groups.map { group -> - val visibleStreams = group.streams.filterNot { stream -> stream.isUncachedDebridStream } + val visibleStreams = group.streams + .filterNot { stream -> stream.isInactiveResolverStream(settings) } + .filterNot { stream -> stream.isUncachedDebridStream } val debridStreams = visibleStreams.filter { stream -> stream.isManagedDebridStream } if (debridStreams.isEmpty()) return@map group.copy(streams = visibleStreams) @@ -53,6 +55,12 @@ object DebridStreamPresentation { DebridProviders.byId(debridCacheStatus?.providerId)?.supports(DebridProviderCapability.LocalTorrentCacheCheck) == true && debridCacheStatus?.state == StreamDebridCacheState.NOT_CACHED + private fun StreamItem.isInactiveResolverStream(settings: DebridSettings): Boolean { + val streamProviderId = DebridProviders.byId(clientResolve?.service)?.id ?: return false + val activeProviderId = settings.activeResolverProviderId ?: return false + return isDirectDebridStream && streamProviderId != activeProviderId + } + private fun applyLimits( streams: List>, preferences: DebridStreamPreferences, 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 d6b6eb8c..73c127fc 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 @@ -119,7 +119,9 @@ object DirectDebridPlaybackResolver { return false } val providerId = DebridProviders.byId(stream.clientResolve?.service)?.id ?: return false - return settings.apiKeyFor(providerId).isNotBlank() && DebridProviderApis.apiFor(providerId) != null + return providerId == settings.activeResolverProviderId && + settings.apiKeyFor(providerId).isNotBlank() && + DebridProviderApis.apiFor(providerId) != null } private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { @@ -128,7 +130,11 @@ object DirectDebridPlaybackResolver { } val providerId = DebridProviders.byId(stream.clientResolve?.service)?.id ?: return DirectDebridResolveResult.Error - val apiKey = DebridSettingsRepository.snapshot() + val settings = DebridSettingsRepository.snapshot() + if (providerId != settings.activeResolverProviderId) { + return DirectDebridResolveResult.Stale + } + val apiKey = settings .apiKeyFor(providerId) .trim() .takeIf { it.isNotBlank() } @@ -194,6 +200,7 @@ fun DirectDebridPlayableResult.toastMessage(): String? = private class LocalDebridAddonStreamResolver( private val fileSelector: TorboxFileSelector = TorboxFileSelector(), + private val premiumizeFileSelector: PremiumizeDirectDownloadFileSelector = PremiumizeDirectDownloadFileSelector(), ) { suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { val account = localTorrentResolveCredential() ?: return DirectDebridResolveResult.MissingApiKey @@ -220,6 +227,16 @@ private class LocalDebridAddonStreamResolver( return when (account.provider.id) { DebridProviders.TORBOX_ID -> resolveTorbox(stream, resolve, apiKey, magnet, season, episode) + DebridProviders.PREMIUMIZE_ID -> resolvePremiumizeDirectDownload( + apiKey = apiKey, + source = magnet, + resolve = resolve, + season = season, + episode = episode, + fallbackFilename = stream.behaviorHints.filename, + fallbackSize = stream.behaviorHints.videoSize, + fileSelector = premiumizeFileSelector, + ) else -> DirectDebridResolveResult.Error } } @@ -273,8 +290,8 @@ private class LocalDebridAddonStreamResolver( private fun localTorrentResolveCredential( settings: DebridSettings = DebridSettingsRepository.snapshot(), ): DebridServiceCredential? = - DebridProviders.configuredServices(settings) - .firstOrNull { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentResolve) } + settings.activeResolverCredential + ?.takeIf { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentResolve) } private fun StreamItem.debridResolveCacheKey(season: Int?, episode: Int?): String? { val resolve = clientResolve @@ -294,7 +311,9 @@ private fun StreamItem.debridResolveCacheKey(season: Int?, episode: Int?): Strin } resolve ?: return null val providerId = DebridProviders.byId(resolve.service)?.id ?: return null - val apiKey = DebridSettingsRepository.snapshot() + val settings = DebridSettingsRepository.snapshot() + if (providerId != settings.activeResolverProviderId) return null + val apiKey = settings .apiKeyFor(providerId) .trim() .takeIf { it.isNotBlank() } 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 0a5c429f..775259b4 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 @@ -30,7 +30,7 @@ object DirectDebridStreamPreparer { if (!settings.canResolvePlayableLinks || limit <= 0) return val candidates = prioritizeCandidates( - streams = streams, + streams = streams.filter(DirectDebridPlaybackResolver::shouldResolveToPlayableStream), limit = limit, playerSettings = playerSettings, installedAddonNames = installedAddonNames, 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 2342b198..227a6d37 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 @@ -82,8 +82,8 @@ object LocalDebridAvailabilityService { private fun cacheCheckAccount(): DebridServiceCredential? { val settings = DebridSettingsRepository.snapshot() if (!settings.canResolvePlayableLinks) return null - return DebridProviders.configuredServices(settings) - .firstOrNull { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentCacheCheck) } + return settings.activeResolverCredential + ?.takeIf { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentCacheCheck) } } } 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 index 4c40e901..59888c7a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridService.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.debrid import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.longOrNull internal data class LocalDebridCachedItem( val name: String?, @@ -14,6 +16,7 @@ internal object LocalDebridService { ): Map? = when (account.provider.id) { DebridProviders.TORBOX_ID -> checkTorboxCached(account.apiKey, hashes) + DebridProviders.PREMIUMIZE_ID -> checkPremiumizeCached(account.apiKey, hashes) else -> null } @@ -42,4 +45,38 @@ internal object LocalDebridService { if (error is CancellationException) throw error null } + + private suspend fun checkPremiumizeCached( + apiKey: String, + hashes: List, + ): Map? = + try { + val normalizedHashes = hashes + .map { it.trim().lowercase() } + .filter { it.isNotBlank() } + .distinct() + if (normalizedHashes.isEmpty()) return emptyMap() + val sources = normalizedHashes.map { hash -> "magnet:?xt=urn:btih:$hash" } + val response = PremiumizeApiClient.checkCache(apiKey = apiKey, items = sources) + val body = response.body + if (!response.isSuccessful || body?.status.equals("error", ignoreCase = true)) { + null + } else { + normalizedHashes.mapIndexedNotNull { index, hash -> + if (body?.response?.getOrNull(index) != true) return@mapIndexedNotNull null + hash to LocalDebridCachedItem( + name = body.filename?.getOrNull(index), + size = body.filesize?.getOrNull(index)?.asLongOrNull(), + ) + }.toMap() + } + } catch (error: Exception) { + if (error is CancellationException) throw error + null + } +} + +private fun kotlinx.serialization.json.JsonElement?.asLongOrNull(): Long? { + val primitive = this as? JsonPrimitive ?: return null + return primitive.longOrNull ?: primitive.content.toLongOrNull() } 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 e50ff9f3..244f5f1e 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 @@ -805,6 +805,7 @@ private fun cloudLibraryTypeLabel(type: CloudLibraryItemType): String = CloudLibraryItemType.Torrent -> stringResource(Res.string.cloud_library_type_torrents) CloudLibraryItemType.Usenet -> stringResource(Res.string.cloud_library_type_usenet) CloudLibraryItemType.WebDownload -> stringResource(Res.string.cloud_library_type_web) + CloudLibraryItemType.File -> stringResource(Res.string.cloud_library_type_files) } private fun formatCloudBytes(bytes: Long): String { 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 df6a73c2..8a5a23b5 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 @@ -67,6 +67,7 @@ import com.nuvio.app.features.debrid.DebridStreamSortCriterion import com.nuvio.app.features.debrid.DebridStreamSortDirection import com.nuvio.app.features.debrid.DebridStreamSortKey import com.nuvio.app.features.debrid.DebridStreamVisualTag +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.delay import nuvio.composeapp.generated.resources.Res @@ -87,6 +88,7 @@ import nuvio.composeapp.generated.resources.settings_debrid_device_auth_connecte import nuvio.composeapp.generated.resources.settings_debrid_device_auth_expired import nuvio.composeapp.generated.resources.settings_debrid_device_auth_failed import nuvio.composeapp.generated.resources.settings_debrid_device_auth_instructions +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_missing_configuration import nuvio.composeapp.generated.resources.settings_debrid_device_auth_open import nuvio.composeapp.generated.resources.settings_debrid_device_auth_starting import nuvio.composeapp.generated.resources.settings_debrid_device_auth_waiting @@ -113,6 +115,8 @@ import nuvio.composeapp.generated.resources.settings_debrid_name_template_descri import nuvio.composeapp.generated.resources.settings_debrid_not_set import nuvio.composeapp.generated.resources.settings_debrid_provider_description import nuvio.composeapp.generated.resources.settings_debrid_provider_device_description +import nuvio.composeapp.generated.resources.settings_debrid_resolve_with +import nuvio.composeapp.generated.resources.settings_debrid_resolve_with_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 @@ -124,6 +128,9 @@ internal fun LazyListScope.debridSettingsContent( settings: DebridSettings, ) { item { + var showResolverProviderDialog by rememberSaveable { mutableStateOf(false) } + val resolverProviders = settings.resolverServices.map { it.provider } + val activeResolverProvider = settings.activeResolverCredential?.provider SettingsSection( title = stringResource(Res.string.settings_debrid_section_title), isTablet = isTablet, @@ -147,11 +154,22 @@ internal fun LazyListScope.debridSettingsContent( title = stringResource(Res.string.settings_debrid_enable), description = stringResource(Res.string.settings_debrid_enable_description), checked = settings.canResolvePlayableLinks, - enabled = settings.hasAnyApiKey, + enabled = settings.hasResolverProvider, isTablet = isTablet, onCheckedChange = DebridSettingsRepository::setLinkResolvingEnabled, ) - if (!settings.hasAnyApiKey) { + if (settings.canResolvePlayableLinks && resolverProviders.size > 1 && activeResolverProvider != null) { + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_debrid_resolve_with), + description = stringResource(Res.string.settings_debrid_resolve_with_description), + value = activeResolverProvider.displayName, + enabled = true, + onClick = { showResolverProviderDialog = true }, + ) + } + if (!settings.hasResolverProvider) { SettingsGroupDivider(isTablet = isTablet) DebridInfoRow( isTablet = isTablet, @@ -160,6 +178,17 @@ internal fun LazyListScope.debridSettingsContent( } } } + + if (showResolverProviderDialog && resolverProviders.size > 1 && activeResolverProvider != null) { + DebridSingleChoiceDialog( + title = stringResource(Res.string.settings_debrid_resolve_with), + selectedValue = activeResolverProvider, + options = resolverProviders, + label = { provider -> provider.displayName }, + onSelected = { provider -> DebridSettingsRepository.setPreferredResolverProviderId(provider.id) }, + onDismiss = { showResolverProviderDialog = false }, + ) + } } item { @@ -1270,6 +1299,7 @@ private fun DebridDeviceAuthDialog( val startingMessage = stringResource(Res.string.settings_debrid_device_auth_starting) val waitingMessage = stringResource(Res.string.settings_debrid_device_auth_waiting) val failedMessage = stringResource(Res.string.settings_debrid_device_auth_failed) + val missingConfigurationMessage = stringResource(Res.string.settings_debrid_device_auth_missing_configuration) val expiredMessage = stringResource(Res.string.settings_debrid_device_auth_expired) val codeCopiedMessage = stringResource(Res.string.settings_debrid_device_auth_code_copied) @@ -1284,11 +1314,20 @@ private fun DebridDeviceAuthDialog( isStarting = true isPolling = false statusMessage = null - session = runCatching { + val startResult = runCatching { DebridProviderApis.apiFor(provider.id)?.startDeviceAuthorization("Nuvio") - }.getOrNull() + }.onFailure { error -> + if (error is CancellationException) throw error + } + session = startResult.getOrNull() isStarting = false - statusMessage = if (session == null) failedMessage else waitingMessage + statusMessage = if (session == null) { + startResult.exceptionOrNull()?.message?.takeIf { it.contains("PREMIUMIZE_CLIENT_ID") } + ?.let { missingConfigurationMessage } + ?: failedMessage + } else { + waitingMessage + } } LaunchedEffect(session?.deviceCode, restartNonce, isConnected) { @@ -1301,8 +1340,13 @@ private fun DebridDeviceAuthDialog( DebridProviderApis.apiFor(provider.id) ?.redeemDeviceAuthorization(activeSession.deviceCode) ?: DebridDeviceAuthorizationTokenResult.Unsupported - }.getOrElse { - DebridDeviceAuthorizationTokenResult.Failed(it.message) + }.getOrElse { error -> + if (error is CancellationException) throw error + if (error.isCancelledHttpRequest()) { + DebridDeviceAuthorizationTokenResult.Pending + } else { + DebridDeviceAuthorizationTokenResult.Failed(null) + } } isPolling = false when (result) { @@ -1321,7 +1365,11 @@ private fun DebridDeviceAuthDialog( return@LaunchedEffect } - is DebridDeviceAuthorizationTokenResult.Failed, + is DebridDeviceAuthorizationTokenResult.Failed -> { + statusMessage = result.message.toDeviceAuthStatusMessage(failedMessage) + return@LaunchedEffect + } + DebridDeviceAuthorizationTokenResult.Unsupported -> { statusMessage = failedMessage return@LaunchedEffect @@ -1401,7 +1449,7 @@ private fun DebridDeviceAuthDialog( Text( text = message, style = MaterialTheme.typography.bodySmall, - color = if (message == failedMessage || message == expiredMessage) { + color = if (message == failedMessage || message == expiredMessage || message == missingConfigurationMessage) { MaterialTheme.colorScheme.error } else { MaterialTheme.colorScheme.onSurfaceVariant @@ -1448,6 +1496,29 @@ private fun DebridDeviceAuthDialog( } } +private fun Throwable.isCancelledHttpRequest(): Boolean { + val text = listOfNotNull(message, toString()) + .joinToString(" ") + .lowercase() + return "code=-999" in text || + ("nsurlerrordomain" in text && ("cancelled" in text || "canceled" in text)) +} + +private fun String?.toDeviceAuthStatusMessage(fallback: String): String { + val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return fallback + val lower = value.lowercase() + return if ( + value.length > 180 || + "exception in http request" in lower || + "nsurlerrordomain" in lower || + "userinfo=" in lower + ) { + fallback + } else { + value + } +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun DebridApiKeyDialog( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApiTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApiTest.kt new file mode 100644 index 00000000..9e08f5a1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApiTest.kt @@ -0,0 +1,67 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.debrid.PremiumizeCloudFileDto +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PremiumizeCloudLibraryProviderApiTest { + @Test + fun `groups nested files by top-level folder and keeps root files standalone`() { + val items = premiumizeCloudItemsFromFiles( + files = listOf( + PremiumizeCloudFileDto( + id = "e01", + name = "Show.S01E01.mkv", + path = "Show/Season 01/Show.S01E01.mkv", + size = 1_000, + mimeType = "video/x-matroska", + link = "https://pm/e01", + ), + PremiumizeCloudFileDto( + id = "e02", + name = "Show.S01E02.mkv", + path = "Show/Season 01/Show.S01E02.mkv", + size = 2_000, + mimeType = "video/x-matroska", + link = "https://pm/e02", + ), + PremiumizeCloudFileDto( + id = "movie", + name = "Movie.mp4", + path = "Movie.mp4", + size = 3_000, + mimeType = "video/mp4", + link = "https://pm/movie", + ), + ), + providerId = DebridProviders.PREMIUMIZE_ID, + providerName = "Premiumize", + ) + + assertEquals(listOf("Movie.mp4", "Show"), items.map { it.name }) + assertEquals(CloudLibraryItemType.File, items.first().type) + assertEquals(listOf("Show.S01E01.mkv", "Show.S01E02.mkv"), items[1].files.map { it.name }) + assertEquals("https://pm/e01", items[1].files.first().playbackUrl) + } + + @Test + fun `marks non video and missing fields as non playable without dropping valid files`() { + val items = premiumizeCloudItemsFromFiles( + files = listOf( + PremiumizeCloudFileDto(id = "notes", name = "notes.txt", path = "Pack/notes.txt", size = 100), + PremiumizeCloudFileDto(id = "video", name = "video.avi", path = "Pack/video.avi", size = 200), + PremiumizeCloudFileDto(id = "missing", name = null, path = null, size = 300), + ), + providerId = DebridProviders.PREMIUMIZE_ID, + providerName = "Premiumize", + ) + + assertEquals(1, items.size) + assertEquals(2, items.single().files.size) + assertFalse(items.single().files.first { it.name == "notes.txt" }.playable) + assertTrue(items.single().files.first { it.name == "video.avi" }.playable) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt index ad4f9eab..04159a21 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt @@ -127,6 +127,42 @@ class DebridFileSelectorTest { assertEquals(1, selected?.id) } + @Test + fun `Premiumize direct download selector ignores non-video and matches episode`() { + val files = listOf( + PremiumizeDirectDownloadFileDto(path = "Show/Readme.txt", size = 9_000, link = "https://pm/readme"), + PremiumizeDirectDownloadFileDto(path = "Show/Show.S01E02.mkv", size = 2_000, link = "https://pm/e02"), + PremiumizeDirectDownloadFileDto(path = "Show/Show.S01E01.mkv", size = 1_000, link = "https://pm/e01"), + ) + + val selected = PremiumizeDirectDownloadFileSelector().selectFile( + files = files, + resolve = resolve(season = 1, episode = 1), + season = null, + episode = null, + ) + + assertEquals("Show/Show.S01E01.mkv", selected?.path) + } + + @Test + fun `Premiumize direct download selector falls back to largest playable file`() { + val files = listOf( + PremiumizeDirectDownloadFileDto(path = "small.mp4", size = 1_000, link = "https://pm/small"), + PremiumizeDirectDownloadFileDto(path = "large.mkv", size = 3_000, link = "https://pm/large"), + PremiumizeDirectDownloadFileDto(path = "large-without-link.mkv", size = 9_000, link = null), + ) + + val selected = PremiumizeDirectDownloadFileSelector().selectFile( + files = files, + resolve = resolve(), + season = null, + episode = null, + ) + + assertEquals("large.mkv", selected?.path) + } + private fun resolve( fileIdx: Int? = null, season: Int? = null, 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 index 31c65109..ce8a66e7 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt @@ -14,6 +14,16 @@ class DebridProviderTest { assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.CloudLibrary)) } + @Test + fun `premiumize exposes oauth and cloud service capabilities`() { + assertTrue(DebridProviders.Premiumize.visibleInUi) + assertTrue(DebridProviders.Premiumize.authMethod == DebridProviderAuthMethod.DeviceCode) + assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.ClientResolve)) + assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.LocalTorrentCacheCheck)) + assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.LocalTorrentResolve)) + assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.CloudLibrary)) + } + @Test fun `real debrid stays hidden from local addon capability paths`() { assertTrue(DebridProviders.RealDebrid.authMethod == DebridProviderAuthMethod.ApiKey) 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 8aa924d6..dbe4dd26 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 @@ -22,18 +22,36 @@ class DebridSettingsTest { val settings = DebridSettings( providerApiKeys = mapOf( DebridProviders.TORBOX_ID to "tb_key", + DebridProviders.PREMIUMIZE_ID to "pm_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) + assertEquals(listOf(DebridProviders.TORBOX_ID, DebridProviders.PREMIUMIZE_ID), services.map { it.provider.id }) + assertEquals(listOf("tb_key", "pm_key"), services.map { it.apiKey }) assertTrue(settings.hasAnyApiKey) assertFalse(DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID)) } + @Test + fun `preferred resolver uses saved provider when connected and falls back otherwise`() { + val preferred = DebridSettings( + enabled = true, + providerApiKeys = mapOf( + DebridProviders.TORBOX_ID to "tb_key", + DebridProviders.PREMIUMIZE_ID to "pm_key", + ), + preferredResolverProviderId = DebridProviders.PREMIUMIZE_ID, + ) + val fallback = preferred.copy(preferredResolverProviderId = DebridProviders.REAL_DEBRID_ID) + + assertEquals(DebridProviders.PREMIUMIZE_ID, preferred.activeResolverProviderId) + assertEquals(DebridProviders.TORBOX_ID, fallback.activeResolverProviderId) + assertTrue(preferred.canResolvePlayableLinks) + } + @Test fun `cloud library and link resolving capabilities are independent`() { val settings = DebridSettings( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/PremiumizeDeviceAuthTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/PremiumizeDeviceAuthTest.kt new file mode 100644 index 00000000..b4b87e4a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/PremiumizeDeviceAuthTest.kt @@ -0,0 +1,62 @@ +package com.nuvio.app.features.debrid + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PremiumizeDeviceAuthTest { + @Test + fun `maps pending and slow down oauth states to pending`() { + assertEquals( + DebridDeviceAuthorizationTokenResult.Pending, + premiumizeDeviceAuthorizationTokenResult(tokenError("authorization_pending")), + ) + assertEquals( + DebridDeviceAuthorizationTokenResult.Pending, + premiumizeDeviceAuthorizationTokenResult(tokenError("slow_down")), + ) + } + + @Test + fun `maps success expired denied and invalid oauth states`() { + assertTrue( + premiumizeDeviceAuthorizationTokenResult( + DebridApiResponse( + status = 200, + body = PremiumizeDeviceTokenDto(accessToken = "pm-token", tokenType = "Bearer"), + rawBody = "", + ), + ) is DebridDeviceAuthorizationTokenResult.Authorized, + ) + assertEquals( + DebridDeviceAuthorizationTokenResult.Expired, + premiumizeDeviceAuthorizationTokenResult(tokenError("invalid_grant")), + ) + assertTrue( + premiumizeDeviceAuthorizationTokenResult(tokenError("access_denied")) is + DebridDeviceAuthorizationTokenResult.Failed, + ) + } + + @Test + fun `missing Premiumize client id fails before device flow starts`() = runBlocking { + val api = PremiumizeDebridProviderApi(clientIdProvider = { "" }) + + val failed = try { + api.startDeviceAuthorization("Nuvio") + false + } catch (_: IllegalStateException) { + true + } + + assertTrue(failed) + } + + private fun tokenError(error: String): DebridApiResponse = + DebridApiResponse( + status = 400, + body = PremiumizeDeviceTokenDto(error = error, errorDescription = error), + rawBody = """{"error":"$error"}""", + ) +} 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 311bba69..d11d9c64 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 @@ -15,6 +15,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 preferredResolverProviderIdKey = "debrid_preferred_resolver_provider_id" 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 { listOf( enabledKey, cloudLibraryEnabledKey, + preferredResolverProviderIdKey, instantPlaybackPreparationLimitKey, streamMaxResultsKey, streamSortModeKey, @@ -55,6 +57,12 @@ actual object DebridSettingsStorage { saveBoolean(cloudLibraryEnabledKey, enabled) } + actual fun loadPreferredResolverProviderId(): String? = loadString(preferredResolverProviderIdKey) + + actual fun savePreferredResolverProviderId(providerId: String) { + saveString(preferredResolverProviderIdKey, providerId) + } + actual fun loadProviderApiKey(providerId: String): String? = loadString(providerApiKeyKey(providerId)) @@ -172,6 +180,7 @@ actual object DebridSettingsStorage { actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) } + loadPreferredResolverProviderId()?.let { put(preferredResolverProviderIdKey, encodeSyncString(it)) } DebridProviders.all().forEach { provider -> loadProviderApiKey(provider.id)?.let { put(providerApiKeyKey(provider.id), encodeSyncString(it)) @@ -196,6 +205,7 @@ actual object DebridSettingsStorage { payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) payload.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled) + payload.decodeSyncString(preferredResolverProviderIdKey)?.let(::savePreferredResolverProviderId) DebridProviders.all().forEach { provider -> payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey -> saveProviderApiKey(provider.id, apiKey) From 0bfd2cb99c8ebcdfa876a2a1cc2abe499912574a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 21 May 2026 14:51:30 +0530 Subject: [PATCH 10/18] String changes --- .../composeResources/values-no/strings.xml | 26 ++++---- .../composeResources/values/strings.xml | 36 +++++----- .../app/features/debrid/DebridSettings.kt | 5 +- .../features/debrid/DebridStreamFormatter.kt | 6 +- .../debrid/DebridStreamFormatterDefaults.kt | 2 +- .../features/settings/DebridSettingsPage.kt | 19 ++++-- .../debrid/DebridStreamPresentationTest.kt | 65 ++++++++++++++++++- 7 files changed, 116 insertions(+), 43 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml index c80318d5..15e482d2 100644 --- a/composeApp/src/commonMain/composeResources/values-no/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml @@ -379,7 +379,7 @@ Utseende Innhold & oppdagelse Fortsett Γ₯ se - Skytjenester + Tilkoblede tjenester Hjemmeoppsett Integrasjoner Lisenser & attribusjon @@ -588,17 +588,17 @@ Integrasjoner Metadata-berikelse-kontroller Eksterne vurderingsleverandΓΈrer - Administrer skytjenestekontoer og tilgang til skybibliotek - Skytjenester - StΓΈtte for skytjenester er eksperimentell og kan endres eller fjernes senere. + Koble til kontoer for lenker og bibliotektilgang + Tilkoblede tjenester + Disse integrasjonene er eksperimentelle og kan endres eller fjernes senere. Skybibliotek - Bla gjennom og spill filer som allerede finnes i tilkoblede skytjenester. + Bla gjennom og spill filer som allerede finnes i tilkoblede kontoer. 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. LΓΈs med - Velg hvilken tilkoblet skytjeneste som hΓ₯ndterer spillbare lenker. - Koble til en skytjenestekonto fΓΈrst. - Skytjenester + Velg hvilken tilkoblet konto som hΓ₯ndterer spillbare lenker. + Koble til en konto fΓΈrst. + Kontoer Koble til %1$s-kontoen din. Koble til %1$s-kontoen din i nettleseren. %1$s API-nΓΈkkel @@ -625,9 +625,9 @@ %1$d lenker Formatering Navnemal - Styrer hvordan navn pΓ₯ skyresultater vises. + Styrer hvordan resultatnavn vises. Beskrivelsesmal - Styrer metadata vist under hvert skyresultat. + Styrer metadata vist under hvert resultat. API-nΓΈkkel validert. Kunne ikke validere denne API-nΓΈkkelen. Legg til MDBList API-nΓΈkkel fΓΈr du skrur pΓ₯ vurderinger. @@ -1165,9 +1165,9 @@ Gjenoppta fra %1$s STØRRELSE %1$s Denne strΓΈmtypen stΓΈttes ikke - Koble til en skytjenestekonto i Innstillinger. - Denne skytjenestelenken er utgΓ₯tt. Oppdaterer resultater. - Kunne ikke Γ₯pne denne skytjenestelenken. + Koble til en konto i Innstillinger. + Denne lenken er utgΓ₯tt. Oppdaterer resultater. + Kunne ikke Γ₯pne denne lenken. Kunne ikke Γ₯pne ekstern avspiller Velg en ekstern avspiller i innstillinger fΓΈrst Ingen ekstern avspiller er tilgjengelig diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index e75ecbdf..ab3b8ab1 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -380,7 +380,7 @@ Layout Content & Discovery Continue Watching - Cloud Services + Connected Services Home Layout Integrations Licenses & Attribution @@ -589,17 +589,17 @@ Integrations Metadata enrichment controls External ratings providers - Manage cloud service accounts and cloud library access - Cloud Services - Cloud Services support is experimental and may be kept, changed, or removed later. + Connect accounts for links and library access + Connected Services + These integrations are experimental and may be kept, changed, or removed later. Cloud library - Browse and play files already in your connected cloud services. + Browse and play files already in your connected accounts. Resolve playable links Ask a connected service for playable links when a result needs it. This may add the item to that service. Resolve with - Choose which connected cloud service manages playable links. - Connect a cloud service account first. - Cloud Services + Choose which connected account handles playable links. + Connect an account first. + Accounts Connect your %1$s account. Link your %1$s account in the browser. %1$s API Key @@ -623,16 +623,16 @@ Prepare links Resolve playable links before playback starts. Links to prepare - Use a lower count when possible. Cloud services may rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time. + Use a lower count when possible. Connected services may rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time. 1 link %1$d links Formatting Name template - Controls how cloud result names appear. + Controls how result names appear. Description template - Controls the metadata shown under each cloud result. + Controls the metadata shown under each result. Reset formatting - Restore default cloud result formatting. + Restore default result formatting. API key validated. Could not validate this API key. Add your MDBList API key below before turning ratings on. @@ -1170,10 +1170,10 @@ Resume from %1$s SIZE %1$s This stream type is not supported - Connect a cloud service account in Settings. + Connect an account in Settings. Not cached on Torbox. - This cloud service link expired. Refreshing results. - Could not open this cloud service link. + This link expired. Refreshing results. + Could not open this link. Couldn't open external player Choose an external player in settings first No external player is available @@ -1333,10 +1333,10 @@ Couldn't load Trakt library Trakt Library Connect account - Connect a cloud service in Cloud Services settings to browse playable files from your cloud library. + Connect an account in Connected 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. + Open Connected Services + Turn on Cloud library in Connected Services settings to browse files from connected accounts. Cloud library is off No playable cloud files match the current filters. Nothing here yet 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 472cdbfa..8774316f 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 @@ -56,7 +56,10 @@ data class DebridSettings( get() = cloudLibraryEnabled && hasCloudLibraryProvider val hasCustomStreamFormatting: Boolean - get() = streamNameTemplate.isNotBlank() || streamDescriptionTemplate.isNotBlank() + get() = DebridStreamFormatterDefaults.NAME_TEMPLATE.isNotBlank() || + DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE.isNotBlank() || + streamNameTemplate.isNotBlank() || + streamDescriptionTemplate.isNotBlank() fun apiKeyFor(providerId: String?): String { val normalized = DebridProviders.byId(providerId)?.id diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt index c4880bc1..a148c9de 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt @@ -12,12 +12,14 @@ class DebridStreamFormatter( fun format(stream: StreamItem, settings: DebridSettings): StreamItem { if (!stream.isManagedDebridStream) return stream val values = buildValues(stream, settings) - val formattedName = engine.render(settings.streamNameTemplate, values) + val nameTemplate = settings.streamNameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } + val descriptionTemplate = settings.streamDescriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } + val formattedName = engine.render(nameTemplate, values) .lineSequence() .joinToString(" ") { it.trim() } .replace(Regex("\\s+"), " ") .trim() - val formattedDescription = engine.render(settings.streamDescriptionTemplate, values) + val formattedDescription = engine.render(descriptionTemplate, values) .lineSequence() .map { it.trim() } .filter { it.isNotBlank() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt index d6637fd4..0129ae2e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt @@ -1,7 +1,7 @@ package com.nuvio.app.features.debrid object DebridStreamFormatterDefaults { - const val NAME_TEMPLATE = "" + const val NAME_TEMPLATE = "{stream.resolution::exists[\"{stream.resolution} \"||\"\"]}{service.shortName::exists[\"{service.shortName}\"||\"Cloud\"]} Instant" const val DESCRIPTION_TEMPLATE = "" 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 8a5a23b5..bef71870 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 @@ -321,7 +321,7 @@ internal fun LazyListScope.debridSettingsContent( DebridPreferenceRow( isTablet = isTablet, title = "Max results", - description = "Limit how many cloud-service results appear.", + description = "Limit how many results appear.", value = streamMaxResultsLabel(preferences.maxResults), enabled = settings.canResolvePlayableLinks, onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS }, @@ -330,7 +330,7 @@ internal fun LazyListScope.debridSettingsContent( DebridPreferenceRow( isTablet = isTablet, title = "Sort results", - description = "Choose how cloud-service results are ordered.", + description = "Choose how results are ordered.", value = sortProfileLabel(preferences.sortCriteria), enabled = settings.canResolvePlayableLinks, onClick = { activeStreamPicker = DebridStreamPicker.SORT_MODE }, @@ -357,7 +357,7 @@ internal fun LazyListScope.debridSettingsContent( DebridPreferenceRow( isTablet = isTablet, title = "Size range", - description = "Filter cloud-service results by file size.", + description = "Filter results by file size.", value = sizeRangeLabel(preferences), enabled = settings.canResolvePlayableLinks, onClick = { activeStreamPicker = DebridStreamPicker.SIZE_RANGE }, @@ -398,7 +398,10 @@ internal fun LazyListScope.debridSettingsContent( isTablet = isTablet, title = stringResource(Res.string.settings_debrid_name_template), description = stringResource(Res.string.settings_debrid_name_template_description), - value = templatePreview(settings.streamNameTemplate), + value = templatePreview( + value = settings.streamNameTemplate, + defaultValue = DebridStreamFormatterDefaults.NAME_TEMPLATE, + ), enabled = settings.canResolvePlayableLinks, onClick = { activeTemplateField = DebridTemplateField.NAME }, ) @@ -407,7 +410,10 @@ internal fun LazyListScope.debridSettingsContent( isTablet = isTablet, title = stringResource(Res.string.settings_debrid_description_template), description = stringResource(Res.string.settings_debrid_description_template_description), - value = templatePreview(settings.streamDescriptionTemplate), + value = templatePreview( + value = settings.streamDescriptionTemplate, + defaultValue = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, + ), enabled = settings.canResolvePlayableLinks, onClick = { activeTemplateField = DebridTemplateField.DESCRIPTION }, ) @@ -450,7 +456,8 @@ private enum class DebridTemplateField { DESCRIPTION, } -private fun templatePreview(value: String): String { +private fun templatePreview(value: String, defaultValue: String): String { + if (value.trim().isBlank() || value.trim() == defaultValue.trim()) return "Default format" val firstLine = value .lineSequence() .map { it.trim() } 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 b23a17c5..d97f29c5 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 @@ -2,12 +2,17 @@ package com.nuvio.app.features.debrid import com.nuvio.app.features.streams.AddonStreamGroup import com.nuvio.app.features.streams.StreamBehaviorHints +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamClientResolveParsed +import com.nuvio.app.features.streams.StreamClientResolveRaw +import com.nuvio.app.features.streams.StreamClientResolveStream import com.nuvio.app.features.streams.StreamDebridCacheState import com.nuvio.app.features.streams.StreamDebridCacheStatus import com.nuvio.app.features.streams.StreamItem import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals +import kotlin.test.assertFalse class DebridStreamPresentationTest { @Test @@ -34,6 +39,36 @@ class DebridStreamPresentationTest { assertContains(description, "Lost.S01E01.2160p.WEB-DL.H265.AAC-NAKSU.mkv") } + @Test + fun `default formatter replaces addon source labels for managed streams`() { + val stream = premiumizeDirectStream( + name = "[P2P] Torrentio 2160p - PM Instant", + filename = "The.Boys.S03E01.Payback.2160p.WEB-DL.H265.mkv", + size = 12_000_000_000, + ) + + val presented = DebridStreamPresentation.apply( + groups = listOf( + AddonStreamGroup( + addonName = "Torrentio", + addonId = "addon:torrentio", + streams = listOf(stream), + ), + ), + settings = DebridSettings( + enabled = true, + providerApiKeys = mapOf(DebridProviders.PREMIUMIZE_ID to "pm_key"), + ), + ).single().streams.single() + + val name = presented.name.orEmpty() + assertEquals("2160p PM Instant", name) + assertFalse(name.contains("P2P", ignoreCase = true)) + assertFalse(name.contains("torrent", ignoreCase = true)) + assertFalse(name.contains("Torrentio", ignoreCase = true)) + assertFalse(name.contains("Comet", ignoreCase = true)) + } + @Test fun `applies debrid sort filters and limits without removing normal urls`() { val low = localTorboxStream( @@ -75,7 +110,7 @@ class DebridStreamPresentationTest { ), ).single().streams - assertEquals(listOf("Large", "Mid", "Resolved addon URL"), presented.map { it.name }) + assertEquals(listOf("2160p TB Instant", "1080p TB Instant", "Resolved addon URL"), presented.map { it.name }) } @Test @@ -106,7 +141,7 @@ class DebridStreamPresentationTest { ), ).single().streams - assertEquals(listOf("Cached"), presented.map { it.name }) + assertEquals(listOf("1080p TB Instant"), presented.map { it.name }) } @Test @@ -158,4 +193,30 @@ class DebridStreamPresentationTest { cachedSize = size, ), ) + + private fun premiumizeDirectStream( + name: String, + filename: String, + size: Long, + ): StreamItem = + StreamItem( + name = name, + addonName = "Torrentio", + addonId = "addon:torrentio", + clientResolve = StreamClientResolve( + type = "debrid", + service = DebridProviders.PREMIUMIZE_ID, + filename = filename, + isCached = true, + stream = StreamClientResolveStream( + raw = StreamClientResolveRaw( + filename = filename, + size = size, + parsed = StreamClientResolveParsed( + resolution = "2160p", + ), + ), + ), + ), + ) } From 800d7160b1f6f16ca1979d0c4eb9ef06a55d45fd Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 21 May 2026 15:11:42 +0530 Subject: [PATCH 11/18] ref: adjust cloud library ui and sorting --- .../composeResources/values/strings.xml | 2 + .../nuvio/app/core/ui/NuvioDropdownChip.kt | 167 +++++++++++++++++ .../app/features/library/LibraryScreen.kt | 134 ++++++++------ .../features/search/SearchDiscoverContent.kt | 169 +----------------- 4 files changed, 253 insertions(+), 219 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioDropdownChip.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ab3b8ab1..115fbf8a 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1350,6 +1350,8 @@ %1$d playable files All Refresh cloud library + Select provider + Select type Ready to play All Torrents diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioDropdownChip.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioDropdownChip.kt new file mode 100644 index 00000000..85ea9052 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioDropdownChip.kt @@ -0,0 +1,167 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +data class NuvioDropdownOption( + val key: String, + val label: String, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NuvioDropdownChip( + title: String, + label: String, + selectedKey: String?, + options: List, + enabled: Boolean = true, + onSelected: (NuvioDropdownOption) -> Unit, + modifier: Modifier = Modifier, +) { + var isSheetVisible by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + + Row( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface) + .then( + if (enabled) { + Modifier.clickable { isSheetVisible = true } + } else { + Modifier + }, + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline, + ) + } + + if (isSheetVisible) { + NuvioDropdownOptionsSheet( + title = title, + options = options, + selectedKey = selectedKey, + sheetState = sheetState, + onDismiss = { + coroutineScope.launch { + dismissNuvioBottomSheet( + sheetState = sheetState, + onDismiss = { isSheetVisible = false }, + ) + } + }, + onSelected = { option -> + onSelected(option) + coroutineScope.launch { + dismissNuvioBottomSheet( + sheetState = sheetState, + onDismiss = { isSheetVisible = false }, + ) + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NuvioDropdownOptionsSheet( + title: String, + options: List, + selectedKey: String?, + sheetState: SheetState, + onDismiss: () -> Unit, + onSelected: (NuvioDropdownOption) -> Unit, +) { + NuvioModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = nuvioSafeBottomPadding(16.dp)), + ) { + Text( + text = title, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + NuvioBottomSheetDivider() + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + ) { + itemsIndexed(options) { index, option -> + NuvioBottomSheetActionRow( + title = option.label, + onClick = { onSelected(option) }, + trailingContent = { + if (option.key == selectedKey) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + }, + ) + if (index < options.lastIndex) { + NuvioBottomSheetDivider() + } + } + } + } + } +} 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 244f5f1e..3f196705 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 @@ -9,6 +9,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,10 +20,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile @@ -57,6 +58,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository +import com.nuvio.app.core.ui.NuvioDropdownChip +import com.nuvio.app.core.ui.NuvioDropdownOption import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader @@ -204,6 +207,7 @@ fun LibraryScreen( selectedCloudItemKey = selectedCloudItemKey, onProviderSelected = { selectedProviderId = it + selectedTypeName = null selectedCloudItemKey = null }, onTypeSelected = { @@ -337,9 +341,15 @@ private fun LazyListScope.cloudLibraryContent( } else -> { - val filteredItems = uiState.items + val providerItems = uiState.items .filter { item -> selectedProviderId == null || item.providerId == selectedProviderId } - .filter { item -> selectedType == null || item.type == selectedType } + val availableTypes = providerItems + .map { item -> item.type } + .distinct() + .sortedBy { type -> type.ordinal } + val effectiveSelectedType = selectedType?.takeIf { type -> type in availableTypes } + val filteredItems = providerItems + .filter { item -> effectiveSelectedType == null || item.type == effectiveSelectedType } val selectedItem = filteredItems.firstOrNull { it.stableKey == selectedCloudItemKey } if (selectedItem != null) { @@ -355,7 +365,8 @@ private fun LazyListScope.cloudLibraryContent( CloudLibraryToolbar( uiState = uiState, selectedProviderId = selectedProviderId, - selectedType = selectedType, + selectedType = effectiveSelectedType, + availableTypes = availableTypes, onProviderSelected = onProviderSelected, onTypeSelected = onTypeSelected, onRefresh = onRefresh, @@ -445,11 +456,41 @@ private fun CloudLibraryToolbar( uiState: CloudLibraryUiState, selectedProviderId: String?, selectedType: CloudLibraryItemType?, + availableTypes: List, onProviderSelected: (String?) -> Unit, onTypeSelected: (CloudLibraryItemType?) -> Unit, onRefresh: () -> Unit, modifier: Modifier = Modifier, ) { + val providerOptions = buildList { + add(NuvioDropdownOption(key = "", label = stringResource(Res.string.cloud_library_provider_all))) + addAll( + uiState.providers.map { provider -> + NuvioDropdownOption( + key = provider.providerId, + label = provider.providerName, + ) + }, + ) + } + val typeOptions = buildList { + add(NuvioDropdownOption(key = "", label = stringResource(Res.string.cloud_library_type_all))) + addAll( + availableTypes.map { type -> + NuvioDropdownOption( + key = type.name, + label = cloudLibraryTypeLabel(type), + ) + }, + ) + } + val selectedProviderName = uiState.providers + .firstOrNull { provider -> provider.providerId == selectedProviderId } + ?.providerName + ?: stringResource(Res.string.cloud_library_provider_all) + val selectedTypeLabel = selectedType?.let { type -> cloudLibraryTypeLabel(type) } + ?: stringResource(Res.string.cloud_library_type_all) + Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -460,30 +501,35 @@ private fun CloudLibraryToolbar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - LazyRow( - modifier = Modifier.weight(1f), + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(end = 8.dp), ) { - item { - LibraryChip( - label = stringResource(Res.string.cloud_library_provider_all), - selected = selectedProviderId == null, - onClick = { onProviderSelected(null) }, - ) - } - items( - items = uiState.providers, - key = { provider -> provider.providerId }, - ) { provider -> - LibraryChip( - label = provider.providerName, - selected = selectedProviderId == provider.providerId, - loading = provider.isLoading, - error = !provider.errorMessage.isNullOrBlank(), - onClick = { onProviderSelected(provider.providerId) }, - ) - } + NuvioDropdownChip( + title = stringResource(Res.string.cloud_library_select_provider), + label = selectedProviderName, + selectedKey = selectedProviderId.orEmpty(), + options = providerOptions, + enabled = providerOptions.size > 1, + onSelected = { option -> + onProviderSelected(option.key.ifBlank { null }) + }, + ) + NuvioDropdownChip( + title = stringResource(Res.string.cloud_library_select_type), + label = selectedTypeLabel, + selectedKey = selectedType?.name.orEmpty(), + options = typeOptions, + enabled = typeOptions.size > 1, + onSelected = { option -> + val type = option.key + .takeIf { it.isNotBlank() } + ?.let(CloudLibraryItemType::valueOf) + onTypeSelected(type) + }, + ) } IconButton(onClick = onRefresh) { Icon( @@ -493,28 +539,6 @@ private fun CloudLibraryToolbar( ) } } - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(end = 16.dp), - ) { - item { - LibraryChip( - label = stringResource(Res.string.cloud_library_type_all), - selected = selectedType == null, - onClick = { onTypeSelected(null) }, - ) - } - items( - items = CloudLibraryItemType.entries, - key = { type -> type.name }, - ) { type -> - LibraryChip( - label = cloudLibraryTypeLabel(type), - selected = selectedType == type, - onClick = { onTypeSelected(type) }, - ) - } - } } } @@ -841,24 +865,16 @@ private fun CloudLibrarySkeletonToolbar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - CloudSkeletonBlock(brush = brush, width = 52.dp, height = 34.dp, cornerRadius = 18.dp) - CloudSkeletonBlock(brush = brush, width = 86.dp, height = 34.dp, cornerRadius = 18.dp) + CloudSkeletonBlock(brush = brush, width = 92.dp, height = 34.dp, cornerRadius = 12.dp) + CloudSkeletonBlock(brush = brush, width = 78.dp, height = 34.dp, cornerRadius = 12.dp) CloudSkeletonBlock( brush = brush, modifier = Modifier.weight(1f), height = 34.dp, - cornerRadius = 18.dp, + cornerRadius = 12.dp, ) CloudSkeletonBlock(brush = brush, width = 40.dp, height = 40.dp, cornerRadius = 20.dp) } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - CloudSkeletonBlock(brush = brush, width = 52.dp, height = 34.dp, cornerRadius = 18.dp) - CloudSkeletonBlock(brush = brush, width = 82.dp, height = 34.dp, cornerRadius = 18.dp) - CloudSkeletonBlock(brush = brush, width = 72.dp, height = 34.dp, cornerRadius = 18.dp) - CloudSkeletonBlock(brush = brush, width = 60.dp, height = 34.dp, cornerRadius = 18.dp) - } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index 3f1901f3..2cf53cba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -2,7 +2,6 @@ package com.nuvio.app.features.search import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -13,31 +12,16 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetState import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -49,12 +33,9 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.format.formatReleaseDateForDisplay +import com.nuvio.app.core.ui.NuvioDropdownChip +import com.nuvio.app.core.ui.NuvioDropdownOption import com.nuvio.app.core.ui.NuvioNetworkOfflineCard -import com.nuvio.app.core.ui.NuvioBottomSheetActionRow -import com.nuvio.app.core.ui.NuvioBottomSheetDivider -import com.nuvio.app.core.ui.NuvioModalBottomSheet -import com.nuvio.app.core.ui.dismissNuvioBottomSheet -import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.core.ui.posterCardClickable @@ -62,7 +43,6 @@ import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.watching.application.WatchingState -import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -174,19 +154,19 @@ private fun DiscoverFilterRow( modifier = modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - DiscoverDropdownChip( + NuvioDropdownChip( title = stringResource(Res.string.discover_select_type), label = state.selectedType?.displayTypeLabel() ?: stringResource(Res.string.discover_type), selectedKey = state.selectedType, - options = state.typeOptions.map { DiscoverOptionItem(key = it, label = it.displayTypeLabel()) }, + options = state.typeOptions.map { NuvioDropdownOption(key = it, label = it.displayTypeLabel()) }, enabled = state.typeOptions.isNotEmpty(), onSelected = { onTypeSelected(it.key) }, ) - DiscoverDropdownChip( + NuvioDropdownChip( title = stringResource(Res.string.discover_select_catalog), label = state.selectedCatalog?.catalogName ?: stringResource(Res.string.discover_catalog), selectedKey = state.selectedCatalogKey, - options = state.catalogOptions.map { option -> DiscoverOptionItem(key = option.key, label = option.catalogName) }, + options = state.catalogOptions.map { option -> NuvioDropdownOption(key = option.key, label = option.catalogName) }, enabled = state.catalogOptions.isNotEmpty(), onSelected = { onCatalogSelected(it.key) }, ) @@ -194,11 +174,11 @@ private fun DiscoverFilterRow( val selectedCatalog = state.selectedCatalog val genreOptions = buildList { if (selectedCatalog?.genreRequired != true) { - add(DiscoverOptionItem(key = "", label = stringResource(Res.string.discover_all_genres))) + add(NuvioDropdownOption(key = "", label = stringResource(Res.string.discover_all_genres))) } - addAll(state.genreOptions.map { genre -> DiscoverOptionItem(key = genre, label = genre) }) + addAll(state.genreOptions.map { genre -> NuvioDropdownOption(key = genre, label = genre) }) } - DiscoverDropdownChip( + NuvioDropdownChip( title = stringResource(Res.string.discover_select_genre), label = state.selectedGenre ?: stringResource(Res.string.discover_all_genres), selectedKey = state.selectedGenre ?: "", @@ -211,132 +191,6 @@ private fun DiscoverFilterRow( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DiscoverDropdownChip( - title: String, - label: String, - selectedKey: String?, - options: List, - enabled: Boolean, - onSelected: (DiscoverOptionItem) -> Unit, -) { - var isSheetVisible by remember { mutableStateOf(false) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val coroutineScope = rememberCoroutineScope() - - Row( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface) - .then( - if (enabled) { - Modifier.clickable { isSheetVisible = true } - } else { - Modifier - }, - ) - .padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - style = MaterialTheme.typography.labelLarge, - color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Icon( - imageVector = Icons.Rounded.KeyboardArrowDown, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline, - ) - } - - if (isSheetVisible) { - DiscoverOptionsSheet( - title = title, - options = options, - selectedKey = selectedKey, - sheetState = sheetState, - onDismiss = { - coroutineScope.launch { - dismissNuvioBottomSheet( - sheetState = sheetState, - onDismiss = { isSheetVisible = false }, - ) - } - }, - onSelected = { option -> - onSelected(option) - coroutineScope.launch { - dismissNuvioBottomSheet( - sheetState = sheetState, - onDismiss = { isSheetVisible = false }, - ) - } - }, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DiscoverOptionsSheet( - title: String, - options: List, - selectedKey: String?, - sheetState: SheetState, - onDismiss: () -> Unit, - onSelected: (DiscoverOptionItem) -> Unit, -) { - NuvioModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = nuvioSafeBottomPadding(16.dp)), - ) { - Text( - text = title, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - NuvioBottomSheetDivider() - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 420.dp), - ) { - itemsIndexed(options) { index, option -> - NuvioBottomSheetActionRow( - title = option.label, - onClick = { onSelected(option) }, - trailingContent = { - if (option.key == selectedKey) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } - }, - ) - if (index < options.lastIndex) { - NuvioBottomSheetDivider() - } - } - } - } - } -} - @Composable private fun DiscoverGridRow( items: List, @@ -518,11 +372,6 @@ private fun DiscoverEmptyStateCard( ) } -private data class DiscoverOptionItem( - val key: String, - val label: String, -) - @Composable private fun String.displayTypeLabel(): String = when (lowercase()) { From 914f4147e90812de18ef24320cb118324cd7228a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 21 May 2026 15:47:06 +0530 Subject: [PATCH 12/18] feat(streams): adjust autoplay stream selection for cloud services --- .../commonMain/kotlin/com/nuvio/app/App.kt | 14 ++- .../streams/StreamAutoPlaySelector.kt | 119 +++++++++++++++--- .../app/features/streams/StreamModels.kt | 1 + .../app/features/streams/StreamsRepository.kt | 52 ++++++-- .../streams/StreamAutoPlaySelectorTest.kt | 97 +++++++++++++- 5 files changed, 246 insertions(+), 37 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 6cd778a3..f60bdd76 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1571,9 +1571,11 @@ private fun MainAppContent( ) { is DirectDebridPlayableResult.Success -> resolved.stream else -> { - resolved.toastMessage()?.let { NuvioToastController.show(it) } - StreamsRepository.consumeAutoPlay() - if (resolved == DirectDebridPlayableResult.Stale) { + val hasNextCandidate = StreamsRepository.skipAutoPlayStream(selectedStream) + if (!hasNextCandidate) { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + } + if (!hasNextCandidate && resolved == DirectDebridPlayableResult.Stale) { StreamsRepository.reload( type = launch.type, videoId = effectiveVideoId, @@ -1588,7 +1590,11 @@ private fun MainAppContent( } else { selectedStream } - val sourceUrl = stream.playableDirectUrl ?: return@LaunchedEffect + val sourceUrl = stream.playableDirectUrl + if (sourceUrl == null) { + StreamsRepository.skipAutoPlayStream(selectedStream) + return@LaunchedEffect + } autoPlayHandled = true if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index a88faa7d..db1552c8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -41,8 +41,36 @@ object StreamAutoPlaySelector { preferredBingeGroup: String? = null, preferBingeGroupInSelection: Boolean = false, debridEnabled: Boolean = true, - ): StreamItem? { - if (streams.isEmpty()) return null + activeResolverProviderId: String? = null, + ): StreamItem? = + evaluateAutoPlayStream( + streams = streams, + mode = mode, + regexPattern = regexPattern, + source = source, + installedAddonNames = installedAddonNames, + selectedAddons = selectedAddons, + selectedPlugins = selectedPlugins, + preferredBingeGroup = preferredBingeGroup, + preferBingeGroupInSelection = preferBingeGroupInSelection, + debridEnabled = debridEnabled, + activeResolverProviderId = activeResolverProviderId, + ).stream + + fun evaluateAutoPlayStream( + streams: List, + mode: StreamAutoPlayMode, + regexPattern: String, + source: StreamAutoPlaySource, + installedAddonNames: Set, + selectedAddons: Set, + selectedPlugins: Set, + preferredBingeGroup: String? = null, + preferBingeGroupInSelection: Boolean = false, + debridEnabled: Boolean = true, + activeResolverProviderId: String? = null, + ): StreamAutoPlayEvaluation { + if (streams.isEmpty()) return StreamAutoPlayEvaluation() val sourceScopedStreams = when (source) { StreamAutoPlaySource.ALL_SOURCES -> streams @@ -57,25 +85,26 @@ object StreamAutoPlaySelector { selectedPlugins.isEmpty() || stream.addonName in selectedPlugins } } - if (candidateStreams.isEmpty()) return null - if (mode == StreamAutoPlayMode.MANUAL) return null + if (candidateStreams.isEmpty()) return StreamAutoPlayEvaluation() + if (mode == StreamAutoPlayMode.MANUAL) return StreamAutoPlayEvaluation() val targetBingeGroup = preferredBingeGroup?.trim().orEmpty() - if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { - val bingeGroupMatch = candidateStreams.firstOrNull { stream -> - stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable(debridEnabled) + val preferredReadyStream = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { + candidateStreams.firstOrNull { stream -> + stream.behaviorHints.bingeGroup == targetBingeGroup && + stream.isAutoPlayable(debridEnabled, activeResolverProviderId) } - if (bingeGroupMatch != null) return bingeGroupMatch + } else { + null } - - return when (mode) { - StreamAutoPlayMode.MANUAL -> null - StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.isAutoPlayable(debridEnabled) } + val matchingStreams = when (mode) { + StreamAutoPlayMode.MANUAL -> emptyList() + StreamAutoPlayMode.FIRST_STREAM -> candidateStreams StreamAutoPlayMode.REGEX_MATCH -> { val pattern = regexPattern.trim() val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull() - ?: return null + ?: return StreamAutoPlayEvaluation() val exclusionMatches = Regex("\\(\\?![^)]*?\\(([^)]+)\\)").findAll(pattern) @@ -89,8 +118,7 @@ object StreamAutoPlaySelector { Regex("\\b(${exclusionWords.joinToString("|")})\\b", RegexOption.IGNORE_CASE) } else null - val matchingStreams = candidateStreams.filter { stream -> - if (!stream.isAutoPlayable(debridEnabled)) return@filter false + candidateStreams.filter { stream -> val url = stream.playableDirectUrl.orEmpty() val searchableText = buildString { @@ -109,14 +137,65 @@ object StreamAutoPlaySelector { true } - - if (matchingStreams.isEmpty()) return null - matchingStreams.firstOrNull { it.isAutoPlayable(debridEnabled) } } } + if (matchingStreams.isEmpty() && preferredReadyStream == null) return StreamAutoPlayEvaluation() + + val readyStreams = buildList { + preferredReadyStream?.let(::add) + matchingStreams + .filter { it.isAutoPlayable(debridEnabled, activeResolverProviderId) } + .filterNot { it == preferredReadyStream } + .forEach(::add) + } + val selected = readyStreams.firstOrNull() + if (selected != null) { + return StreamAutoPlayEvaluation( + stream = selected, + readyStreams = readyStreams, + ) + } + + return StreamAutoPlayEvaluation( + readyStreams = readyStreams, + hasPendingDebridCandidate = matchingStreams.any { + it.isPendingDebridAutoPlay(debridEnabled, activeResolverProviderId) + }, + ) } - private fun StreamItem.isAutoPlayable(debridEnabled: Boolean): Boolean = + private fun StreamItem.isAutoPlayable( + debridEnabled: Boolean, + activeResolverProviderId: String?, + ): Boolean = playableDirectUrl != null || - (debridEnabled && isAddonDebridCandidate && (isDirectDebridStream || isCachedDebridTorrentStream)) + (debridEnabled && isAddonDebridCandidate && isReadyDebridAutoPlay(activeResolverProviderId)) + + private fun StreamItem.isReadyDebridAutoPlay(activeResolverProviderId: String?): Boolean = + when { + isDirectDebridStream -> clientResolve?.service.matchesResolver(activeResolverProviderId) + isCachedDebridTorrentStream -> debridCacheStatus?.providerId.matchesResolver(activeResolverProviderId) + else -> false + } + + private fun StreamItem.isPendingDebridAutoPlay( + debridEnabled: Boolean, + activeResolverProviderId: String?, + ): Boolean { + if (!debridEnabled || !isInstalledAddonStream || !needsLocalDebridResolve) return false + if (!debridCacheStatus?.providerId.matchesResolver(activeResolverProviderId)) return false + val state = debridCacheStatus?.state + return state == null || state == StreamDebridCacheState.CHECKING + } + + private fun String?.matchesResolver(activeResolverProviderId: String?): Boolean { + val active = activeResolverProviderId?.trim().orEmpty() + return active.isBlank() || this == null || equals(active, ignoreCase = true) + } } + +data class StreamAutoPlayEvaluation( + val stream: StreamItem? = null, + val readyStreams: List = emptyList(), + val hasPendingDebridCandidate: Boolean = false, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 55deebce..b88c8251 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -186,6 +186,7 @@ data class StreamsUiState( val isAnyLoading: Boolean = false, val emptyStateReason: StreamsEmptyStateReason? = null, val autoPlayStream: StreamItem? = null, + val autoPlayCandidates: List = emptyList(), val isDirectAutoPlayFlow: Boolean = false, val showDirectAutoPlayOverlay: Boolean = false, ) { 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 575a78e4..fffbb8be 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 @@ -232,7 +232,6 @@ object StreamsRepository { val installedAddonIds = streamAddons.map { it.addonId }.toSet() val debridAvailabilityJobs = mutableListOf() var autoSelectTriggered = false - var timeoutElapsed = false fun publishCompletion(completion: StreamLoadCompletion) { if (completions.trySend(completion).isFailure) { log.d { "Ignoring late stream load completion after channel close" } @@ -286,12 +285,10 @@ object StreamsRepository { if (timeoutMs > 0L && playerSettings.streamAutoPlayTimeoutSeconds < 11) { launch { delay(timeoutMs) - timeoutElapsed = true if (!autoSelectTriggered) { val allStreams = _uiState.value.groups.flatMap { it.streams } if (allStreams.isNotEmpty()) { - autoSelectTriggered = true - val selected = StreamAutoPlaySelector.selectAutoPlayStream( + val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream( streams = allStreams, mode = autoPlayMode, regexPattern = playerSettings.streamAutoPlayRegex, @@ -300,9 +297,18 @@ object StreamsRepository { selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, ) - _uiState.update { it.copy(autoPlayStream = selected) } - if (selected == null) { + if (evaluation.stream != null || !evaluation.hasPendingDebridCandidate) { + autoSelectTriggered = true + _uiState.update { + it.copy( + autoPlayStream = evaluation.stream, + autoPlayCandidates = evaluation.readyStreams, + ) + } + } + if (evaluation.stream == null && !evaluation.hasPendingDebridCandidate) { _uiState.update { it.copy( isDirectAutoPlayFlow = false, @@ -313,9 +319,6 @@ object StreamsRepository { } } } - } else if (timeoutMs <= 0L) { - timeoutElapsed = true - null } else { null } @@ -490,7 +493,7 @@ object StreamsRepository { if (isAutoPlayEnabled && !autoSelectTriggered) { autoSelectTriggered = true val allStreams = _uiState.value.groups.flatMap { it.streams } - val selected = StreamAutoPlaySelector.selectAutoPlayStream( + val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream( streams = allStreams, mode = autoPlayMode, regexPattern = playerSettings.streamAutoPlayRegex, @@ -499,8 +502,14 @@ object StreamsRepository { selectedAddons = playerSettings.streamAutoPlaySelectedAddons, selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, ) - _uiState.update { it.copy(autoPlayStream = selected) } + _uiState.update { + it.copy( + autoPlayStream = evaluation.stream, + autoPlayCandidates = evaluation.readyStreams, + ) + } } if (isDirectAutoPlayFlow && _uiState.value.autoPlayStream == null) { _uiState.update { @@ -522,12 +531,33 @@ object StreamsRepository { _uiState.update { it.copy( autoPlayStream = null, + autoPlayCandidates = emptyList(), isDirectAutoPlayFlow = false, showDirectAutoPlayOverlay = false, ) } } + fun skipAutoPlayStream(stream: StreamItem): Boolean { + var hasNext = false + _uiState.update { current -> + val failedIndex = current.autoPlayCandidates.indexOf(stream) + val remaining = if (failedIndex >= 0) { + current.autoPlayCandidates.drop(failedIndex + 1) + } else { + current.autoPlayCandidates.drop(1) + } + hasNext = remaining.isNotEmpty() + current.copy( + autoPlayStream = remaining.firstOrNull(), + autoPlayCandidates = remaining, + isDirectAutoPlayFlow = remaining.isNotEmpty(), + showDirectAutoPlayOverlay = remaining.isNotEmpty(), + ) + } + return hasNext + } + fun cancelLoading() { activeJob?.cancel() activeJob = null diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt index 1ebf6b84..000d00da 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt @@ -2,7 +2,9 @@ package com.nuvio.app.features.streams import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class StreamAutoPlaySelectorTest { @@ -167,27 +169,118 @@ class StreamAutoPlaySelectorTest { assertEquals(directDebrid, selected) } + @Test + fun `timeout evaluation keeps pending regex debrid candidate open`() { + val pending = stream( + addonName = "Torrentio", + name = "The Show 1080p", + infoHash = "hash-pending", + cacheState = StreamDebridCacheState.CHECKING, + ) + + val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream( + streams = listOf(pending), + mode = StreamAutoPlayMode.REGEX_MATCH, + regexPattern = "1080p", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("Torrentio"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + debridEnabled = true, + activeResolverProviderId = "premiumize", + ) + + assertNull(evaluation.stream) + assertTrue(evaluation.hasPendingDebridCandidate) + } + + @Test + fun `timeout evaluation still selects direct link while debrid candidate is pending`() { + val pending = stream( + addonName = "Torrentio", + name = "The Show 1080p", + infoHash = "hash-pending", + cacheState = StreamDebridCacheState.CHECKING, + ) + val direct = stream( + addonName = "Direct Addon", + url = "https://example.com/video.mp4", + name = "The Show 1080p", + ) + + val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream( + streams = listOf(pending, direct), + mode = StreamAutoPlayMode.REGEX_MATCH, + regexPattern = "1080p", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("Torrentio", "Direct Addon"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + debridEnabled = true, + activeResolverProviderId = "premiumize", + ) + + assertEquals(direct, evaluation.stream) + assertFalse(evaluation.hasPendingDebridCandidate) + } + + @Test + fun `direct debrid candidate must match active resolver`() { + val torbox = stream( + addonName = "Comet", + name = "TB Instant", + directDebrid = true, + directDebridService = "torbox", + ) + + val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream( + streams = listOf(torbox), + mode = StreamAutoPlayMode.FIRST_STREAM, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("Comet"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + debridEnabled = true, + activeResolverProviderId = "premiumize", + ) + + assertNull(evaluation.stream) + assertFalse(evaluation.hasPendingDebridCandidate) + } + private fun stream( addonName: String, url: String? = null, name: String? = null, bingeGroup: String? = null, directDebrid: Boolean = false, + directDebridService: String = "torbox", + infoHash: String? = null, + cacheState: StreamDebridCacheState? = null, ): StreamItem = StreamItem( name = name, url = url, + infoHash = infoHash, addonName = addonName, - addonId = addonName, + addonId = "addon:$addonName", clientResolve = if (directDebrid) { StreamClientResolve( type = "debrid", - service = "torbox", + service = directDebridService, isCached = true, infoHash = "hash", ) } else { null }, + debridCacheStatus = cacheState?.let { state -> + StreamDebridCacheStatus( + providerId = "premiumize", + providerName = "Premiumize", + state = state, + ) + }, behaviorHints = StreamBehaviorHints( bingeGroup = bingeGroup, ), From 996fd7f949bee30cccf84feb37ffe08a2d0bf275 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 21 May 2026 21:29:39 +0530 Subject: [PATCH 13/18] ref(cloud): ui adjustments --- .../app/features/debrid/DebridProviderApis.kt | 58 +++++++++++-------- .../com/nuvio/app/features/home/HomeScreen.kt | 10 +++- .../home/components/HomeSkeletonLoading.kt | 25 ++++---- .../app/features/library/LibraryScreen.kt | 52 ++++++++--------- .../nuvio/app/features/search/SearchScreen.kt | 20 +++++-- .../features/settings/DebridSettingsPage.kt | 12 ++++ .../features/debrid/TorboxDeviceAuthTest.kt | 50 ++++++++++++++++ 7 files changed, 160 insertions(+), 67 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/TorboxDeviceAuthTest.kt 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 index 9ebe9616..295179d8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt @@ -84,30 +84,7 @@ private class TorboxDebridProviderApi( val normalized = deviceCode.trim() if (normalized.isBlank()) return DebridDeviceAuthorizationTokenResult.Failed(null) val response = TorboxApiClient.redeemDeviceAuthorization(deviceCode = normalized) - val envelope = response.body - val accessToken = envelope - ?.takeIf { response.isSuccessful && it.success != false } - ?.data - ?.accessToken - ?.takeIf { it.isNotBlank() } - if (accessToken != null) { - return DebridDeviceAuthorizationTokenResult.Authorized(accessToken) - } - val message = listOfNotNull(envelope?.error, envelope?.detail, response.rawBody) - .joinToString(" ") - .lowercase() - return when { - message.contains("pending") || message.contains("not authorized") -> - DebridDeviceAuthorizationTokenResult.Pending - message.contains("expired") -> - DebridDeviceAuthorizationTokenResult.Expired - response.status == 404 || response.status == 409 || response.status == 425 -> - DebridDeviceAuthorizationTokenResult.Pending - response.status == 410 -> - DebridDeviceAuthorizationTokenResult.Expired - else -> - DebridDeviceAuthorizationTokenResult.Failed(envelope?.detail ?: envelope?.error) - } + return torboxDeviceAuthorizationTokenResult(response) } override suspend fun resolveClientStream( @@ -320,6 +297,39 @@ internal fun premiumizeDeviceAuthorizationFromResponse( ) } +internal fun torboxDeviceAuthorizationTokenResult( + response: DebridApiResponse>, +): DebridDeviceAuthorizationTokenResult { + val envelope = response.body + val accessToken = envelope + ?.takeIf { response.isSuccessful && it.success != false } + ?.data + ?.accessToken + ?.takeIf { it.isNotBlank() } + if (accessToken != null) { + return DebridDeviceAuthorizationTokenResult.Authorized(accessToken) + } + val message = listOfNotNull(envelope?.error, envelope?.detail, response.rawBody) + .joinToString(" ") + .lowercase() + return when { + message.contains("pending") || + message.contains("not authorized") || + message.contains("not been used") || + message.contains("not used yet") || + message.contains("scan the code") -> + DebridDeviceAuthorizationTokenResult.Pending + message.contains("expired") -> + DebridDeviceAuthorizationTokenResult.Expired + response.status == 404 || response.status == 409 || response.status == 425 -> + DebridDeviceAuthorizationTokenResult.Pending + response.status == 410 -> + DebridDeviceAuthorizationTokenResult.Expired + else -> + DebridDeviceAuthorizationTokenResult.Failed(envelope?.detail ?: envelope?.error) + } +} + internal fun premiumizeDeviceAuthorizationTokenResult( response: DebridApiResponse, ): DebridDeviceAuthorizationTokenResult { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 3bf4715b..a5da0fdc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -100,7 +100,10 @@ fun HomeScreen( val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle() - val homeSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() + val homeSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() val homeListState = rememberLazyListState() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() @@ -612,7 +615,10 @@ fun HomeScreen( } } items(3) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp)) + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = 16.dp), + showHeaderAccent = !homeSettingsUiState.hideCatalogUnderline, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt index 3609fd00..d95b26bc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt @@ -181,7 +181,10 @@ fun HomeSkeletonHero( } @Composable -fun HomeSkeletonRow(modifier: Modifier = Modifier) { +fun HomeSkeletonRow( + modifier: Modifier = Modifier, + showHeaderAccent: Boolean = true, +) { val brush = rememberHomeSkeletonBrush() val posterCardStyle = rememberPosterCardStyleUiState() val skeletonWidth = if (posterCardStyle.catalogLandscapeModeEnabled) { @@ -207,15 +210,17 @@ fun HomeSkeletonRow(modifier: Modifier = Modifier) { .clip(RoundedCornerShape(6.dp)) .background(brush), ) - // Accent bar - Box( - modifier = Modifier - .width(60.dp) - .height(4.dp) - .clip(RoundedCornerShape(999.dp)) - .background(brush), - ) - Spacer(modifier = Modifier.height(2.dp)) + if (showHeaderAccent) { + // Accent bar + Box( + modifier = Modifier + .width(60.dp) + .height(4.dp) + .clip(RoundedCornerShape(999.dp)) + .background(brush), + ) + Spacer(modifier = Modifier.height(2.dp)) + } // Poster row Row( horizontalArrangement = Arrangement.spacedBy(10.dp), 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 3f196705..af8b6410 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 @@ -72,6 +72,7 @@ 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.HomeCatalogSettingsRepository import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow @@ -107,6 +108,10 @@ fun LibraryScreen( WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() + val homeCatalogSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() var observedOfflineState by remember { mutableStateOf(false) } var sourceModeName by rememberSaveable { mutableStateOf(LibraryViewMode.Saved.name) } @@ -230,7 +235,10 @@ fun LibraryScreen( when { !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> { items(3) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp)) + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = 16.dp), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) } } @@ -288,6 +296,7 @@ fun LibraryScreen( librarySections( sections = uiState.sections, watchedKeys = watchedUiState.watchedKeys, + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, onPosterClick = onPosterClick, onSectionViewAllClick = onSectionViewAllClick, onPosterLongClick = onPosterLongClick, @@ -423,7 +432,7 @@ private fun LazyListScope.cloudLibrarySkeletonItems() { modifier = Modifier.padding(horizontal = 16.dp), ) } - items(4) { + items(3) { CloudLibrarySkeletonRow() } } @@ -856,24 +865,17 @@ private fun CloudLibrarySkeletonToolbar( modifier: Modifier = Modifier, ) { val brush = rememberCloudLibrarySkeletonBrush() - Column( + Row( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - CloudSkeletonBlock(brush = brush, width = 92.dp, height = 34.dp, cornerRadius = 12.dp) - CloudSkeletonBlock(brush = brush, width = 78.dp, height = 34.dp, cornerRadius = 12.dp) - CloudSkeletonBlock( - brush = brush, - modifier = Modifier.weight(1f), - height = 34.dp, - cornerRadius = 12.dp, - ) - CloudSkeletonBlock(brush = brush, width = 40.dp, height = 40.dp, cornerRadius = 20.dp) + CloudSkeletonBlock(brush = brush, width = 112.dp, height = 36.dp, cornerRadius = 12.dp) + CloudSkeletonBlock(brush = brush, width = 92.dp, height = 36.dp, cornerRadius = 12.dp) } } } @@ -903,35 +905,29 @@ private fun CloudLibrarySkeletonRow( ) { Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(7.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { CloudSkeletonBlock( brush = brush, modifier = Modifier.fillMaxWidth(0.74f), - height = 17.dp, + height = 18.dp, cornerRadius = 6.dp, ) CloudSkeletonBlock( brush = brush, - modifier = Modifier.fillMaxWidth(), - height = 12.dp, + modifier = Modifier.fillMaxWidth(0.9f), + height = 14.dp, cornerRadius = 6.dp, ) CloudSkeletonBlock( brush = brush, - modifier = Modifier.fillMaxWidth(0.58f), + modifier = Modifier.fillMaxWidth(0.52f), height = 12.dp, cornerRadius = 6.dp, ) } - CloudSkeletonBlock(brush = brush, width = 32.dp, height = 32.dp, cornerRadius = 16.dp) + CloudSkeletonBlock(brush = brush, width = 48.dp, height = 48.dp, cornerRadius = 24.dp) } - CloudSkeletonBlock( - brush = brush, - modifier = Modifier.fillMaxWidth(0.44f), - height = 4.dp, - cornerRadius = 999.dp, - ) } } } @@ -987,6 +983,7 @@ private enum class LibraryViewMode { private fun LazyListScope.librarySections( sections: List, watchedKeys: Set, + showHeaderAccent: Boolean, onPosterClick: ((LibraryItem) -> Unit)?, onSectionViewAllClick: ((LibrarySection) -> Unit)?, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?, @@ -1001,6 +998,7 @@ private fun LazyListScope.librarySections( entries = previewItems, headerHorizontalPadding = 16.dp, rowContentPadding = PaddingValues(horizontal = 16.dp), + showHeaderAccent = showHeaderAccent, onViewAllClick = if (section.items.size > LIBRARY_SECTION_PREVIEW_LIMIT) { onSectionViewAllClick?.let { { it(section) } } } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index adcaa7e6..8aba024f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -104,7 +104,10 @@ fun SearchScreen( val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val uiState by SearchRepository.uiState.collectAsStateWithLifecycle() val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle() - val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() + val homeCatalogSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() @@ -305,13 +308,19 @@ fun SearchScreen( when { isWaitingForSearch -> { items(2) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = homeSectionPadding), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) } } uiState.isLoading && uiState.sections.isEmpty() -> { items(2) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = homeSectionPadding), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) } } @@ -351,7 +360,10 @@ fun SearchScreen( } if (uiState.isLoading) { item(key = "search_loading_more") { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = homeSectionPadding), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) } } } 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 bef71870..3ea86ce7 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 @@ -123,12 +123,15 @@ import nuvio.composeapp.generated.resources.settings_debrid_section_providers import nuvio.composeapp.generated.resources.settings_debrid_section_title import org.jetbrains.compose.resources.stringResource +private const val CLOUD_SERVICES_FAQ_URL = "https://nuvioapp.space/faq#common-cloud-library-and-cloud-services" + internal fun LazyListScope.debridSettingsContent( isTablet: Boolean, settings: DebridSettings, ) { item { var showResolverProviderDialog by rememberSaveable { mutableStateOf(false) } + val uriHandler = LocalUriHandler.current val resolverProviders = settings.resolverServices.map { it.provider } val activeResolverProvider = settings.activeResolverCredential?.provider SettingsSection( @@ -141,6 +144,15 @@ internal fun LazyListScope.debridSettingsContent( text = stringResource(Res.string.settings_debrid_experimental_notice), ) SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Learn more", + description = "Cloud Library, connected accounts, and playable-link preparation.", + value = "Open", + enabled = true, + onClick = { runCatching { uriHandler.openUri(CLOUD_SERVICES_FAQ_URL) } }, + ) + SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( title = stringResource(Res.string.settings_debrid_cloud_library), description = stringResource(Res.string.settings_debrid_cloud_library_description), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/TorboxDeviceAuthTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/TorboxDeviceAuthTest.kt new file mode 100644 index 00000000..594a985a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/TorboxDeviceAuthTest.kt @@ -0,0 +1,50 @@ +package com.nuvio.app.features.debrid + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TorboxDeviceAuthTest { + @Test + fun `maps unused device code response to pending`() { + val response = DebridApiResponse( + status = 400, + body = TorboxEnvelopeDto( + success = false, + detail = "This device code has not been used yet. Please wait for the user to scan the code.", + ), + rawBody = "", + ) + + assertEquals( + DebridDeviceAuthorizationTokenResult.Pending, + torboxDeviceAuthorizationTokenResult(response), + ) + } + + @Test + fun `maps authorized and expired Torbox device states`() { + assertTrue( + torboxDeviceAuthorizationTokenResult( + DebridApiResponse( + status = 200, + body = TorboxEnvelopeDto( + success = true, + data = TorboxDeviceTokenDto(accessToken = "tb-token", tokenType = "Bearer"), + ), + rawBody = "", + ), + ) is DebridDeviceAuthorizationTokenResult.Authorized, + ) + assertEquals( + DebridDeviceAuthorizationTokenResult.Expired, + torboxDeviceAuthorizationTokenResult( + DebridApiResponse( + status = 410, + body = TorboxEnvelopeDto(success = false, detail = "Device code expired."), + rawBody = "", + ), + ), + ) + } +} From 29f78a98cb5e4383d9a1eb970d8c4055b267c021 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 22 May 2026 12:29:35 +0530 Subject: [PATCH 14/18] fix: cw seeding and series continuity --- .../details/SeriesPlaybackResolver.kt | 21 +- .../com/nuvio/app/features/home/HomeScreen.kt | 338 ++++++++---------- .../watching/domain/SeriesContinuity.kt | 5 +- .../details/SeriesPlaybackResolverTest.kt | 41 +++ .../nuvio/app/features/home/HomeScreenTest.kt | 109 +++++- 5 files changed, 320 insertions(+), 194 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt index d2210058..9e655f77 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt @@ -142,14 +142,33 @@ internal fun MetaDetails.seriesPrimaryAction( watchedItems: List, todayIsoDate: String, preferFurthestEpisode: Boolean = true, + showUnairedNextUp: Boolean = false, +): SeriesPrimaryAction? = + seriesPrimaryAction( + content = WatchingContentRef(type = type, id = id), + entries = entries, + watchedItems = watchedItems, + todayIsoDate = todayIsoDate, + preferFurthestEpisode = preferFurthestEpisode, + showUnairedNextUp = showUnairedNextUp, + ) + +internal fun MetaDetails.seriesPrimaryAction( + content: WatchingContentRef, + entries: List, + watchedItems: List, + todayIsoDate: String, + preferFurthestEpisode: Boolean = true, + showUnairedNextUp: Boolean = false, ): SeriesPrimaryAction? = decideSeriesPrimaryAction( - content = WatchingContentRef(type = type, id = id), + content = content, episodes = videos.map(MetaVideo::toDomainReleasedEpisode), progressRecords = entries.map(WatchProgressEntry::toDomainProgressRecord), watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord), todayIsoDate = todayIsoDate, preferFurthestEpisode = preferFurthestEpisode, + showUnairedNextUp = showUnairedNextUp, )?.toLegacySeriesPrimaryAction() internal fun MetaVideo.playLabel(): String = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 3bf4715b..6f1c422b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -21,8 +21,11 @@ import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetailsRepository -import com.nuvio.app.features.details.nextReleasedEpisodeAfter +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.details.SeriesPrimaryAction +import com.nuvio.app.features.details.seriesPrimaryAction import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeContinueWatchingSection import com.nuvio.app.features.home.components.HomeEmptyStateCard @@ -44,6 +47,7 @@ import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode +import com.nuvio.app.features.watchprogress.isMalformedNextUpSeedContentId import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching @@ -51,14 +55,12 @@ import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueW import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressRepository -import com.nuvio.app.features.watchprogress.WatchProgressSourceLocal -import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback -import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle import com.nuvio.app.features.watchprogress.continueWatchingEntries import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem +import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.isReleasedBy import com.nuvio.app.features.collection.CollectionRepository @@ -167,47 +169,41 @@ fun HomeScreen( ) } - val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) { - if (isTraktProgressActive) emptyList() else watchedUiState.items - } - - val allNextUpSeedEntries = remember( + val allNextUpSeedCandidates = remember( watchProgressUiState.entries, - effectiveWatchedItems, + watchedUiState.items, isTraktProgressActive, continueWatchingPreferences.upNextFromFurthestEpisode, ) { - buildTvParityNextUpSeedEntries( + buildHomeNextUpSeedCandidates( progressEntries = watchProgressUiState.entries, - watchedItems = effectiveWatchedItems, + watchedItems = watchedUiState.items, isTraktProgressActive = isTraktProgressActive, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, nowEpochMs = WatchProgressClock.nowEpochMs(), ) } - val recentNextUpSeedEntries = remember( - allNextUpSeedEntries, + val recentNextUpSeedCandidates = remember( + allNextUpSeedCandidates, isTraktProgressActive, traktSettingsUiState.continueWatchingDaysCap, ) { - filterEntriesForTraktContinueWatchingWindow( - entries = allNextUpSeedEntries, + filterHomeNextUpCandidatesForTraktContinueWatchingWindow( + candidates = allNextUpSeedCandidates, isTraktProgressActive = isTraktProgressActive, daysCap = traktSettingsUiState.continueWatchingDaysCap, nowEpochMs = WatchProgressClock.nowEpochMs(), ) } - val activeNextUpSeedContentIds = remember(allNextUpSeedEntries) { - allNextUpSeedEntries.mapTo(mutableSetOf()) { entry -> entry.parentMetaId } + val activeNextUpSeedContentIds = remember(allNextUpSeedCandidates) { + allNextUpSeedCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id } } - val currentNextUpSeedByContentId = remember(allNextUpSeedEntries) { - allNextUpSeedEntries.mapNotNull { entry -> - val season = entry.seasonNumber ?: return@mapNotNull null - val episode = entry.episodeNumber ?: return@mapNotNull null - entry.parentMetaId to (season to episode) + val currentNextUpSeedByContentId = remember(allNextUpSeedCandidates) { + allNextUpSeedCandidates.associate { candidate -> + candidate.content.id to (candidate.seasonNumber to candidate.episodeNumber) }.toMap() } @@ -215,10 +211,10 @@ fun HomeScreen( effectiveWatchProgressEntries.continueWatchingEntries() } - val latestCompletedAtBySeries = remember(allNextUpSeedEntries) { - allNextUpSeedEntries - .groupBy { entry -> entry.parentMetaId } - .mapValues { (_, entries) -> entries.maxOfOrNull { entry -> entry.lastUpdatedEpochMs } ?: Long.MIN_VALUE } + val latestCompletedAtBySeries = remember(allNextUpSeedCandidates) { + allNextUpSeedCandidates + .groupBy { candidate -> candidate.content.id } + .mapValues { (_, candidates) -> candidates.maxOfOrNull { candidate -> candidate.markedAtEpochMs } ?: Long.MIN_VALUE } } val nextUpSuppressedSeriesIds = remember(visibleContinueWatchingEntries, latestCompletedAtBySeries) { @@ -236,17 +232,9 @@ fun HomeScreen( .toSet() } - val completedSeriesCandidates = remember(recentNextUpSeedEntries, nextUpSuppressedSeriesIds) { - recentNextUpSeedEntries.mapNotNull { seed -> - val season = seed.seasonNumber ?: return@mapNotNull null - val episode = seed.episodeNumber ?: return@mapNotNull null - if (season == 0 || seed.parentMetaId in nextUpSuppressedSeriesIds) return@mapNotNull null - CompletedSeriesCandidate( - content = WatchingContentRef(type = seed.parentMetaType, id = seed.parentMetaId), - seasonNumber = season, - episodeNumber = episode, - markedAtEpochMs = seed.lastUpdatedEpochMs, - ) + val completedSeriesCandidates = remember(recentNextUpSeedCandidates, nextUpSuppressedSeriesIds) { + recentNextUpSeedCandidates.filter { candidate -> + candidate.content.id !in nextUpSuppressedSeriesIds } } val profileState by ProfileRepository.state.collectAsStateWithLifecycle() @@ -256,6 +244,17 @@ fun HomeScreen( var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf>(emptySet()) } val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() } + val shouldValidateMissingNextUpSeeds = remember( + isTraktProgressActive, + watchProgressUiState.hasLoadedRemoteProgress, + watchedUiState.isLoaded, + ) { + if (isTraktProgressActive) { + watchProgressUiState.hasLoadedRemoteProgress + } else { + watchedUiState.isLoaded + } + } val cachedNextUpItems = remember( cachedSnapshots.first, continueWatchingPreferences.dismissedNextUpKeys, @@ -263,6 +262,7 @@ fun HomeScreen( currentNextUpSeedByContentId, isTraktProgressActive, watchProgressUiState.hasLoadedRemoteProgress, + shouldValidateMissingNextUpSeeds, processedNextUpContentIds, nextUpItemsBySeries, continueWatchingPreferences.showUnairedNextUp, @@ -270,25 +270,13 @@ fun HomeScreen( ) { cachedSnapshots.first.mapNotNull { cached -> if ( - !isTraktProgressActive && - watchedUiState.isLoaded && - cached.contentId !in activeNextUpSeedContentIds - ) { - return@mapNotNull null - } - if ( - isTraktProgressActive && - watchProgressUiState.hasLoadedRemoteProgress && + shouldValidateMissingNextUpSeeds && cached.contentId !in activeNextUpSeedContentIds ) { return@mapNotNull null } val currentSeed = currentNextUpSeedByContentId[cached.contentId] - if ( - currentSeed != null && - cached.seedSeason != null && - cached.seedEpisode != null - ) { + if (currentSeed != null) { val (currentSeason, currentEpisode) = currentSeed val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode if (seedChanged) return@mapNotNull null @@ -321,8 +309,16 @@ fun HomeScreen( nextUpItemsBySeries, cachedNextUpItems, continueWatchingPreferences.dismissedNextUpKeys, + activeNextUpSeedContentIds, + currentNextUpSeedByContentId, + shouldValidateMissingNextUpSeeds, ) { - val liveNextUpItems = nextUpItemsBySeries.filterValues { (_, item) -> + val liveNextUpItems = filterNextUpItemsByCurrentSeeds( + nextUpItemsBySeries = nextUpItemsBySeries, + activeSeedContentIds = activeNextUpSeedContentIds, + currentSeedByContentId = currentNextUpSeedByContentId, + shouldDropItemsWithoutActiveSeed = shouldValidateMissingNextUpSeeds, + ).filterValues { (_, item) -> nextUpDismissKey( item.parentMetaId, item.nextUpSeedSeasonNumber, @@ -396,6 +392,9 @@ fun HomeScreen( visibleContinueWatchingEntries, metaProviderKey, continueWatchingPreferences.showUnairedNextUp, + continueWatchingPreferences.upNextFromFurthestEpisode, + watchProgressUiState.entries, + watchedUiState.items, ) { if (completedSeriesCandidates.isEmpty()) { nextUpItemsBySeries = emptyMap() @@ -446,12 +445,18 @@ fun HomeScreen( if (meta == null) { return@withPermit null } - val nextEpisode = meta.nextReleasedEpisodeAfter( - seasonNumber = completedEntry.seasonNumber, - episodeNumber = completedEntry.episodeNumber, + val action = meta.seriesPrimaryAction( + content = completedEntry.content, + entries = watchProgressUiState.entries, + watchedItems = watchedUiState.items, todayIsoDate = todayIsoDate, + preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, ) + if (action?.resumePositionMs != null) { + return@withPermit null + } + val nextEpisode = action?.let { meta.videoForSeriesAction(it) } if (nextEpisode == null) { return@withPermit null } @@ -717,40 +722,99 @@ internal fun filterEntriesForTraktContinueWatchingWindow( return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs } } -private fun buildTvParityNextUpSeedEntries( +internal fun filterHomeNextUpCandidatesForTraktContinueWatchingWindow( + candidates: List, + isTraktProgressActive: Boolean, + daysCap: Int, + nowEpochMs: Long, +): List { + if (!isTraktProgressActive) return candidates + val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap) + if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return candidates + + val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY) + return candidates.filter { candidate -> candidate.markedAtEpochMs >= cutoffMs } +} + +internal fun buildHomeNextUpSeedCandidates( progressEntries: List, watchedItems: List, isTraktProgressActive: Boolean, preferFurthestEpisode: Boolean, nowEpochMs: Long, -): List { - val rawSeeds = if (isTraktProgressActive) { - progressEntries.asSequence() - .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } - .filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 } - .filter { entry -> shouldUseAsTraktNextUpSeed(entry, nowEpochMs) } - .toList() - } else { - watchedItems.asSequence() - .filter { item -> item.type.isSeriesTypeForContinueWatching() } - .filter { item -> item.season != null && item.episode != null && item.season != 0 } - .filter { item -> !isMalformedNextUpSeedContentId(item.id) } - .map { item -> item.toNextUpSeedEntry() } - .toList() +): List { + val progressSeeds = progressEntries + .asSequence() + .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } + .filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 } + .filter { entry -> !isMalformedNextUpSeedContentId(entry.parentMetaId) } + .filter { entry -> + if (isTraktProgressActive) { + shouldUseAsTraktNextUpSeed(entry = entry, nowEpochMs = nowEpochMs) + } else { + entry.shouldUseAsCompletedSeedForContinueWatching() + } + } + .toList() + val watchedSeeds = watchedItems.filter { item -> + item.type.isSeriesTypeForContinueWatching() && + item.season != null && + item.episode != null && + item.season != 0 && + !isMalformedNextUpSeedContentId(item.id) } - return if (isTraktProgressActive) { - mergeTvTraktNextUpSeeds(rawSeeds) - } else { - rawSeeds - .groupBy { entry -> nextUpSeedKey(entry) } - .mapNotNull { (_, entries) -> - choosePreferredNextUpSeed( - entries = entries, - preferFurthestEpisode = preferFurthestEpisode, - ) - } - .sortedByDescending { entry -> entry.lastUpdatedEpochMs } + return WatchingState.latestCompletedBySeries( + progressEntries = progressSeeds, + watchedItems = watchedSeeds, + preferFurthestEpisode = preferFurthestEpisode, + ).mapNotNull { (content, completed) -> + if (!content.type.isSeriesTypeForContinueWatching()) return@mapNotNull null + if (completed.seasonNumber == 0) return@mapNotNull null + if (isMalformedNextUpSeedContentId(content.id)) return@mapNotNull null + CompletedSeriesCandidate( + content = content, + seasonNumber = completed.seasonNumber, + episodeNumber = completed.episodeNumber, + markedAtEpochMs = completed.markedAtEpochMs, + ) + }.sortedWith( + compareByDescending { candidate -> candidate.markedAtEpochMs } + .thenByDescending { candidate -> candidate.seasonNumber } + .thenByDescending { candidate -> candidate.episodeNumber }, + ) +} + +internal fun filterNextUpItemsByCurrentSeeds( + nextUpItemsBySeries: Map>, + activeSeedContentIds: Set, + currentSeedByContentId: Map>, + shouldDropItemsWithoutActiveSeed: Boolean, +): Map> = + nextUpItemsBySeries.filter { (contentId, pair) -> + if (shouldDropItemsWithoutActiveSeed && contentId !in activeSeedContentIds) { + return@filter false + } + val item = pair.second + val currentSeed = currentSeedByContentId[contentId] ?: return@filter true + item.nextUpSeedSeasonNumber == currentSeed.first && + item.nextUpSeedEpisodeNumber == currentSeed.second + } + +private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaVideo? { + if (action.seasonNumber != null && action.episodeNumber != null) { + videos.firstOrNull { video -> + video.season == action.seasonNumber && + video.episode == action.episodeNumber + }?.let { return it } + } + return videos.firstOrNull { video -> + com.nuvio.app.features.watchprogress.buildPlaybackVideoId( + parentMetaId = id, + seasonNumber = video.season, + episodeNumber = video.episode, + fallbackVideoId = video.id, + ) == action.videoId || video.id == action.videoId } } @@ -765,103 +829,6 @@ private fun shouldUseAsTraktNextUpSeed( return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS } -private fun WatchedItem.toNextUpSeedEntry(): WatchProgressEntry = - WatchProgressEntry( - contentType = type, - parentMetaId = id, - parentMetaType = type, - videoId = id, - title = name, - poster = poster, - seasonNumber = season, - episodeNumber = episode, - lastPositionMs = 1L, - durationMs = 1L, - lastUpdatedEpochMs = markedAtEpochMs, - isCompleted = true, - progressPercent = 100f, - source = WatchProgressSourceLocal, - ) - -private fun nextUpSeedKey(entry: WatchProgressEntry): String = - entry.parentMetaId.trim() - -private fun mergeTvTraktNextUpSeeds(entries: List): List { - val merged = linkedMapOf() - entries - .filter { entry -> entry.source == WatchProgressSourceTraktShowProgress } - .forEach { seed -> - merged[nextUpSeedKey(seed)] = seed - } - entries - .filter { entry -> entry.source == WatchProgressSourceTraktHistory || entry.source == WatchProgressSourceTraktPlayback } - .forEach { seed -> - val key = nextUpSeedKey(seed) - val existing = merged[key] - if (existing == null || shouldReplaceNextUpSeed(existing, seed)) { - merged[key] = seed - } - } - return merged.values.sortedByDescending { entry -> entry.lastUpdatedEpochMs } -} - -private fun shouldReplaceNextUpSeed( - existing: WatchProgressEntry, - candidate: WatchProgressEntry, -): Boolean { - val candidateSeason = candidate.seasonNumber ?: -1 - val candidateEpisode = candidate.episodeNumber ?: -1 - val existingSeason = existing.seasonNumber ?: -1 - val existingEpisode = existing.episodeNumber ?: -1 - return candidateSeason > existingSeason || - ( - candidateSeason == existingSeason && - ( - candidateEpisode > existingEpisode || - ( - candidateEpisode == existingEpisode && - candidate.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs - ) - ) - ) -} - -private fun choosePreferredNextUpSeed( - entries: List, - preferFurthestEpisode: Boolean, -): WatchProgressEntry? { - if (entries.isEmpty()) return null - val bestRank = entries.minOf(::nextUpSeedSourceRank) - return entries - .asSequence() - .filter { entry -> nextUpSeedSourceRank(entry) == bestRank } - .maxWithOrNull( - if (preferFurthestEpisode) { - compareBy( - { it.seasonNumber ?: -1 }, - { it.episodeNumber ?: -1 }, - { it.lastUpdatedEpochMs }, - ) - } else { - compareBy( - { it.lastUpdatedEpochMs }, - { it.seasonNumber ?: -1 }, - { it.episodeNumber ?: -1 }, - ) - }, - ) -} - -private fun nextUpSeedSourceRank(entry: WatchProgressEntry): Int = - when (entry.source) { - WatchProgressSourceTraktPlayback, - WatchProgressSourceTraktShowProgress, - -> 0 - WatchProgressSourceTraktHistory -> 1 - WatchProgressSourceLocal -> 2 - else -> 4 - } - private fun shouldTreatAsActiveInProgressForNextUpSuppression( progress: WatchProgressEntry, latestCompletedAt: Long?, @@ -871,15 +838,6 @@ private fun shouldTreatAsActiveInProgressForNextUpSuppression( return progress.lastUpdatedEpochMs >= latestCompletedAt } -private fun isMalformedNextUpSeedContentId(contentId: String?): Boolean { - val trimmed = contentId?.trim().orEmpty() - if (trimmed.isEmpty()) return true - return when (trimmed.lowercase()) { - "tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true - else -> false - } -} - private fun heroMobileBelowSectionHeightHint( maxWidthDp: Float, continueWatchingVisible: Boolean, @@ -994,7 +952,7 @@ private fun applyStreamingStyleSort( return sortedReleased + sortedUnreleased } -private data class CompletedSeriesCandidate( +internal data class CompletedSeriesCandidate( val content: WatchingContentRef, val seasonNumber: Int, val episodeNumber: Int, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt index 10263a55..d3c2bc37 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt @@ -41,6 +41,7 @@ fun nextReleasedEpisodeAfter( seasonNumber: Int?, episodeNumber: Int?, todayIsoDate: String, + showUnairedNextUp: Boolean = false, ): WatchingReleasedEpisode? { val sortedEpisodes = episodes.sortedWith( compareBy({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }), @@ -76,7 +77,7 @@ fun nextReleasedEpisodeAfter( candidateSeasonNumber = episode.seasonNumber, todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate, - showUnairedNextUp = false, + showUnairedNextUp = showUnairedNextUp, ) } return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } @@ -89,6 +90,7 @@ fun decideSeriesPrimaryAction( watchedRecords: List, todayIsoDate: String, preferFurthestEpisode: Boolean = true, + showUnairedNextUp: Boolean = false, ): WatchingSeriesPrimaryAction? { val resumeRecord = resumeProgressForSeries( content = content, @@ -112,6 +114,7 @@ fun decideSeriesPrimaryAction( seasonNumber = latestCompletedEpisode.seasonNumber, episodeNumber = latestCompletedEpisode.episodeNumber, todayIsoDate = todayIsoDate, + showUnairedNextUp = showUnairedNextUp, ) } else { val sorted = episodes diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt index e5428e16..a70a2627 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.details import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -89,6 +90,46 @@ class SeriesPlaybackResolverTest { assertEquals("show:1:3", action.videoId) } + @Test + fun seriesPrimaryAction_uses_explicit_content_when_meta_id_is_alias() { + val meta = MetaDetails( + id = "tt1234567", + type = "series", + name = "Show", + videos = listOf( + MetaVideo(id = "s4e14", title = "Episode 14", season = 4, episode = 14, released = "2026-03-01"), + MetaVideo(id = "s4e15", title = "Episode 15", season = 4, episode = 15, released = "2026-03-08"), + ), + ) + + val action = meta.seriesPrimaryAction( + content = WatchingContentRef(type = "series", id = "tmdb:98765"), + entries = listOf( + WatchProgressEntry( + contentType = "series", + parentMetaId = "tmdb:98765", + parentMetaType = "series", + videoId = "tmdb:98765:4:14", + title = "Show", + seasonNumber = 4, + episodeNumber = 14, + lastPositionMs = 10_000L, + durationMs = 10_000L, + lastUpdatedEpochMs = 100L, + isCompleted = true, + ), + ), + watchedItems = emptyList(), + todayIsoDate = "2026-03-30", + ) + + assertNotNull(action) + assertEquals("Up Next β€’ S4E15", action.label) + assertEquals("tmdb:98765:4:15", action.videoId) + assertEquals(4, action.seasonNumber) + assertEquals(15, action.episodeNumber) + } + @Test fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() { val meta = MetaDetails( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index bb98bcbb..0b3d698d 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -2,9 +2,11 @@ package com.nuvio.app.features.home import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class HomeScreenTest { @@ -146,6 +148,85 @@ class HomeScreenTest { assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId)) } + @Test + fun `home next up seed uses completed progress when watched item lags on Nuvio Sync`() { + val completedProgress = progressEntry( + videoId = "show:4:14", + title = "Show", + seasonNumber = 4, + episodeNumber = 14, + lastUpdatedEpochMs = 2_000L, + isCompleted = true, + ) + val olderWatchedItem = watchedItem( + id = "show", + season = 4, + episode = 10, + markedAtEpochMs = 1_000L, + ) + + val result = buildHomeNextUpSeedCandidates( + progressEntries = listOf(completedProgress), + watchedItems = listOf(olderWatchedItem), + isTraktProgressActive = false, + preferFurthestEpisode = true, + nowEpochMs = 3_000L, + ) + + assertEquals(1, result.size) + assertEquals("show", result.single().content.id) + assertEquals(4, result.single().seasonNumber) + assertEquals(14, result.single().episodeNumber) + } + + @Test + fun `home next up seed uses furthest watched item when progress is older`() { + val olderCompletedProgress = progressEntry( + videoId = "show:4:10", + title = "Show", + seasonNumber = 4, + episodeNumber = 10, + lastUpdatedEpochMs = 2_000L, + isCompleted = true, + ) + val newerWatchedItem = watchedItem( + id = "show", + season = 4, + episode = 14, + markedAtEpochMs = 1_000L, + ) + + val result = buildHomeNextUpSeedCandidates( + progressEntries = listOf(olderCompletedProgress), + watchedItems = listOf(newerWatchedItem), + isTraktProgressActive = false, + preferFurthestEpisode = true, + nowEpochMs = 3_000L, + ) + + assertEquals(4, result.single().seasonNumber) + assertEquals(14, result.single().episodeNumber) + } + + @Test + fun `stale live next up item is dropped when current seed advances`() { + val staleNextUp = continueWatchingItem( + videoId = "show:4:11", + subtitle = "Up Next β€’ S4E11", + seedSeasonNumber = 4, + seedEpisodeNumber = 10, + ) + + val result = filterNextUpItemsByCurrentSeeds( + nextUpItemsBySeries = mapOf("show" to (1_000L to staleNextUp)), + activeSeedContentIds = setOf("show"), + currentSeedByContentId = mapOf("show" to (4 to 14)), + shouldDropItemsWithoutActiveSeed = true, + ) + + assertTrue(result.isEmpty()) + } + private fun progressEntry( videoId: String, title: String, @@ -153,6 +234,7 @@ class HomeScreenTest { seasonNumber: Int? = 1, episodeNumber: Int? = 4, episodeTitle: String? = "Episode", + isCompleted: Boolean = false, ): WatchProgressEntry = WatchProgressEntry( contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", @@ -166,11 +248,16 @@ class HomeScreenTest { lastPositionMs = if (seasonNumber != null && episodeNumber != null) 120_000L else 60_000L, durationMs = 1_000_000L, lastUpdatedEpochMs = lastUpdatedEpochMs, + isCompleted = isCompleted, ) private fun continueWatchingItem( videoId: String, subtitle: String, + seasonNumber: Int? = 1, + episodeNumber: Int? = 4, + seedSeasonNumber: Int? = seasonNumber, + seedEpisodeNumber: Int? = episodeNumber, ): ContinueWatchingItem = ContinueWatchingItem( parentMetaId = videoId.substringBefore(':'), @@ -179,14 +266,32 @@ class HomeScreenTest { title = "Show", subtitle = subtitle, imageUrl = null, - seasonNumber = 1, - episodeNumber = 4, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, episodeTitle = subtitle.substringAfterLast(" β€’ ", "Episode"), + isNextUp = true, + nextUpSeedSeasonNumber = seedSeasonNumber, + nextUpSeedEpisodeNumber = seedEpisodeNumber, resumePositionMs = 0L, durationMs = 0L, progressFraction = 0f, ) + private fun watchedItem( + id: String, + season: Int, + episode: Int, + markedAtEpochMs: Long, + ): WatchedItem = + WatchedItem( + id = id, + type = "series", + name = "Show", + season = season, + episode = episode, + markedAtEpochMs = markedAtEpochMs, + ) + private companion object { const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L } From 3d6d0fcfb4a842152388091373d1f5dd2aa872f9 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 22 May 2026 15:03:38 +0530 Subject: [PATCH 15/18] proper management of cloud items in cw section --- .../commonMain/kotlin/com/nuvio/app/App.kt | 144 ++++++++++++------ .../nuvio/app/core/ui/ContinueWatchingText.kt | 3 + .../ui/NuvioContinueWatchingActionSheet.kt | 24 ++- .../app/features/cloud/CloudLibraryModels.kt | 37 +++++ .../features/cloud/CloudLibraryRepository.kt | 62 ++++++++ .../components/HomeContinueWatchingSection.kt | 17 ++- .../watchprogress/WatchProgressModels.kt | 17 ++- .../features/cloud/CloudLibraryStoreTest.kt | 81 ++++++++++ .../watchprogress/WatchProgressRulesTest.kt | 18 +++ 9 files changed, 340 insertions(+), 63 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index f60bdd76..864d3010 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -106,10 +106,13 @@ import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL +import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.CloudLibraryFile import com.nuvio.app.features.cloud.CloudLibraryItem import com.nuvio.app.features.cloud.CloudLibraryPlaybackResult import com.nuvio.app.features.cloud.CloudLibraryRepository +import com.nuvio.app.features.cloud.playbackVideoId +import com.nuvio.app.features.cloud.providerPosterUrl import com.nuvio.app.features.debrid.DirectDebridPlayableResult import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver import com.nuvio.app.features.debrid.toastMessage @@ -842,6 +845,52 @@ private fun MainAppContent( } } + suspend fun launchCloudLibraryFile( + item: CloudLibraryItem, + file: CloudLibraryFile, + resumePositionMs: Long? = null, + resumeProgressFraction: Float? = null, + startFromBeginning: Boolean = false, + ): Boolean { + return when ( + val resolved = CloudLibraryRepository.resolvePlayback( + item = item, + file = file, + ) + ) { + is CloudLibraryPlaybackResult.Success -> { + val playbackTitle = resolved.filename + ?.takeIf { it.isNotBlank() } + ?: file.name.ifBlank { item.name } + val playerLaunch = PlayerLaunch( + title = playbackTitle, + sourceUrl = resolved.url, + streamTitle = playbackTitle, + streamSubtitle = item.name.takeIf { it != playbackTitle }, + providerName = item.providerName, + providerAddonId = "cloud:${item.providerId}", + poster = item.providerPosterUrl(), + contentType = CloudLibraryContentType, + videoId = item.playbackVideoId(file), + parentMetaId = item.stableKey, + parentMetaType = CloudLibraryContentType, + initialPositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L), + initialProgressFraction = if (startFromBeginning) null else resumeProgressFraction, + ) + if (playerSettingsUiState.externalPlayerEnabled) { + openExternalPlayback(playerLaunch) + true + } else { + val launchId = PlayerLaunchStore.put(playerLaunch) + navController.navigate(PlayerRoute(launchId = launchId)) + true + } + } + + else -> false + } + } + fun launchPlaybackWithDownloadPreference( type: String, videoId: String, @@ -1012,25 +1061,46 @@ private fun MainAppContent( } val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning -> - launchPlaybackWithDownloadPreference( - type = item.parentMetaType, - videoId = item.videoId, - parentMetaId = item.parentMetaId, - parentMetaType = item.parentMetaType, - title = item.title, - logo = item.logo, - poster = item.poster, - background = item.background, - seasonNumber = item.seasonNumber, - episodeNumber = item.episodeNumber, - episodeTitle = item.episodeTitle, - episodeThumbnail = item.episodeThumbnail, - pauseDescription = item.pauseDescription, - resumePositionMs = item.resumePositionMs, - resumeProgressFraction = item.resumeProgressFraction, - manualSelection = manualSelection, - startFromBeginning = startFromBeginning, - ) + if (item.isCloudLibraryContinueWatchingItem()) { + coroutineScope.launch { + val target = CloudLibraryRepository.findPlaybackTargetForProgress( + contentId = item.parentMetaId, + videoId = item.videoId, + ) + val launched = target?.let { playbackTarget -> + launchCloudLibraryFile( + item = playbackTarget.item, + file = playbackTarget.file, + resumePositionMs = item.resumePositionMs, + resumeProgressFraction = item.resumeProgressFraction, + startFromBeginning = startFromBeginning, + ) + } == true + if (!launched) { + NuvioToastController.show(cloudLibraryPlayFailedText) + } + } + } else { + launchPlaybackWithDownloadPreference( + type = item.parentMetaType, + videoId = item.videoId, + parentMetaId = item.parentMetaId, + parentMetaType = item.parentMetaType, + title = item.title, + logo = item.logo, + poster = item.poster, + background = item.background, + seasonNumber = item.seasonNumber, + episodeNumber = item.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + pauseDescription = item.pauseDescription, + resumePositionMs = item.resumePositionMs, + resumeProgressFraction = item.resumeProgressFraction, + manualSelection = manualSelection, + startFromBeginning = startFromBeginning, + ) + } } val onContinueWatchingClick: (ContinueWatchingItem) -> Unit = { item -> @@ -1165,36 +1235,8 @@ private fun MainAppContent( onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, onCloudFilePlay = { item, file -> coroutineScope.launch { - when ( - val resolved = CloudLibraryRepository.resolvePlayback( - item = item, - file = file, - ) - ) { - is CloudLibraryPlaybackResult.Success -> { - val playbackTitle = file.name.ifBlank { item.name } - val playerLaunch = PlayerLaunch( - title = playbackTitle, - sourceUrl = resolved.url, - streamTitle = playbackTitle, - streamSubtitle = item.name.takeIf { it != playbackTitle }, - providerName = item.providerName, - providerAddonId = "cloud:${item.providerId}", - contentType = "cloud", - videoId = "${item.stableKey}:${file.stableKey}", - parentMetaId = item.stableKey, - parentMetaType = "cloud", - ) - if (playerSettingsUiState.externalPlayerEnabled) { - openExternalPlayback(playerLaunch) - return@launch - } - val launchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = launchId)) - } - else -> { - NuvioToastController.show(cloudLibraryPlayFailedText) - } + if (!launchCloudLibraryFile(item = item, file = file)) { + NuvioToastController.show(cloudLibraryPlayFailedText) } } }, @@ -2161,6 +2203,7 @@ private fun MainAppContent( NuvioContinueWatchingActionSheet( item = selectedContinueWatchingForActions, showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState), + showDetailsOption = selectedContinueWatchingForActions?.isCloudLibraryContinueWatchingItem() != true, onDismiss = { selectedContinueWatchingForActions = null }, onOpenDetails = { selectedContinueWatchingForActions?.let { item -> @@ -2540,6 +2583,9 @@ private fun TabletFloatingTopBar( } } +private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean = + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) + @Composable private fun TabletTopPillItem( label: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt index 8c122e2c..f99e90b7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt @@ -1,6 +1,7 @@ package com.nuvio.app.core.ui import androidx.compose.runtime.Composable +import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.watchprogress.ContinueWatchingItem import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -18,6 +19,8 @@ fun localizedContinueWatchingSubtitle(item: ContinueWatchingItem): String { stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) item.isNextUp -> stringResource(Res.string.continue_watching_up_next) + item.parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) -> + stringResource(Res.string.library_source_cloud) else -> stringResource(Res.string.media_movie) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt index b85173d3..693017d3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.nuvio.app.features.cloud.CloudLibraryContentType +import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl import com.nuvio.app.features.watchprogress.ContinueWatchingItem import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res @@ -42,6 +44,7 @@ import org.jetbrains.compose.resources.stringResource fun NuvioContinueWatchingActionSheet( item: ContinueWatchingItem?, showManualPlayOption: Boolean, + showDetailsOption: Boolean = true, onDismiss: () -> Unit, onOpenDetails: () -> Unit, onStartFromBeginning: (() -> Unit)? = null, @@ -73,12 +76,14 @@ fun NuvioContinueWatchingActionSheet( .padding(bottom = nuvioSafeBottomPadding(16.dp)), ) { ContinueWatchingSheetHeader(item = item) - NuvioBottomSheetDivider() - NuvioBottomSheetActionRow( - icon = Icons.Default.Info, - title = stringResource(Res.string.cw_action_go_to_details), - onClick = { dismissAfter(onOpenDetails) }, - ) + if (showDetailsOption) { + NuvioBottomSheetDivider() + NuvioBottomSheetActionRow( + icon = Icons.Default.Info, + title = stringResource(Res.string.cw_action_go_to_details), + onClick = { dismissAfter(onOpenDetails) }, + ) + } if (showManualPlayOption && onPlayManually != null) { NuvioBottomSheetDivider() NuvioBottomSheetActionRow( @@ -128,10 +133,10 @@ private fun ContinueWatchingSheetHeader( val artwork = item.poster ?: item.imageUrl if (artwork != null) { AsyncImage( - model = artwork, + model = cloudLibraryDisplayArtworkUrl(artwork), contentDescription = item.title, modifier = Modifier.matchParentSize(), - contentScale = ContentScale.Crop, + contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop, ) } else { Text( @@ -167,3 +172,6 @@ private fun ContinueWatchingSheetHeader( } } } + +private fun ContinueWatchingItem.isCloudLibraryItem(): Boolean = + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) 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 dfdbcdfd..eea7f20c 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 @@ -39,6 +39,43 @@ data class CloudLibraryItem( get() = files.filter { it.playable } } +data class CloudLibraryPlaybackTarget( + val item: CloudLibraryItem, + val file: CloudLibraryFile, +) + +const val CloudLibraryContentType = "cloud" +const val TorboxCloudLibraryPosterUrl = "https://torbox.app/assets/logo-bb7a9579.svg" +const val PremiumizeCloudLibraryPosterUrl = "https://www.premiumize.me/icon_normal.svg" +private const val TorboxCloudLibraryPosterDataUrl = + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjM2NyAzMDggNzY2IDg4NCI+PHBvbHlnb24gZmlsbD0iIzAwNDQ0RCIgcG9pbnRzPSI3NDkuOTksNzQ5Ljk5IDc0OS45OSwxMTkxLjk2IDM2Ny4yNSw5NzAuOTcgMzY3LjI1LDUyOS4wMSIvPjxwb2x5Z29uIGZpbGw9IiMzNEJBOTAiIHBvaW50cz0iMTEzMi43NSw1MjkuMDEgMTEzMi43NSw5NzAuOTcgNzQ5Ljk5LDExOTEuOTYgNzQ5Ljk5LDc0OS45OSA4NzIuODcsNjc5LjA1IDk1Ni43MSw2MzAuNjYiLz48cG9seWdvbiBmaWxsPSIjNTJBMTUzIiBwb2ludHM9IjExMzIuNzUsNTI5LjAxIDc0OS45OSw3NDkuOTkgMzY3LjI1LDUyOS4wMSA3NDkuOTksMzA4LjA0Ii8+PHBvbHlnb24gZmlsbD0iI0ZGRkZGRiIgcG9pbnRzPSIxMDQzLjA0LDczOS4zNiA5NTguNjYsMTA1Ny4wOCA5NTIuNCw4NTEuODQgODM5LjcxLDkxNS4zOSA4NzIuODcsNjc5LjA1IDk1Ni43MSw2MzAuNjYgOTMxLjgxLDc5OS4yMSIvPjwvc3ZnPg==" + +fun CloudLibraryItem.playbackVideoId(file: CloudLibraryFile): String = + "$stableKey:${file.stableKey}" + +fun CloudLibraryItem.providerPosterUrl(): String? = + cloudLibraryProviderPosterUrl(providerId) + +fun cloudLibraryProviderPosterUrl(providerIdOrContentId: String?): String? = + when (providerIdOrContentId.normalizedCloudLibraryProviderId()) { + "torbox" -> TorboxCloudLibraryPosterUrl + "premiumize" -> PremiumizeCloudLibraryPosterUrl + else -> null + } + +fun cloudLibraryDisplayArtworkUrl(url: String?): String? = + when (url?.trim()) { + TorboxCloudLibraryPosterUrl -> TorboxCloudLibraryPosterDataUrl + else -> url?.trim() + } + +private fun String?.normalizedCloudLibraryProviderId(): String = + orEmpty() + .trim() + .removePrefix("$CloudLibraryContentType:") + .substringBefore(':') + .lowercase() + data class CloudLibraryProviderState( val provider: DebridProvider, val isLoading: Boolean = false, 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 c9eaa43c..01543dfd 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 @@ -121,6 +121,29 @@ object CloudLibraryRepository { } } + suspend fun findPlaybackTargetForProgress( + contentId: String, + videoId: String, + ): CloudLibraryPlaybackTarget? { + DebridSettingsRepository.ensureLoaded() + if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) { + loadedConnectionKeys = emptyList() + _uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false) + return null + } + + _uiState.value.findPlaybackTargetForProgress( + contentId = contentId, + videoId = videoId, + )?.let { target -> return target } + + val refreshed = refreshNow() + return refreshed.findPlaybackTargetForProgress( + contentId = contentId, + videoId = videoId, + ) + } + suspend fun resolvePlayback( item: CloudLibraryItem, file: CloudLibraryFile, @@ -147,8 +170,47 @@ object CloudLibraryRepository { ) }.sortedBy { it.providerId } + private suspend fun refreshNow(): CloudLibraryUiState { + _uiState.update { current -> + current.copy( + isEnabled = true, + isRefreshing = true, + providers = current.providers.map { it.copy(isLoading = true, errorMessage = null) }, + ) + } + val refreshed = store.refresh() + loadedConnectionKeys = connectedCloudConnectionKeys() + _uiState.value = refreshed + return refreshed + } + private data class CloudConnectionKey( val providerId: String, val apiKeyHash: Int, ) } + +internal fun CloudLibraryUiState.findPlaybackTargetForProgress( + contentId: String, + videoId: String, +): CloudLibraryPlaybackTarget? { + val normalizedContentId = contentId.trim() + val normalizedVideoId = videoId.trim() + if (normalizedContentId.isBlank()) return null + + val matchingItems = items.filter { item -> item.stableKey == normalizedContentId } + if (matchingItems.isEmpty()) return null + + for (item in matchingItems) { + val exactFile = item.playableFiles.firstOrNull { file -> + item.playbackVideoId(file) == normalizedVideoId + } + if (exactFile != null) { + return CloudLibraryPlaybackTarget(item = item, file = exactFile) + } + } + + val singleItem = matchingItems.singleOrNull() ?: return null + val singleFile = singleItem.playableFiles.singleOrNull() ?: return null + return CloudLibraryPlaybackTarget(item = singleItem, file = singleFile) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index 6037ce7b..804a5e22 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -45,6 +45,8 @@ import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.posterCardClickable +import com.nuvio.app.features.cloud.CloudLibraryContentType +import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle @@ -64,10 +66,15 @@ private fun localizedContinueWatchingMetaLine(item: ContinueWatchingItem): Strin stringResource(Res.string.compose_player_episode_code_full, item.seasonNumber, item.episodeNumber) item.isNextUp -> stringResource(Res.string.continue_watching_up_next) + item.isCloudLibraryItem() -> + stringResource(Res.string.library_source_cloud) else -> stringResource(Res.string.media_movie) } +private fun ContinueWatchingItem.isCloudLibraryItem(): Boolean = + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) + private fun ContinueWatchingItem.continueWatchingArtworkUrl( useEpisodeThumbnails: Boolean, ): String? = when { @@ -392,6 +399,7 @@ private fun ContinueWatchingWideCard( imageUrl = artworkUrl, width = layout.widePosterStripWidth, blurred = shouldBlurArtwork, + contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop, modifier = Modifier.fillMaxHeight(), ) Column( @@ -504,12 +512,12 @@ private fun ContinueWatchingPosterCard( val imageUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails) if (imageUrl != null) { AsyncImage( - model = imageUrl, + model = cloudLibraryDisplayArtworkUrl(imageUrl), contentDescription = item.title, modifier = Modifier .fillMaxSize() .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier), - contentScale = ContentScale.Crop, + contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop, ) } if (item.progressFraction <= 0f && item.seasonNumber != null && item.episodeNumber != null) { @@ -589,6 +597,7 @@ private fun ArtworkPanel( imageUrl: String?, width: Dp, blurred: Boolean = false, + contentScale: ContentScale = ContentScale.Crop, modifier: Modifier = Modifier, ) { Box( @@ -598,12 +607,12 @@ private fun ArtworkPanel( ) { if (imageUrl != null) { AsyncImage( - model = imageUrl, + model = cloudLibraryDisplayArtworkUrl(imageUrl), contentDescription = null, modifier = Modifier .fillMaxSize() .then(if (blurred) Modifier.blur(18.dp) else Modifier), - contentScale = ContentScale.Crop, + contentScale = contentScale, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 9fb84629..7783f353 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -1,5 +1,7 @@ package com.nuvio.app.features.watchprogress +import com.nuvio.app.features.cloud.CloudLibraryContentType +import com.nuvio.app.features.cloud.cloudLibraryProviderPosterUrl import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlinx.serialization.Serializable @@ -199,6 +201,7 @@ internal fun nextUpDismissKey( internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { val normalizedEntry = normalizedCompletion() + val cloudPosterUrl = normalizedEntry.cloudLibraryPosterFallbackUrl() val explicitResumeProgressFraction = normalizedEntry.normalizedProgressPercent ?.takeIf { durationMs <= 0L && it > 0f } ?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) } @@ -213,9 +216,9 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { episodeNumber = normalizedEntry.episodeNumber, episodeTitle = normalizedEntry.episodeTitle, ), - imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster, + imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster ?: cloudPosterUrl, logo = normalizedEntry.logo, - poster = normalizedEntry.poster, + poster = normalizedEntry.poster ?: cloudPosterUrl, background = normalizedEntry.background, seasonNumber = normalizedEntry.seasonNumber, episodeNumber = normalizedEntry.episodeNumber, @@ -233,6 +236,16 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { ) } +private fun WatchProgressEntry.cloudLibraryPosterFallbackUrl(): String? { + if (!contentType.equals(CloudLibraryContentType, ignoreCase = true) && + !parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) + ) { + return null + } + return cloudLibraryProviderPosterUrl(parentMetaId) + ?: cloudLibraryProviderPosterUrl(providerAddonId) +} + internal fun WatchProgressEntry.toUpNextContinueWatchingItem( nextEpisode: MetaVideo, ): ContinueWatchingItem { diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt index 297daf49..5610846a 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt @@ -6,6 +6,7 @@ import com.nuvio.app.features.debrid.DebridServiceCredential import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertTrue class CloudLibraryStoreTest { @@ -68,6 +69,86 @@ class CloudLibraryStoreTest { assertEquals(listOf("cloud"), state.providers.map { it.providerId }) assertEquals(listOf("cloud-item"), state.items.map { it.id }) } + + @Test + fun `playback target lookup matches cloud watch progress video id`() { + val provider = cloudProvider(id = "torbox", name = "TorBox") + val item = CloudLibraryItem( + providerId = provider.id, + providerName = provider.displayName, + id = "29773238", + type = CloudLibraryItemType.Torrent, + name = "Torrent", + files = listOf( + CloudLibraryFile(id = "7", name = "sample.mkv", playable = true), + CloudLibraryFile(id = "8", name = "movie.mkv", playable = true), + ), + ) + val state = CloudLibraryUiState( + isLoaded = true, + providers = listOf( + CloudLibraryProviderState( + provider = provider, + items = listOf(item), + ), + ), + ) + + val target = assertNotNull( + state.findPlaybackTargetForProgress( + contentId = "torbox:Torrent:29773238", + videoId = "torbox:Torrent:29773238:8", + ), + ) + + assertEquals(item, target.item) + assertEquals("8", target.file.id) + } + + @Test + fun `playback target lookup falls back to single playable file`() { + val provider = cloudProvider(id = "torbox", name = "TorBox") + val item = cloudItem(provider, "29773238") + val state = CloudLibraryUiState( + isLoaded = true, + providers = listOf( + CloudLibraryProviderState( + provider = provider, + items = listOf(item), + ), + ), + ) + + val target = assertNotNull( + state.findPlaybackTargetForProgress( + contentId = item.stableKey, + videoId = item.stableKey, + ), + ) + + assertEquals(item, target.item) + assertEquals(item.playableFiles.single(), target.file) + } + + @Test + fun `provider poster urls are mapped for cloud services`() { + assertEquals( + TorboxCloudLibraryPosterUrl, + cloudLibraryProviderPosterUrl("torbox:Torrent:29773238"), + ) + assertEquals( + PremiumizeCloudLibraryPosterUrl, + cloudLibraryProviderPosterUrl("cloud:premiumize"), + ) + assertTrue( + cloudLibraryDisplayArtworkUrl(TorboxCloudLibraryPosterUrl) + ?.startsWith("data:image/svg+xml;base64,") == true, + ) + assertEquals( + PremiumizeCloudLibraryPosterUrl, + cloudLibraryDisplayArtworkUrl(PremiumizeCloudLibraryPosterUrl), + ) + } } private class FakeCloudProviderApi( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index b6112ba8..883e47e6 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.watchprogress +import com.nuvio.app.features.cloud.TorboxCloudLibraryPosterUrl import com.nuvio.app.features.details.MetaVideo import kotlin.test.Test import kotlin.test.assertEquals @@ -95,6 +96,23 @@ class WatchProgressRulesTest { assertEquals(2, result.size) } + @Test + fun `cloud continue watching uses provider poster fallback`() { + val item = WatchProgressEntry( + contentType = "cloud", + parentMetaId = "torbox:Torrent:29773238", + parentMetaType = "cloud", + videoId = "torbox:Torrent:29773238:8", + title = "Cloud file", + lastPositionMs = 120_000L, + durationMs = 1_000_000L, + lastUpdatedEpochMs = 1L, + ).toContinueWatchingItem() + + assertEquals(TorboxCloudLibraryPosterUrl, item.poster) + assertEquals(TorboxCloudLibraryPosterUrl, item.imageUrl) + } + @Test fun `continue watching excludes explicit 100 percent entries even when completion flag is false`() { val completedByPercent = entry( From 8fe059901c30f590f2d068d96b4c23076ffcac69 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 22 May 2026 15:32:11 +0530 Subject: [PATCH 16/18] feat(cloud): passing watchprogress --- .../composeResources/values/strings.xml | 3 + .../commonMain/kotlin/com/nuvio/app/App.kt | 66 ++++++++++++++----- .../app/features/cloud/CloudLibraryModels.kt | 13 +++- .../features/cloud/CloudLibraryRepository.kt | 42 ++++++++++-- .../com/nuvio/app/features/home/HomeScreen.kt | 59 ++++++++++++++++- .../nuvio/app/features/home/HomeScreenTest.kt | 46 +++++++++++++ 6 files changed, 205 insertions(+), 24 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 115fbf8a..43397cbe 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1345,8 +1345,11 @@ This item does not expose a playable video file. No playable files No playable files + Cloud library is off. Couldn't play this cloud file. Play file + Cloud service is not connected. + %1$s is not connected. %1$d playable files All Refresh cloud library diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 864d3010..266ac99a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -110,6 +110,7 @@ import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.CloudLibraryFile import com.nuvio.app.features.cloud.CloudLibraryItem import com.nuvio.app.features.cloud.CloudLibraryPlaybackResult +import com.nuvio.app.features.cloud.CloudLibraryPlaybackTargetLookupResult import com.nuvio.app.features.cloud.CloudLibraryRepository import com.nuvio.app.features.cloud.playbackVideoId import com.nuvio.app.features.cloud.providerPosterUrl @@ -187,6 +188,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor import com.nuvio.app.features.watchprogress.ResumePromptRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.Flow @@ -610,6 +612,8 @@ private fun MainAppContent( val externalPlayerUnavailableText = stringResource(Res.string.external_player_unavailable) val externalPlayerFailedText = stringResource(Res.string.external_player_failed) val cloudLibraryPlayFailedText = stringResource(Res.string.cloud_library_play_failed) + val cloudLibraryPlayDisabledText = stringResource(Res.string.cloud_library_play_disabled) + val cloudLibraryPlayNotConnectedText = stringResource(Res.string.cloud_library_play_not_connected) val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT var initialHomeReady by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } @@ -1063,21 +1067,42 @@ private fun MainAppContent( val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning -> if (item.isCloudLibraryContinueWatchingItem()) { coroutineScope.launch { - val target = CloudLibraryRepository.findPlaybackTargetForProgress( - contentId = item.parentMetaId, - videoId = item.videoId, - ) - val launched = target?.let { playbackTarget -> - launchCloudLibraryFile( - item = playbackTarget.item, - file = playbackTarget.file, - resumePositionMs = item.resumePositionMs, - resumeProgressFraction = item.resumeProgressFraction, - startFromBeginning = startFromBeginning, + when ( + val lookup = CloudLibraryRepository.findPlaybackTargetForProgressResult( + contentId = item.parentMetaId, + videoId = item.videoId, ) - } == true - if (!launched) { - NuvioToastController.show(cloudLibraryPlayFailedText) + ) { + is CloudLibraryPlaybackTargetLookupResult.Found -> { + val launched = launchCloudLibraryFile( + item = lookup.target.item, + file = lookup.target.file, + resumePositionMs = item.resumePositionMs, + resumeProgressFraction = item.resumeProgressFraction, + startFromBeginning = startFromBeginning, + ) + if (!launched) { + NuvioToastController.show(cloudLibraryPlayFailedText) + } + } + + CloudLibraryPlaybackTargetLookupResult.Disabled -> { + NuvioToastController.show(cloudLibraryPlayDisabledText) + } + + is CloudLibraryPlaybackTargetLookupResult.NotConnected -> { + val providerName = lookup.providerName?.takeIf { it.isNotBlank() } + NuvioToastController.show( + providerName?.let { name -> + getString(Res.string.cloud_library_play_provider_not_connected, name) + } + ?: cloudLibraryPlayNotConnectedText, + ) + } + + CloudLibraryPlaybackTargetLookupResult.NotFound -> { + NuvioToastController.show(cloudLibraryPlayFailedText) + } } } } else { @@ -1235,7 +1260,18 @@ private fun MainAppContent( onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, onCloudFilePlay = { item, file -> coroutineScope.launch { - if (!launchCloudLibraryFile(item = item, file = file)) { + val resumeItem = WatchProgressRepository + .progressForVideo(item.playbackVideoId(file)) + ?.takeIf { it.isResumable } + ?.toContinueWatchingItem() + if ( + !launchCloudLibraryFile( + item = item, + file = file, + resumePositionMs = resumeItem?.resumePositionMs, + resumeProgressFraction = resumeItem?.resumeProgressFraction, + ) + ) { NuvioToastController.show(cloudLibraryPlayFailedText) } } 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 eea7f20c..751c0608 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 @@ -44,6 +44,13 @@ data class CloudLibraryPlaybackTarget( val file: CloudLibraryFile, ) +sealed interface CloudLibraryPlaybackTargetLookupResult { + data class Found(val target: CloudLibraryPlaybackTarget) : CloudLibraryPlaybackTargetLookupResult + data object Disabled : CloudLibraryPlaybackTargetLookupResult + data class NotConnected(val providerName: String? = null) : CloudLibraryPlaybackTargetLookupResult + data object NotFound : CloudLibraryPlaybackTargetLookupResult +} + const val CloudLibraryContentType = "cloud" const val TorboxCloudLibraryPosterUrl = "https://torbox.app/assets/logo-bb7a9579.svg" const val PremiumizeCloudLibraryPosterUrl = "https://www.premiumize.me/icon_normal.svg" @@ -57,7 +64,7 @@ fun CloudLibraryItem.providerPosterUrl(): String? = cloudLibraryProviderPosterUrl(providerId) fun cloudLibraryProviderPosterUrl(providerIdOrContentId: String?): String? = - when (providerIdOrContentId.normalizedCloudLibraryProviderId()) { + when (cloudLibraryProviderId(providerIdOrContentId)) { "torbox" -> TorboxCloudLibraryPosterUrl "premiumize" -> PremiumizeCloudLibraryPosterUrl else -> null @@ -69,8 +76,8 @@ fun cloudLibraryDisplayArtworkUrl(url: String?): String? = else -> url?.trim() } -private fun String?.normalizedCloudLibraryProviderId(): String = - orEmpty() +fun cloudLibraryProviderId(providerIdOrContentId: String?): String = + providerIdOrContentId.orEmpty() .trim() .removePrefix("$CloudLibraryContentType:") .substringBefore(':') 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 01543dfd..d5f7669d 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 @@ -124,24 +124,58 @@ object CloudLibraryRepository { suspend fun findPlaybackTargetForProgress( contentId: String, videoId: String, - ): CloudLibraryPlaybackTarget? { + ): CloudLibraryPlaybackTarget? = + when (val result = findPlaybackTargetForProgressResult(contentId = contentId, videoId = videoId)) { + is CloudLibraryPlaybackTargetLookupResult.Found -> result.target + CloudLibraryPlaybackTargetLookupResult.Disabled, + is CloudLibraryPlaybackTargetLookupResult.NotConnected, + CloudLibraryPlaybackTargetLookupResult.NotFound, + -> null + } + + suspend fun findPlaybackTargetForProgressResult( + contentId: String, + videoId: String, + ): CloudLibraryPlaybackTargetLookupResult { DebridSettingsRepository.ensureLoaded() if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) { loadedConnectionKeys = emptyList() _uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false) - return null + return CloudLibraryPlaybackTargetLookupResult.Disabled + } + + val providerId = cloudLibraryProviderId(contentId) + .ifBlank { cloudLibraryProviderId(videoId) } + val connectedCredentials = connectedCloudCredentials() + if (connectedCredentials.isEmpty()) { + return CloudLibraryPlaybackTargetLookupResult.NotConnected( + providerName = providerId.takeIf { it.isNotBlank() }?.let(DebridProviders::displayName), + ) + } + if ( + providerId.isNotBlank() && + connectedCredentials.none { credential -> credential.provider.id.equals(providerId, ignoreCase = true) } + ) { + return CloudLibraryPlaybackTargetLookupResult.NotConnected( + providerName = DebridProviders.displayName(providerId), + ) } _uiState.value.findPlaybackTargetForProgress( contentId = contentId, videoId = videoId, - )?.let { target -> return target } + )?.let { target -> return CloudLibraryPlaybackTargetLookupResult.Found(target) } val refreshed = refreshNow() - return refreshed.findPlaybackTargetForProgress( + val refreshedTarget = refreshed.findPlaybackTargetForProgress( contentId = contentId, videoId = videoId, ) + return if (refreshedTarget != null) { + CloudLibraryPlaybackTargetLookupResult.Found(refreshedTarget) + } else { + CloudLibraryPlaybackTargetLookupResult.NotFound + } } suspend fun resolvePlayback( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index a5da0fdc..030a0643 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -21,6 +21,10 @@ import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.cloud.CloudLibraryContentType +import com.nuvio.app.features.cloud.CloudLibraryRepository +import com.nuvio.app.features.cloud.CloudLibraryUiState +import com.nuvio.app.features.cloud.findPlaybackTargetForProgress import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.nextReleasedEpisodeAfter import com.nuvio.app.features.home.components.HomeCatalogRowSection @@ -109,6 +113,7 @@ fun HomeScreen( val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() + val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val traktSettingsUiState by remember { TraktSettingsRepository.ensureLoaded() @@ -218,6 +223,12 @@ fun HomeScreen( effectiveWatchProgressEntries.continueWatchingEntries() } + LaunchedEffect(visibleContinueWatchingEntries) { + if (visibleContinueWatchingEntries.any(WatchProgressEntry::isCloudLibraryProgressEntry)) { + CloudLibraryRepository.ensureLoaded() + } + } + val latestCompletedAtBySeries = remember(allNextUpSeedEntries) { allNextUpSeedEntries .groupBy { entry -> entry.parentMetaId } @@ -348,6 +359,7 @@ fun HomeScreen( effectivNextUpItems, nextUpSuppressedSeriesIds, continueWatchingPreferences.sortMode, + cloudLibraryUiState, ) { buildHomeContinueWatchingItems( visibleEntries = visibleContinueWatchingEntries, @@ -356,6 +368,7 @@ fun HomeScreen( nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds, sortMode = continueWatchingPreferences.sortMode, todayIsoDate = CurrentDateProvider.todayIsoDate(), + cloudLibraryUiState = cloudLibraryUiState, ) } val availableManifests = remember(addonsUiState.addons) { @@ -911,6 +924,7 @@ internal fun buildHomeContinueWatchingItems( nextUpSuppressedSeriesIds: Set? = null, sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, todayIsoDate: String = "", + cloudLibraryUiState: CloudLibraryUiState? = null, ): List { val suppressedSeriesIds = nextUpSuppressedSeriesIds ?: visibleEntries @@ -926,7 +940,9 @@ internal fun buildHomeContinueWatchingItems( val liveItem = entry.toContinueWatchingItem() HomeContinueWatchingCandidate( lastUpdatedEpochMs = entry.lastUpdatedEpochMs, - item = liveItem.withFallbackMetadata(cachedInProgressByVideoId[entry.videoId]), + item = liveItem + .withFallbackMetadata(cachedInProgressByVideoId[entry.videoId]) + .withCloudLibraryMetadata(cloudLibraryUiState), isProgressEntry = true, ) }, @@ -1166,9 +1182,16 @@ private fun ContinueWatchingItem.withFallbackMetadata( fallback: ContinueWatchingItem?, ): ContinueWatchingItem { if (fallback == null) return this + val fallbackTitle = fallback.title + .takeIf { it.isNotBlank() } + ?.takeUnless { fallback.hasPlaceholderCloudTitle() } return copy( - title = title.ifBlank { fallback.title }, + title = when { + title.isBlank() -> fallback.title + hasPlaceholderCloudTitle() && fallbackTitle != null -> fallbackTitle + else -> title + }, subtitle = subtitle.ifBlank { fallback.subtitle }, imageUrl = imageUrl ?: fallback.imageUrl, logo = logo ?: fallback.logo, @@ -1180,3 +1203,35 @@ private fun ContinueWatchingItem.withFallbackMetadata( released = released ?: fallback.released, ) } + +private fun ContinueWatchingItem.withCloudLibraryMetadata( + cloudLibraryUiState: CloudLibraryUiState?, +): ContinueWatchingItem { + if (!isCloudLibraryContinueWatchingItem() || cloudLibraryUiState == null) return this + val target = cloudLibraryUiState.findPlaybackTargetForProgress( + contentId = parentMetaId, + videoId = videoId, + ) ?: return this + val fileName = target.file.name.trim().takeIf { it.isNotBlank() } + ?: target.item.name.trim().takeIf { it.isNotBlank() } + ?: return this + return copy( + title = fileName, + pauseDescription = pauseDescription + ?: target.item.name.takeIf { itemName -> itemName.isNotBlank() && itemName != fileName }, + ) +} + +private fun ContinueWatchingItem.hasPlaceholderCloudTitle(): Boolean { + if (!isCloudLibraryContinueWatchingItem()) return false + val normalizedTitle = title.trim() + return normalizedTitle.equals(parentMetaId, ignoreCase = true) || + normalizedTitle.equals(videoId, ignoreCase = true) +} + +private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean = + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) + +private fun WatchProgressEntry.isCloudLibraryProgressEntry(): Boolean = + contentType.equals(CloudLibraryContentType, ignoreCase = true) || + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index bb98bcbb..7c96c5af 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -1,5 +1,12 @@ package com.nuvio.app.features.home +import com.nuvio.app.features.cloud.CloudLibraryFile +import com.nuvio.app.features.cloud.CloudLibraryItem +import com.nuvio.app.features.cloud.CloudLibraryItemType +import com.nuvio.app.features.cloud.CloudLibraryProviderState +import com.nuvio.app.features.cloud.CloudLibraryUiState +import com.nuvio.app.features.cloud.playbackVideoId +import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL @@ -84,6 +91,45 @@ class HomeScreenTest { assertEquals("S1E4 β€’ Current", result.single().subtitle) } + @Test + fun `build home continue watching items enriches cloud title from library file`() { + val file = CloudLibraryFile(id = "8", name = "GOAT.2026.2160p.UHD.mkv") + val cloudItem = CloudLibraryItem( + providerId = DebridProviders.TORBOX_ID, + providerName = DebridProviders.Torbox.displayName, + id = "29773238", + type = CloudLibraryItemType.Torrent, + name = "GOAT torrent", + files = listOf(file), + ) + val progress = WatchProgressEntry( + contentType = "cloud", + parentMetaId = cloudItem.stableKey, + parentMetaType = "cloud", + videoId = cloudItem.playbackVideoId(file), + title = cloudItem.stableKey, + lastPositionMs = 120_000L, + durationMs = 1_000_000L, + lastUpdatedEpochMs = 500L, + ) + + val result = buildHomeContinueWatchingItems( + visibleEntries = listOf(progress), + nextUpItemsBySeries = emptyMap(), + cloudLibraryUiState = CloudLibraryUiState( + isLoaded = true, + providers = listOf( + CloudLibraryProviderState( + provider = DebridProviders.Torbox, + items = listOf(cloudItem), + ), + ), + ), + ) + + assertEquals("GOAT.2026.2160p.UHD.mkv", result.single().title) + } + @Test fun `Trakt continue watching window filters old progress only when Trakt source is active`() { val oldEntry = progressEntry( From 5d9a78913960a4d43c0eb9452d726c0e10227cf8 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 22 May 2026 15:43:50 +0530 Subject: [PATCH 17/18] Revert "removal: debrid integration" This reverts commit 4e5a32510b64370284e75217d6e017cbe2d0a218. --- composeApp/build.gradle.kts | 13 + .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../debrid/DebridSettingsStorage.android.kt | 210 +++ .../composeResources/values-no/strings.xml | 25 + .../composeResources/values-pl/strings.xml | 32 + .../composeResources/values/strings.xml | 32 + .../commonMain/kotlin/com/nuvio/app/App.kt | 86 +- .../app/core/sync/ProfileSettingsSync.kt | 10 + .../app/features/debrid/DebridApiClients.kt | 244 ++++ .../app/features/debrid/DebridApiModels.kt | 94 ++ .../features/debrid/DebridFileSelectors.kt | 169 +++ .../app/features/debrid/DebridProvider.kt | 83 ++ .../app/features/debrid/DebridSettings.kt | 256 ++++ .../debrid/DebridSettingsRepository.kt | 419 ++++++ .../features/debrid/DebridSettingsStorage.kt | 34 + .../features/debrid/DebridStreamFormatter.kt | 143 ++ .../debrid/DebridStreamFormatterDefaults.kt | 8 + .../debrid/DebridStreamTemplateEngine.kt | 394 +++++ .../app/features/debrid/DebridUrlEncoding.kt | 38 + .../debrid/DirectDebridConfigEncoder.kt | 39 + .../features/debrid/DirectDebridResolver.kt | 375 +++++ .../debrid/DirectDebridStreamFilter.kt | 425 ++++++ .../debrid/DirectDebridStreamPreparer.kt | 196 +++ .../debrid/DirectDebridStreamSource.kt | 253 ++++ .../app/features/details/MetaDetailsScreen.kt | 16 + .../features/player/PlayerEpisodesPanel.kt | 2 +- .../nuvio/app/features/player/PlayerScreen.kt | 72 + .../app/features/player/PlayerSourcesPanel.kt | 2 +- .../player/PlayerStreamsRepository.kt | 52 +- .../features/settings/DebridSettingsPage.kt | 1295 +++++++++++++++++ .../settings/IntegrationsSettingsPage.kt | 13 + .../app/features/settings/SettingsModels.kt | 6 + .../app/features/settings/SettingsScreen.kt | 20 + .../streams/StreamAutoPlaySelector.kt | 12 +- .../app/features/streams/StreamModels.kt | 75 +- .../app/features/streams/StreamParser.kt | 73 +- .../app/features/streams/StreamsRepository.kt | 72 +- .../app/features/streams/StreamsScreen.kt | 6 +- .../features/debrid/DebridFileSelectorTest.kt | 148 ++ .../debrid/DebridStreamFormatterTest.kt | 122 ++ .../debrid/DebridStreamTemplateEngineTest.kt | 45 + .../debrid/DirectDebridConfigEncoderTest.kt | 27 + .../debrid/DirectDebridStreamFilterTest.kt | 210 +++ .../debrid/DirectDebridStreamPreparerTest.kt | 70 + .../streams/StreamAutoPlaySelectorTest.kt | 33 + .../app/features/streams/StreamParserTest.kt | 51 + .../debrid/DebridSettingsStorage.ios.kt | 193 +++ 47 files changed, 6176 insertions(+), 19 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 5c5811e4..89ebdf46 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -90,6 +90,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { ) } + outDir.resolve("com/nuvio/app/features/debrid").apply { + mkdirs() + resolve("DebridConfig.kt").writeText( + """ + |package com.nuvio.app.features.debrid + | + |object DebridConfig { + | const val DIRECT_DEBRID_API_BASE_URL = "${props.getProperty("DIRECT_DEBRID_API_BASE_URL", "")}" + |} + """.trimMargin() + ) + } + outDir.resolve("com/nuvio/app/core/build").apply { mkdirs() resolve("AppVersionConfig.kt").writeText( diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 2c8bebbb..94036653 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -15,6 +15,7 @@ import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner import com.nuvio.app.features.addons.AddonStorage import com.nuvio.app.features.collection.CollectionMobileSettingsStorage import com.nuvio.app.features.collection.CollectionStorage +import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform import com.nuvio.app.features.downloads.DownloadsPlatformDownloader import com.nuvio.app.features.downloads.DownloadsStorage @@ -74,6 +75,7 @@ class MainActivity : AppCompatActivity() { SearchHistoryStorage.initialize(applicationContext) SeasonViewModeStorage.initialize(applicationContext) PosterCardStyleStorage.initialize(applicationContext) + DebridSettingsStorage.initialize(applicationContext) TmdbSettingsStorage.initialize(applicationContext) MdbListSettingsStorage.initialize(applicationContext) TraktAuthStorage.initialize(applicationContext) 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 new file mode 100644 index 00000000..d1ff44e5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -0,0 +1,210 @@ +package com.nuvio.app.features.debrid + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncInt +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncInt +import com.nuvio.app.core.sync.encodeSyncString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +actual object DebridSettingsStorage { + private const val preferencesName = "nuvio_debrid_settings" + private const val enabledKey = "debrid_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" + private const val streamMaxResultsKey = "debrid_stream_max_results" + private const val streamSortModeKey = "debrid_stream_sort_mode" + private const val streamMinimumQualityKey = "debrid_stream_minimum_quality" + private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter" + private const val streamHdrFilterKey = "debrid_stream_hdr_filter" + private const val streamCodecFilterKey = "debrid_stream_codec_filter" + 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 var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + + actual fun saveEnabled(enabled: Boolean) { + saveBoolean(enabledKey, enabled) + } + + actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey) + + actual fun saveTorboxApiKey(apiKey: String) { + saveString(torboxApiKeyKey, apiKey) + } + + actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey) + + actual fun saveRealDebridApiKey(apiKey: String) { + saveString(realDebridApiKeyKey, apiKey) + } + + actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey) + + actual fun saveInstantPlaybackPreparationLimit(limit: Int) { + saveInt(instantPlaybackPreparationLimitKey, limit) + } + + actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey) + + actual fun saveStreamMaxResults(maxResults: Int) { + saveInt(streamMaxResultsKey, maxResults) + } + + actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey) + + actual fun saveStreamSortMode(mode: String) { + saveString(streamSortModeKey, mode) + } + + actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey) + + actual fun saveStreamMinimumQuality(quality: String) { + saveString(streamMinimumQualityKey, quality) + } + + actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey) + + actual fun saveStreamDolbyVisionFilter(filter: String) { + saveString(streamDolbyVisionFilterKey, filter) + } + + actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey) + + actual fun saveStreamHdrFilter(filter: String) { + saveString(streamHdrFilterKey, filter) + } + + actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey) + + actual fun saveStreamCodecFilter(filter: String) { + saveString(streamCodecFilterKey, filter) + } + + actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey) + + actual fun saveStreamPreferences(preferences: String) { + saveString(streamPreferencesKey, preferences) + } + + actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) + + actual fun saveStreamNameTemplate(template: String) { + saveString(streamNameTemplateKey, template) + } + + actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey) + + actual fun saveStreamDescriptionTemplate(template: String) { + saveString(streamDescriptionTemplateKey, template) + } + + private fun loadBoolean(key: String): Boolean? = + preferences?.let { sharedPreferences -> + val scopedKey = ProfileScopedKey.of(key) + if (sharedPreferences.contains(scopedKey)) { + sharedPreferences.getBoolean(scopedKey, false) + } else { + null + } + } + + private fun saveBoolean(key: String, enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(key), enabled) + ?.apply() + } + + private fun loadInt(key: String): Int? = + preferences?.let { sharedPreferences -> + val scopedKey = ProfileScopedKey.of(key) + if (sharedPreferences.contains(scopedKey)) { + sharedPreferences.getInt(scopedKey, 0) + } else { + null + } + } + + private fun saveInt(key: String, value: Int) { + preferences + ?.edit() + ?.putInt(ProfileScopedKey.of(key), value) + ?.apply() + } + + private fun loadString(key: String): String? = + preferences?.getString(ProfileScopedKey.of(key), null) + + private fun saveString(key: String, value: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(key), value) + ?.apply() + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) } + loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) } + loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } + loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) } + loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) } + loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) } + loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) } + loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) } + loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) } + loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) } + loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } + loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + preferences?.edit()?.apply { + syncKeys.forEach { remove(ProfileScopedKey.of(it)) } + }?.apply() + + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) + payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) + payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) + payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults) + payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode) + payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality) + payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter) + payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter) + payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter) + payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences) + payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) + payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) + } +} diff --git a/composeApp/src/commonMain/composeResources/values-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml index 89e10815..06b43610 100644 --- a/composeApp/src/commonMain/composeResources/values-no/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml @@ -379,6 +379,7 @@ Utseende Innhold & oppdagelse Fortsett Γ₯ se + Debrid Hjemmeoppsett Integrasjoner Lisenser & attribusjon @@ -587,6 +588,27 @@ Integrasjoner Metadata-berikelse-kontroller Eksterne vurderingsleverandΓΈrer + Eksperimentelle sky-konto-kilder + Debrid + Debrid-stΓΈtte er eksperimentell og kan endres eller fjernes senere. + Aktiver kilder + Vis spillbare resultater fra tilkoblede kontoer. + Legg til en API-nΓΈkkel fΓΈrst. + Konto + Koble til Torbox-kontoen din. + Umiddelbar avspilling + Forbered lenker + LΓΈs fΓΈrste kilder fΓΈr avspilling starter. + Kilder Γ₯ forberede + 1 kilde + %1$d kilder + Formatering + Navnemal + Styrer hvordan kildenavn vises. + Beskrivelsesmal + Styrer metadata vist under hver kilde. + API-nΓΈkkel validert. + Kunne ikke validere denne API-nΓΈkkelen. Legg til MDBList API-nΓΈkkel fΓΈr du skrur pΓ₯ vurderinger. Kreves for Γ₯ hente vurderinger fra MDBList API-nΓΈkkel @@ -1122,6 +1144,9 @@ Gjenoppta fra %1$s STØRRELSE %1$s Denne strΓΈmtypen stΓΈttes ikke + Legg til en Debrid API-nΓΈkkel i Innstillinger. + Dette Debrid-resultatet er utgΓ₯tt. Oppdaterer strΓΈmmer. + Kunne ikke lΓΈse denne Debrid-strΓΈmmen. Kunne ikke Γ₯pne ekstern avspiller Velg en ekstern avspiller i innstillinger fΓΈrst Ingen ekstern avspiller er tilgjengelig diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 3fa135b3..330c2cd6 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -380,6 +380,7 @@ WyglΔ…d TreΕ›ci i odkrywanie Kontynuuj oglΔ…danie + Debrid Ekran gΕ‚Γ³wny Integracje Licencje i atrybucje @@ -588,6 +589,34 @@ INTEGRACJE WzbogaΔ‡ strony szczegΓ³Ε‚Γ³w grafikami TMDB, obsadΔ…, metadanymi odcinkΓ³w i nie tylko. Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnΔ™trzne oceny do stron szczegΓ³Ε‚Γ³w. + Eksperymentalne ΕΊrΓ³dΕ‚a z kont chmurowych + Debrid + ObsΕ‚uga Debrid jest eksperymentalna i moΕΌe zostaΔ‡ zachowana, zmieniona lub usuniΔ™ta w przyszΕ‚oΕ›ci. + WΕ‚Δ…cz ΕΊrΓ³dΕ‚a + PokaΕΌ odtwarzalne wyniki z poΕ‚Δ…czonych kont. + Najpierw dodaj klucz API. + Konto + PoΕ‚Δ…cz swoje konto Torbox. + Klucz API Torbox + WprowadΕΊ swΓ³j klucz API Torbox. + WprowadΕΊ klucz API Torbox + Nie ustawiono + Natychmiastowe odtwarzanie + Przygotuj linki + RozwiΔ…ΕΌ pierwsze ΕΊrΓ³dΕ‚a przed rozpoczΔ™ciem odtwarzania. + ΕΉrΓ³dΕ‚a do przygotowania + UΕΌywaj niΕΌszej liczby, gdy to moΕΌliwe. UsΕ‚ugi Debrid mogΔ… ograniczaΔ‡ liczbΔ™ linkΓ³w rozwiΔ…zywanych w danym okresie. Otwarcie filmu lub odcinka moΕΌe siΔ™ wliczaΔ‡ do tych limitΓ³w, nawet jeΕ›li nie naciΕ›niesz OdtwΓ³rz, poniewaΕΌ linki sΔ… przygotowywane z wyprzedzeniem. + 1 ΕΊrΓ³dΕ‚o + %1$d ΕΊrΓ³deΕ‚ + Formatowanie + Szablon nazwy + Kontroluje sposΓ³b wyΕ›wietlania nazw ΕΊrΓ³deΕ‚. + Szablon opisu + Kontroluje metadane wyΕ›wietlane pod kaΕΌdym ΕΊrΓ³dΕ‚em. + Resetuj formatowanie + PrzywrΓ³Δ‡ domyΕ›lne formatowanie ΕΊrΓ³deΕ‚. + Klucz API zweryfikowany. + Nie udaΕ‚o siΔ™ zweryfikowaΔ‡ tego klucza API. Dodaj klucz API MDBList poniΕΌej przed wΕ‚Δ…czeniem ocen. Pobierz klucz z https://mdblist.com/preferences i wklej go tutaj. Klucz API @@ -1125,6 +1154,9 @@ WznΓ³w od %1$s ROZMIAR %1$s Ten typ strumienia nie jest obsΕ‚ugiwany + Dodaj klucz API Debrid w Ustawieniach. + Ten wynik Debrid wygasΕ‚. OdΕ›wieΕΌanie strumieni. + Nie udaΕ‚o siΔ™ rozwiΔ…zaΔ‡ tego strumienia Debrid. Nie udaΕ‚o siΔ™ otworzyΔ‡ zewnΔ™trznego odtwarzacza Najpierw wybierz zewnΔ™trzny odtwarzacz w ustawieniach Brak dostΔ™pnego zewnΔ™trznego odtwarzacza diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 8f585ada..3267280d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -380,6 +380,7 @@ Layout Content & Discovery Continue Watching + Debrid Home Layout Integrations Licenses & Attribution @@ -588,6 +589,34 @@ Integrations Metadata enrichment controls External ratings providers + Experimental cloud account sources + Debrid + Debrid support is experimental and may be kept, changed, or removed later. + Enable sources + 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 + Not set + Instant Playback + Prepare links + Resolve the first sources before playback starts. + Sources to prepare + Use a lower count when possible. Debrid services may rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time. + 1 source + %1$d sources + Formatting + Name template + Controls how source names appear. + Description template + Controls the metadata shown under each source. + Reset formatting + Restore default source formatting. + API key validated. + Could not validate this API key. Add your MDBList API key below before turning ratings on. Required to fetch ratings from MDBList API Key @@ -1126,6 +1155,9 @@ Resume from %1$s SIZE %1$s This stream type is not supported + Add a Debrid API key in Settings. + This Debrid result expired. Refreshing streams. + Could not resolve this Debrid stream. Couldn't open external player Choose an external player in settings first No external player is available diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 10f6361d..e486476d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -106,6 +106,9 @@ import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL +import com.nuvio.app.features.debrid.DirectDebridPlayableResult +import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver +import com.nuvio.app.features.debrid.toastMessage import com.nuvio.app.features.downloads.DownloadsRepository import com.nuvio.app.features.downloads.DownloadsScreen import com.nuvio.app.features.details.MetaDetailsRepository @@ -1357,6 +1360,8 @@ private fun MainAppContent( return@composable } val pauseDescription = launch.pauseDescription + val streamRouteScope = rememberCoroutineScope() + var resolvingDebridStream by rememberSaveable(route.launchId) { mutableStateOf(false) } val lifecycleOwner = backStackEntry DisposableEffect(lifecycleOwner, route.launchId) { val observer = LifecycleEventObserver { _, event -> @@ -1506,7 +1511,31 @@ private fun MainAppContent( if (reuseNavigated) return@LaunchedEffect if (autoPlayHandled) return@LaunchedEffect if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect - val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect + val selectedStream = streamsUiState.autoPlayStream ?: return@LaunchedEffect + val stream = when ( + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = selectedStream, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) + ) { + is DirectDebridPlayableResult.Success -> resolved.stream + else -> { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + StreamsRepository.consumeAutoPlay() + if (resolved == DirectDebridPlayableResult.Stale) { + StreamsRepository.reload( + type = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + manualSelection = launch.manualSelection, + ) + } + return@LaunchedEffect + } + } val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect autoPlayHandled = true if (playerSettings.streamReuseLastLinkEnabled) { @@ -1584,6 +1613,41 @@ private fun MainAppContent( forceExternal: Boolean, forceInternal: Boolean, ) { + if (stream.isDirectDebridStream && stream.directPlaybackUrl == null) { + if (resolvingDebridStream) return + streamRouteScope.launch { + resolvingDebridStream = true + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = stream, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) + resolvingDebridStream = false + when (resolved) { + is DirectDebridPlayableResult.Success -> openSelectedStream( + stream = resolved.stream, + resolvedResumePositionMs = resolvedResumePositionMs, + resolvedResumeProgressFraction = resolvedResumeProgressFraction, + forceExternal = forceExternal, + forceInternal = forceInternal, + ) + else -> { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + if (resolved == DirectDebridPlayableResult.Stale) { + StreamsRepository.reload( + type = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + manualSelection = launch.manualSelection, + ) + } + } + } + } + return + } val sourceUrl = stream.directPlaybackUrl ?: return if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( @@ -1687,6 +1751,26 @@ private fun MainAppContent( }, modifier = Modifier.fillMaxSize(), ) + if (resolvingDebridStream) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.82f)), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator(color = Color.White) + Text( + text = stringResource(Res.string.streams_finding_source), + color = Color.White.copy(alpha = 0.82f), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } } } composable( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index 58df719e..aacb5336 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -6,6 +6,8 @@ import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.collection.CollectionMobileSettingsRepository import com.nuvio.app.features.collection.CollectionMobileSettingsStorage +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.details.MetaScreenSettingsStorage import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.mdblist.MdbListMetadataService @@ -157,6 +159,7 @@ object ProfileSettingsSync { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }, PosterCardStyleRepository.uiState.map { "poster_card_style" }, PlayerSettingsRepository.uiState.map { "player" }, + DebridSettingsRepository.uiState.map { "debrid" }, TmdbSettingsRepository.uiState.map { "tmdb" }, MdbListSettingsRepository.uiState.map { "mdblist" }, MetaScreenSettingsRepository.uiState.map { "meta" }, @@ -202,6 +205,7 @@ object ProfileSettingsSync { themeSettings = ThemeSettingsStorage.exportToSyncPayload(), posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(), playerSettings = PlayerSettingsStorage.exportToSyncPayload(), + debridSettings = DebridSettingsStorage.exportToSyncPayload(), tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(), mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(), @@ -226,6 +230,9 @@ object ProfileSettingsSync { PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings) PlayerSettingsRepository.onProfileChanged() + DebridSettingsStorage.replaceFromSyncPayload(blob.features.debridSettings) + DebridSettingsRepository.onProfileChanged() + TmdbSettingsStorage.replaceFromSyncPayload(blob.features.tmdbSettings) TmdbSettingsRepository.onProfileChanged() @@ -255,6 +262,7 @@ object ProfileSettingsSync { ThemeSettingsRepository.ensureLoaded() PosterCardStyleRepository.ensureLoaded() PlayerSettingsRepository.ensureLoaded() + DebridSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() MdbListSettingsRepository.ensureLoaded() MetaScreenSettingsRepository.ensureLoaded() @@ -277,6 +285,7 @@ object ProfileSettingsSync { "liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}", "poster_card_style=${PosterCardStyleRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}", + "debrid=${DebridSettingsRepository.uiState.value}", "tmdb=${TmdbSettingsRepository.uiState.value}", "mdblist=${MdbListSettingsRepository.uiState.value}", "meta=${MetaScreenSettingsRepository.uiState.value}", @@ -299,6 +308,7 @@ private data class MobileProfileSettingsFeatures( @SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()), @SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "", @SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()), + @SerialName("debrid_settings") val debridSettings: JsonObject = JsonObject(emptyMap()), @SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()), @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), @SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "", 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 new file mode 100644 index 00000000..cc89019a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt @@ -0,0 +1,244 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.addons.httpRequestRaw +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +internal data class DebridApiResponse( + val status: Int, + val body: T?, + val rawBody: String, +) { + val isSuccessful: Boolean + get() = status in 200..299 +} + +internal object DebridApiJson { + @OptIn(ExperimentalSerializationApi::class) + val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } +} + +internal object TorboxApiClient { + private const val BASE_URL = "https://api.torbox.app" + + suspend fun validateApiKey(apiKey: String): Boolean = + getUser(apiKey.trim()).status in 200..299 + + private suspend fun getUser(apiKey: String): RawHttpResponse = + httpRequestRaw( + method = "GET", + url = "$BASE_URL/v1/api/user/me", + headers = authHeaders(apiKey), + body = "", + ) + + suspend fun createTorrent(apiKey: String, magnet: String): DebridApiResponse> { + val boundary = "NuvioDebrid${magnet.hashCode().toUInt()}" + val body = multipartFormBody( + boundary = boundary, + "magnet" to magnet, + "add_only_if_cached" to "true", + "allow_zip" to "false", + ) + return request( + method = "POST", + url = "$BASE_URL/v1/api/torrents/createtorrent", + apiKey = apiKey, + body = body, + contentType = "multipart/form-data; boundary=$boundary", + ) + } + + suspend fun getTorrent(apiKey: String, id: Int): DebridApiResponse> = + request( + method = "GET", + url = "$BASE_URL/v1/api/torrents/mylist?${ + queryString( + "id" to id.toString(), + "bypass_cache" to "true", + ) + }", + apiKey = apiKey, + ) + + suspend fun requestDownloadLink( + apiKey: String, + torrentId: Int, + fileId: Int?, + ): DebridApiResponse> = + request( + method = "GET", + url = "$BASE_URL/v1/api/torrents/requestdl?${ + queryString( + "token" to apiKey, + "torrent_id" to torrentId.toString(), + "file_id" to fileId?.toString(), + "zip_link" to "false", + "redirect" to "false", + "append_name" to "false", + ) + }", + apiKey = apiKey, + ) + + private suspend inline fun request( + method: String, + url: String, + apiKey: String, + body: String = "", + contentType: String? = null, + ): DebridApiResponse { + val headers = authHeaders(apiKey) + listOfNotNull( + contentType?.let { "Content-Type" to it }, + "Accept" to "application/json", + ) + val response = httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + ) + return DebridApiResponse( + status = response.status, + body = response.decodeBody(), + rawBody = response.body, + ) + } + + private fun authHeaders(apiKey: String): Map = + mapOf("Authorization" to "Bearer $apiKey") +} + +internal object RealDebridApiClient { + private const val BASE_URL = "https://api.real-debrid.com/rest/1.0" + + suspend fun validateApiKey(apiKey: String): Boolean = + httpRequestRaw( + method = "GET", + url = "$BASE_URL/user", + headers = authHeaders(apiKey.trim()), + body = "", + ).status in 200..299 + + suspend fun addMagnet(apiKey: String, magnet: String): DebridApiResponse = + formRequest( + method = "POST", + url = "$BASE_URL/torrents/addMagnet", + apiKey = apiKey, + fields = listOf("magnet" to magnet), + ) + + suspend fun getTorrentInfo(apiKey: String, id: String): DebridApiResponse = + request( + method = "GET", + url = "$BASE_URL/torrents/info/${encodePathSegment(id)}", + apiKey = apiKey, + ) + + suspend fun selectFiles(apiKey: String, id: String, files: String): DebridApiResponse = + formRequest( + method = "POST", + url = "$BASE_URL/torrents/selectFiles/${encodePathSegment(id)}", + apiKey = apiKey, + fields = listOf("files" to files), + ) + + suspend fun unrestrictLink(apiKey: String, link: String): DebridApiResponse = + formRequest( + method = "POST", + url = "$BASE_URL/unrestrict/link", + apiKey = apiKey, + fields = listOf("link" to link), + ) + + suspend fun deleteTorrent(apiKey: String, id: String): DebridApiResponse = + request( + method = "DELETE", + url = "$BASE_URL/torrents/delete/${encodePathSegment(id)}", + apiKey = apiKey, + ) + + private suspend inline fun formRequest( + method: String, + url: String, + apiKey: String, + fields: List>, + ): DebridApiResponse { + val body = fields.joinToString("&") { (key, value) -> + "${encodeFormValue(key)}=${encodeFormValue(value)}" + } + return request( + method = method, + url = url, + apiKey = apiKey, + body = body, + contentType = "application/x-www-form-urlencoded", + ) + } + + private suspend inline fun request( + method: String, + url: String, + apiKey: String, + body: String = "", + contentType: String? = null, + ): DebridApiResponse { + val headers = authHeaders(apiKey) + listOfNotNull( + contentType?.let { "Content-Type" to it }, + "Accept" to "application/json", + ) + val response = httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + ) + return DebridApiResponse( + status = response.status, + body = response.decodeBody(), + rawBody = response.body, + ) + } + + private fun authHeaders(apiKey: String): Map = + mapOf("Authorization" to "Bearer $apiKey") +} + +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 + } + } +} + +private inline fun RawHttpResponse.decodeBody(): T? { + if (body.isBlank() || T::class == Unit::class) return null + return try { + DebridApiJson.json.decodeFromString(body) + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } +} + +private fun multipartFormBody(boundary: String, vararg fields: Pair): String = + buildString { + fields.forEach { (name, value) -> + append("--").append(boundary).append("\r\n") + append("Content-Disposition: form-data; name=\"").append(name).append("\"\r\n\r\n") + append(value).append("\r\n") + } + append("--").append(boundary).append("--\r\n") + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt new file mode 100644 index 00000000..50a89fde --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt @@ -0,0 +1,94 @@ +package com.nuvio.app.features.debrid + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class TorboxEnvelopeDto( + val success: Boolean? = null, + val data: T? = null, + val error: String? = null, + val detail: String? = null, +) + +@Serializable +internal data class TorboxCreateTorrentDataDto( + @SerialName("torrent_id") val torrentId: Int? = null, + val id: Int? = null, + val hash: String? = null, + @SerialName("auth_id") val authId: String? = null, +) { + fun resolvedTorrentId(): Int? = torrentId ?: id +} + +@Serializable +internal data class TorboxTorrentDataDto( + val id: Int? = null, + val hash: String? = null, + val name: String? = null, + val files: List? = null, +) + +@Serializable +internal data class TorboxTorrentFileDto( + val id: Int? = null, + val name: String? = null, + @SerialName("short_name") val shortName: String? = null, + @SerialName("absolute_path") val absolutePath: String? = null, + @SerialName("mimetype") val mimeType: String? = null, + val size: Long? = null, +) { + fun displayName(): String = + listOfNotNull(name, shortName, absolutePath) + .firstOrNull { it.isNotBlank() } + .orEmpty() +} + +@Serializable +internal data class RealDebridAddTorrentDto( + val id: String? = null, + val uri: String? = null, +) + +@Serializable +internal data class RealDebridTorrentInfoDto( + val id: String? = null, + val filename: String? = null, + @SerialName("original_filename") val originalFilename: String? = null, + val hash: String? = null, + val bytes: Long? = null, + @SerialName("original_bytes") val originalBytes: Long? = null, + val host: String? = null, + val split: Int? = null, + val progress: Int? = null, + val status: String? = null, + val files: List? = null, + val links: List? = null, +) + +@Serializable +internal data class RealDebridTorrentFileDto( + val id: Int? = null, + val path: String? = null, + val bytes: Long? = null, + val selected: Int? = null, +) { + fun displayName(): String = + path.orEmpty().substringAfterLast('/').ifBlank { path.orEmpty() } +} + +@Serializable +internal data class RealDebridUnrestrictLinkDto( + val id: String? = null, + val filename: String? = null, + val mimeType: String? = null, + val filesize: Long? = null, + val link: String? = null, + val host: String? = null, + val chunks: Int? = null, + val crc: Int? = null, + val download: String? = null, + val streamable: Int? = null, + val type: String? = null, +) + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt new file mode 100644 index 00000000..0718df7a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt @@ -0,0 +1,169 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamClientResolve + +internal class TorboxFileSelector { + fun selectFile( + files: List, + resolve: StreamClientResolve, + season: Int?, + episode: Int?, + ): TorboxTorrentFileDto? { + val playable = files.filter { it.isPlayableVideo() } + if (playable.isEmpty()) return null + + val episodePatterns = buildEpisodePatterns( + season = season ?: resolve.season, + episode = episode ?: resolve.episode, + ) + val names = resolve.specificFileNames(episodePatterns) + if (names.isNotEmpty()) { + playable.firstNameMatch(names) { it.displayName() }?.let { + return it + } + } + + if (episodePatterns.isNotEmpty()) { + playable.firstOrNull { file -> + val fileName = file.displayName().lowercase() + episodePatterns.any { pattern -> fileName.contains(pattern) } + }?.let { + return it + } + } + + resolve.fileIdx?.let { fileIdx -> + files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let { + return it + } + if (fileIdx > 0) { + files.getOrNull(fileIdx - 1)?.takeIf { it.isPlayableVideo() }?.let { + return it + } + } + playable.firstOrNull { it.id == fileIdx }?.let { + return it + } + } + + return playable.maxByOrNull { it.size ?: 0L } + } + + private fun TorboxTorrentFileDto.isPlayableVideo(): Boolean { + val mime = mimeType.orEmpty().lowercase() + if (mime.startsWith("video/")) return true + return displayName().lowercase().hasVideoExtension() + } +} + +internal class RealDebridFileSelector { + fun selectFile( + files: List, + resolve: StreamClientResolve, + season: Int?, + episode: Int?, + ): RealDebridTorrentFileDto? { + val playable = files.filter { it.isPlayableVideo() } + if (playable.isEmpty()) return null + + val episodePatterns = buildEpisodePatterns( + season = season ?: resolve.season, + episode = episode ?: resolve.episode, + ) + val names = resolve.specificFileNames(episodePatterns) + if (names.isNotEmpty()) { + playable.firstNameMatch(names) { it.displayName() }?.let { + return it + } + } + + if (episodePatterns.isNotEmpty()) { + playable.firstOrNull { file -> + val fileName = file.displayName().lowercase() + episodePatterns.any { pattern -> fileName.contains(pattern) } + }?.let { + return it + } + } + + resolve.fileIdx?.let { fileIdx -> + files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let { + return it + } + if (fileIdx > 0) { + files.getOrNull(fileIdx - 1)?.takeIf { it.isPlayableVideo() }?.let { + return it + } + } + playable.firstOrNull { it.id == fileIdx }?.let { + return it + } + } + + return playable.maxByOrNull { it.bytes ?: 0L } + } + + private fun RealDebridTorrentFileDto.isPlayableVideo(): Boolean = + displayName().lowercase().hasVideoExtension() +} + +private fun String.normalizedName(): String = + substringAfterLast('/') + .substringBeforeLast('.') + .lowercase() + .replace(Regex("[^a-z0-9]+"), " ") + .trim() + +private fun StreamClientResolve.specificFileNames(episodePatterns: List): List { + val raw = stream?.raw + return listOfNotNull( + filename, + raw?.filename, + raw?.parsed?.rawTitle?.takeIf { it.looksSpecificForSelection(episodePatterns) }, + torrentName?.takeIf { it.looksSpecificForSelection(episodePatterns) }, + ) + .map { it.normalizedName() } + .filter { it.isNotBlank() } + .distinct() +} + +private fun String.looksSpecificForSelection(episodePatterns: List): Boolean { + val lower = lowercase() + return lower.hasVideoExtension() || episodePatterns.any { pattern -> lower.contains(pattern) } +} + +private fun List.firstNameMatch( + names: List, + displayName: (T) -> String, +): T? = + firstOrNull { item -> + val fileName = displayName(item).normalizedName() + names.any { name -> fileName.contains(name) || name.contains(fileName) } + } + +private fun buildEpisodePatterns(season: Int?, episode: Int?): List { + if (season == null || episode == null) return emptyList() + val seasonTwo = season.toString().padStart(2, '0') + val episodeTwo = episode.toString().padStart(2, '0') + return listOf( + "s${seasonTwo}e$episodeTwo", + "${season}x$episodeTwo", + "${season}x$episode", + ) +} + +private fun String.hasVideoExtension(): Boolean = + videoExtensions.any { endsWith(it) } + +private val videoExtensions = setOf( + ".mp4", + ".mkv", + ".webm", + ".avi", + ".mov", + ".m4v", + ".ts", + ".m2ts", + ".wmv", + ".flv", +) 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 new file mode 100644 index 00000000..c37e584d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt @@ -0,0 +1,83 @@ +package com.nuvio.app.features.debrid + +data class DebridProvider( + val id: String, + val displayName: String, + val shortName: String, + val visibleInUi: Boolean = true, +) + +data class DebridServiceCredential( + val provider: DebridProvider, + val apiKey: String, +) + +object DebridProviders { + const val TORBOX_ID = "torbox" + const val REAL_DEBRID_ID = "realdebrid" + + val Torbox = DebridProvider( + id = TORBOX_ID, + displayName = "Torbox", + shortName = "TB", + ) + + val RealDebrid = DebridProvider( + id = REAL_DEBRID_ID, + displayName = "Real-Debrid", + shortName = "RD", + visibleInUi = false, + ) + + private val registered = listOf(Torbox, RealDebrid) + + fun all(): List = registered + + fun visible(): List = registered.filter { it.visibleInUi } + + fun byId(id: String?): DebridProvider? { + val normalized = id?.trim()?.takeIf { it.isNotBlank() } ?: return null + return registered.firstOrNull { it.id.equals(normalized, ignoreCase = true) } + } + + fun isSupported(id: String?): Boolean = byId(id) != null + + fun isVisible(id: String?): Boolean = byId(id)?.visibleInUi == true + + fun instantName(id: String?): String = "${displayName(id)} Instant" + + fun addonId(id: String?): String = + "debrid:${byId(id)?.id ?: id?.trim().orEmpty().ifBlank { "unknown" }}" + + fun displayName(id: String?): String = + byId(id)?.displayName ?: id.toFallbackDisplayName() + + fun shortName(id: String?): String = + 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)) + } + } + + fun configuredSourceNames(settings: DebridSettings): List = + configuredServices(settings).map { instantName(it.provider.id) } + + private fun String?.toFallbackDisplayName(): String { + val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return "Debrid" + return value + .replace('-', ' ') + .replace('_', ' ') + .split(' ') + .filter { it.isNotBlank() } + .joinToString(" ") { part -> + part.lowercase().replaceFirstChar { it.titlecase() } + } + .ifBlank { "Debrid" } + } +} 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 new file mode 100644 index 00000000..6e48cc07 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt @@ -0,0 +1,256 @@ +package com.nuvio.app.features.debrid + +import kotlinx.serialization.Serializable + +data class DebridSettings( + val enabled: Boolean = false, + val torboxApiKey: String = "", + val realDebridApiKey: String = "", + val instantPlaybackPreparationLimit: Int = 0, + val streamMaxResults: Int = 0, + val streamSortMode: DebridStreamSortMode = DebridStreamSortMode.DEFAULT, + val streamMinimumQuality: DebridStreamMinimumQuality = DebridStreamMinimumQuality.ANY, + val streamDolbyVisionFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY, + val streamHdrFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY, + val streamCodecFilter: DebridStreamCodecFilter = DebridStreamCodecFilter.ANY, + val streamPreferences: DebridStreamPreferences = DebridStreamPreferences(), + val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE, + val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, +) { + val hasAnyApiKey: Boolean + get() = DebridProviders.configuredServices(this).isNotEmpty() +} + +const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2 +const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5 + +enum class DebridStreamSortMode { + DEFAULT, + QUALITY_DESC, + SIZE_DESC, + SIZE_ASC, +} + +enum class DebridStreamMinimumQuality(val minResolution: Int) { + ANY(0), + P720(720), + P1080(1080), + P2160(2160), +} + +enum class DebridStreamFeatureFilter { + ANY, + EXCLUDE, + ONLY, +} + +enum class DebridStreamCodecFilter { + ANY, + H264, + HEVC, + AV1, +} + +@Serializable +data class DebridStreamPreferences( + val maxResults: Int = 0, + val maxPerResolution: Int = 0, + val maxPerQuality: Int = 0, + val sizeMinGb: Int = 0, + val sizeMaxGb: Int = 0, + val preferredResolutions: List = DebridStreamResolution.defaultOrder, + val requiredResolutions: List = emptyList(), + val excludedResolutions: List = emptyList(), + val preferredQualities: List = DebridStreamQuality.defaultOrder, + val requiredQualities: List = emptyList(), + val excludedQualities: List = emptyList(), + val preferredVisualTags: List = DebridStreamVisualTag.defaultOrder, + val requiredVisualTags: List = emptyList(), + val excludedVisualTags: List = emptyList(), + val preferredAudioTags: List = DebridStreamAudioTag.defaultOrder, + val requiredAudioTags: List = emptyList(), + val excludedAudioTags: List = emptyList(), + val preferredAudioChannels: List = DebridStreamAudioChannel.defaultOrder, + val requiredAudioChannels: List = emptyList(), + val excludedAudioChannels: List = emptyList(), + val preferredEncodes: List = DebridStreamEncode.defaultOrder, + val requiredEncodes: List = emptyList(), + val excludedEncodes: List = emptyList(), + val preferredLanguages: List = emptyList(), + val requiredLanguages: List = emptyList(), + val excludedLanguages: List = emptyList(), + val requiredReleaseGroups: List = emptyList(), + val excludedReleaseGroups: List = emptyList(), + val sortCriteria: List = DebridStreamSortCriterion.defaultOrder, +) + +@Serializable +enum class DebridStreamResolution(val label: String, val value: Int) { + P2160("2160p", 2160), + P1440("1440p", 1440), + P1080("1080p", 1080), + P720("720p", 720), + P576("576p", 576), + P480("480p", 480), + P360("360p", 360), + UNKNOWN("Unknown", 0); + + companion object { + val defaultOrder = listOf(P2160, P1440, P1080, P720, P576, P480, P360, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamQuality(val label: String) { + BLURAY_REMUX("BluRay REMUX"), + BLURAY("BluRay"), + WEB_DL("WEB-DL"), + WEBRIP("WEBRip"), + HDRIP("HDRip"), + HD_RIP("HC HD-Rip"), + DVDRIP("DVDRip"), + HDTV("HDTV"), + CAM("CAM"), + TS("TS"), + TC("TC"), + SCR("SCR"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(BLURAY_REMUX, BLURAY, WEB_DL, WEBRIP, HDRIP, HD_RIP, DVDRIP, HDTV, CAM, TS, TC, SCR, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamVisualTag(val label: String) { + HDR_DV("HDR+DV"), + DV_ONLY("DV Only"), + HDR_ONLY("HDR Only"), + HDR10_PLUS("HDR10+"), + HDR10("HDR10"), + DV("DV"), + HDR("HDR"), + HLG("HLG"), + TEN_BIT("10bit"), + THREE_D("3D"), + IMAX("IMAX"), + AI("AI"), + SDR("SDR"), + H_OU("H-OU"), + H_SBS("H-SBS"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(HDR_DV, DV_ONLY, HDR_ONLY, HDR10_PLUS, HDR10, DV, HDR, HLG, TEN_BIT, IMAX, SDR, THREE_D, AI, H_OU, H_SBS, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamAudioTag(val label: String) { + ATMOS("Atmos"), + DD_PLUS("DD+"), + DD("DD"), + DTS_X("DTS:X"), + DTS_HD_MA("DTS-HD MA"), + DTS_HD("DTS-HD"), + DTS_ES("DTS-ES"), + DTS("DTS"), + TRUEHD("TrueHD"), + OPUS("OPUS"), + FLAC("FLAC"), + AAC("AAC"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(ATMOS, DD_PLUS, DD, DTS_X, DTS_HD_MA, DTS_HD, DTS_ES, DTS, TRUEHD, OPUS, FLAC, AAC, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamAudioChannel(val label: String) { + CH_2_0("2.0"), + CH_5_1("5.1"), + CH_6_1("6.1"), + CH_7_1("7.1"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(CH_7_1, CH_6_1, CH_5_1, CH_2_0, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamEncode(val label: String) { + AV1("AV1"), + HEVC("HEVC"), + AVC("AVC"), + XVID("XviD"), + DIVX("DivX"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(AV1, HEVC, AVC, XVID, DIVX, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamLanguage(val code: String, val label: String) { + EN("en", "English"), + HI("hi", "Hindi"), + IT("it", "Italian"), + ES("es", "Spanish"), + FR("fr", "French"), + DE("de", "German"), + PT("pt", "Portuguese"), + PL("pl", "Polish"), + CS("cs", "Czech"), + LA("la", "Latino"), + JA("ja", "Japanese"), + KO("ko", "Korean"), + ZH("zh", "Chinese"), + MULTI("multi", "Multi"), + UNKNOWN("unknown", "Unknown"), +} + +@Serializable +data class DebridStreamSortCriterion( + val key: DebridStreamSortKey = DebridStreamSortKey.RESOLUTION, + val direction: DebridStreamSortDirection = DebridStreamSortDirection.DESC, +) { + companion object { + val defaultOrder = listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.VISUAL_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.ENCODE, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + } +} + +@Serializable +enum class DebridStreamSortKey(val label: String) { + RESOLUTION("Resolution"), + QUALITY("Quality"), + VISUAL_TAG("Visual tag"), + AUDIO_TAG("Audio"), + AUDIO_CHANNEL("Audio channel"), + ENCODE("Encode"), + SIZE("Size"), + LANGUAGE("Language"), + RELEASE_GROUP("Release group"), +} + +@Serializable +enum class DebridStreamSortDirection { + ASC, + DESC, +} + +fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int = + value.coerceIn(0, DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT) + +fun normalizeDebridStreamMaxResults(value: Int): Int = + if (value <= 0) 0 else value.coerceIn(1, 100) 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 new file mode 100644 index 00000000..d8c7625b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt @@ -0,0 +1,419 @@ +package com.nuvio.app.features.debrid + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object DebridSettingsRepository { + private val _uiState = MutableStateFlow(DebridSettings()) + val uiState: StateFlow = _uiState.asStateFlow() + + @OptIn(ExperimentalSerializationApi::class) + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + private var hasLoaded = false + private var enabled = false + private var torboxApiKey = "" + private var realDebridApiKey = "" + private var instantPlaybackPreparationLimit = 0 + private var streamMaxResults = 0 + private var streamSortMode = DebridStreamSortMode.DEFAULT + private var streamMinimumQuality = DebridStreamMinimumQuality.ANY + private var streamDolbyVisionFilter = DebridStreamFeatureFilter.ANY + private var streamHdrFilter = DebridStreamFeatureFilter.ANY + private var streamCodecFilter = DebridStreamCodecFilter.ANY + private var streamPreferences = DebridStreamPreferences() + private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE + private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + } + + fun snapshot(): DebridSettings { + ensureLoaded() + return _uiState.value + } + + fun setEnabled(value: Boolean) { + ensureLoaded() + if (value && !hasVisibleApiKey()) return + if (enabled == value) return + enabled = value + publish() + DebridSettingsStorage.saveEnabled(value) + } + + fun setTorboxApiKey(value: String) { + ensureLoaded() + val normalized = value.trim() + if (torboxApiKey == normalized) return + torboxApiKey = normalized + disableIfNoKeys() + publish() + DebridSettingsStorage.saveTorboxApiKey(normalized) + } + + fun setRealDebridApiKey(value: String) { + ensureLoaded() + val normalized = value.trim() + if (realDebridApiKey == normalized) return + realDebridApiKey = normalized + disableIfNoKeys() + publish() + DebridSettingsStorage.saveRealDebridApiKey(normalized) + } + + fun setInstantPlaybackPreparationLimit(value: Int) { + ensureLoaded() + val normalized = normalizeDebridInstantPlaybackPreparationLimit(value) + if (instantPlaybackPreparationLimit == normalized) return + instantPlaybackPreparationLimit = normalized + publish() + DebridSettingsStorage.saveInstantPlaybackPreparationLimit(normalized) + } + + fun setStreamMaxResults(value: Int) { + ensureLoaded() + val normalized = normalizeDebridStreamMaxResults(value) + if (streamMaxResults == normalized && streamPreferences.maxResults == normalized) return + streamMaxResults = normalized + streamPreferences = streamPreferences.copy(maxResults = normalized).normalized() + publish() + DebridSettingsStorage.saveStreamMaxResults(normalized) + saveStreamPreferences() + } + + fun setStreamSortMode(value: DebridStreamSortMode) { + ensureLoaded() + if (streamSortMode == value && streamPreferences.sortCriteria == sortCriteriaForLegacyMode(value)) return + streamSortMode = value + streamPreferences = streamPreferences.copy(sortCriteria = sortCriteriaForLegacyMode(value)).normalized() + publish() + DebridSettingsStorage.saveStreamSortMode(value.name) + saveStreamPreferences() + } + + fun setStreamMinimumQuality(value: DebridStreamMinimumQuality) { + ensureLoaded() + if (streamMinimumQuality == value && streamPreferences.requiredResolutions == resolutionsForMinimumQuality(value)) return + streamMinimumQuality = value + streamPreferences = streamPreferences.copy(requiredResolutions = resolutionsForMinimumQuality(value)).normalized() + publish() + DebridSettingsStorage.saveStreamMinimumQuality(value.name) + saveStreamPreferences() + } + + fun setStreamDolbyVisionFilter(value: DebridStreamFeatureFilter) { + ensureLoaded() + if (streamDolbyVisionFilter == value) return + streamDolbyVisionFilter = value + streamPreferences = streamPreferences.applyDolbyVisionFilter(value).normalized() + publish() + DebridSettingsStorage.saveStreamDolbyVisionFilter(value.name) + saveStreamPreferences() + } + + fun setStreamHdrFilter(value: DebridStreamFeatureFilter) { + ensureLoaded() + if (streamHdrFilter == value) return + streamHdrFilter = value + streamPreferences = streamPreferences.applyHdrFilter(value).normalized() + publish() + DebridSettingsStorage.saveStreamHdrFilter(value.name) + saveStreamPreferences() + } + + fun setStreamCodecFilter(value: DebridStreamCodecFilter) { + ensureLoaded() + if (streamCodecFilter == value) return + streamCodecFilter = value + streamPreferences = streamPreferences.applyCodecFilter(value).normalized() + publish() + DebridSettingsStorage.saveStreamCodecFilter(value.name) + saveStreamPreferences() + } + + fun setStreamPreferences(value: DebridStreamPreferences) { + ensureLoaded() + val normalized = value.normalized() + if (streamPreferences == normalized) return + streamPreferences = normalized + streamMaxResults = normalized.maxResults + publish() + DebridSettingsStorage.saveStreamMaxResults(streamMaxResults) + saveStreamPreferences() + } + + fun setStreamNameTemplate(value: String) { + ensureLoaded() + val normalized = value.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } + if (streamNameTemplate == normalized) return + streamNameTemplate = normalized + publish() + DebridSettingsStorage.saveStreamNameTemplate(normalized) + } + + fun setStreamDescriptionTemplate(value: String) { + ensureLoaded() + val normalized = value.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } + if (streamDescriptionTemplate == normalized) return + streamDescriptionTemplate = normalized + publish() + DebridSettingsStorage.saveStreamDescriptionTemplate(normalized) + } + + fun setStreamTemplates(nameTemplate: String, descriptionTemplate: String) { + ensureLoaded() + streamNameTemplate = nameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } + streamDescriptionTemplate = descriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } + publish() + DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate) + DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate) + } + + fun resetStreamTemplates() { + setStreamTemplates( + nameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE, + descriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, + ) + } + + private fun disableIfNoKeys() { + if (!hasVisibleApiKey()) { + enabled = false + DebridSettingsStorage.saveEnabled(false) + } + } + + private fun hasVisibleApiKey(): Boolean = + (DebridProviders.isVisible(DebridProviders.TORBOX_ID) && torboxApiKey.isNotBlank()) || + (DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID) && realDebridApiKey.isNotBlank()) + + private fun loadFromDisk() { + hasLoaded = true + torboxApiKey = DebridSettingsStorage.loadTorboxApiKey()?.trim().orEmpty() + realDebridApiKey = DebridSettingsStorage.loadRealDebridApiKey()?.trim().orEmpty() + enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasVisibleApiKey() + instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit( + DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0, + ) + streamMaxResults = normalizeDebridStreamMaxResults(DebridSettingsStorage.loadStreamMaxResults() ?: 0) + streamSortMode = enumValueOrDefault( + DebridSettingsStorage.loadStreamSortMode(), + DebridStreamSortMode.DEFAULT, + ) + streamMinimumQuality = enumValueOrDefault( + DebridSettingsStorage.loadStreamMinimumQuality(), + DebridStreamMinimumQuality.ANY, + ) + streamDolbyVisionFilter = enumValueOrDefault( + DebridSettingsStorage.loadStreamDolbyVisionFilter(), + DebridStreamFeatureFilter.ANY, + ) + streamHdrFilter = enumValueOrDefault( + DebridSettingsStorage.loadStreamHdrFilter(), + DebridStreamFeatureFilter.ANY, + ) + streamCodecFilter = enumValueOrDefault( + DebridSettingsStorage.loadStreamCodecFilter(), + DebridStreamCodecFilter.ANY, + ) + streamPreferences = parseStreamPreferences(DebridSettingsStorage.loadStreamPreferences()) + ?: legacyStreamPreferences( + maxResults = streamMaxResults, + sortMode = streamSortMode, + minimumQuality = streamMinimumQuality, + dolbyVisionFilter = streamDolbyVisionFilter, + hdrFilter = streamHdrFilter, + codecFilter = streamCodecFilter, + ) + streamNameTemplate = DebridSettingsStorage.loadStreamNameTemplate() + ?.takeIf { it.isNotBlank() } + ?: DebridStreamFormatterDefaults.NAME_TEMPLATE + streamDescriptionTemplate = DebridSettingsStorage.loadStreamDescriptionTemplate() + ?.takeIf { it.isNotBlank() } + ?: DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE + publish() + } + + private fun publish() { + _uiState.value = DebridSettings( + enabled = enabled, + torboxApiKey = torboxApiKey, + realDebridApiKey = realDebridApiKey, + instantPlaybackPreparationLimit = instantPlaybackPreparationLimit, + streamMaxResults = streamMaxResults, + streamSortMode = streamSortMode, + streamMinimumQuality = streamMinimumQuality, + streamDolbyVisionFilter = streamDolbyVisionFilter, + streamHdrFilter = streamHdrFilter, + streamCodecFilter = streamCodecFilter, + streamPreferences = streamPreferences, + streamNameTemplate = streamNameTemplate, + streamDescriptionTemplate = streamDescriptionTemplate, + ) + } + + private fun saveStreamPreferences() { + DebridSettingsStorage.saveStreamPreferences(json.encodeToString(streamPreferences.normalized())) + } + + private inline fun > enumValueOrDefault(value: String?, default: T): T = + runCatching { enumValueOf(value.orEmpty()) }.getOrDefault(default) + + private fun parseStreamPreferences(value: String?): DebridStreamPreferences? { + if (value.isNullOrBlank()) return null + return try { + json.decodeFromString(value).normalized() + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } + } +} + +internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences = + copy( + maxResults = normalizeDebridStreamMaxResults(maxResults), + maxPerResolution = maxPerResolution.coerceIn(0, 100), + maxPerQuality = maxPerQuality.coerceIn(0, 100), + sizeMinGb = sizeMinGb.coerceIn(0, 100), + sizeMaxGb = sizeMaxGb.coerceIn(0, 100), + preferredResolutions = preferredResolutions.ifEmpty { DebridStreamResolution.defaultOrder }, + requiredResolutions = requiredResolutions, + excludedResolutions = excludedResolutions, + preferredQualities = preferredQualities.ifEmpty { DebridStreamQuality.defaultOrder }, + requiredQualities = requiredQualities, + excludedQualities = excludedQualities, + preferredVisualTags = preferredVisualTags.ifEmpty { DebridStreamVisualTag.defaultOrder }, + requiredVisualTags = requiredVisualTags, + excludedVisualTags = excludedVisualTags, + preferredAudioTags = preferredAudioTags.ifEmpty { DebridStreamAudioTag.defaultOrder }, + requiredAudioTags = requiredAudioTags, + excludedAudioTags = excludedAudioTags, + preferredAudioChannels = preferredAudioChannels.ifEmpty { DebridStreamAudioChannel.defaultOrder }, + requiredAudioChannels = requiredAudioChannels, + excludedAudioChannels = excludedAudioChannels, + preferredEncodes = preferredEncodes.ifEmpty { DebridStreamEncode.defaultOrder }, + requiredEncodes = requiredEncodes, + excludedEncodes = excludedEncodes, + preferredLanguages = preferredLanguages, + requiredLanguages = requiredLanguages, + excludedLanguages = excludedLanguages, + requiredReleaseGroups = requiredReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(), + excludedReleaseGroups = excludedReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(), + sortCriteria = sortCriteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }, + ) + +private fun legacyStreamPreferences( + maxResults: Int, + sortMode: DebridStreamSortMode, + minimumQuality: DebridStreamMinimumQuality, + dolbyVisionFilter: DebridStreamFeatureFilter, + hdrFilter: DebridStreamFeatureFilter, + codecFilter: DebridStreamCodecFilter, +): DebridStreamPreferences = + DebridStreamPreferences( + maxResults = normalizeDebridStreamMaxResults(maxResults), + sortCriteria = sortCriteriaForLegacyMode(sortMode), + requiredResolutions = resolutionsForMinimumQuality(minimumQuality), + ) + .applyDolbyVisionFilter(dolbyVisionFilter) + .applyHdrFilter(hdrFilter) + .applyCodecFilter(codecFilter) + .normalized() + +private fun DebridStreamPreferences.applyDolbyVisionFilter( + filter: DebridStreamFeatureFilter, +): DebridStreamPreferences = + when (filter) { + DebridStreamFeatureFilter.ANY -> copy( + requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(), + excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(), + ) + DebridStreamFeatureFilter.EXCLUDE -> copy( + requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(), + excludedVisualTags = (excludedVisualTags + dolbyVisionTags).distinct(), + ) + DebridStreamFeatureFilter.ONLY -> copy( + requiredVisualTags = (requiredVisualTags + dolbyVisionTags).distinct(), + excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(), + ) + } + +private fun DebridStreamPreferences.applyHdrFilter( + filter: DebridStreamFeatureFilter, +): DebridStreamPreferences = + when (filter) { + DebridStreamFeatureFilter.ANY -> copy( + requiredVisualTags = requiredVisualTags - hdrTags.toSet(), + excludedVisualTags = excludedVisualTags - hdrTags.toSet(), + ) + DebridStreamFeatureFilter.EXCLUDE -> copy( + requiredVisualTags = requiredVisualTags - hdrTags.toSet(), + excludedVisualTags = (excludedVisualTags + hdrTags).distinct(), + ) + DebridStreamFeatureFilter.ONLY -> copy( + requiredVisualTags = (requiredVisualTags + hdrTags).distinct(), + excludedVisualTags = excludedVisualTags - hdrTags.toSet(), + ) + } + +private fun DebridStreamPreferences.applyCodecFilter( + filter: DebridStreamCodecFilter, +): DebridStreamPreferences = + copy( + requiredEncodes = when (filter) { + DebridStreamCodecFilter.ANY -> emptyList() + DebridStreamCodecFilter.H264 -> listOf(DebridStreamEncode.AVC) + DebridStreamCodecFilter.HEVC -> listOf(DebridStreamEncode.HEVC) + DebridStreamCodecFilter.AV1 -> listOf(DebridStreamEncode.AV1) + }, + ) + +private fun resolutionsForMinimumQuality(quality: DebridStreamMinimumQuality): List = + DebridStreamResolution.defaultOrder.filter { + it.value >= quality.minResolution && it != DebridStreamResolution.UNKNOWN + } + +private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List = + when (mode) { + DebridStreamSortMode.DEFAULT -> DebridStreamSortCriterion.defaultOrder + DebridStreamSortMode.QUALITY_DESC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + } + +private val dolbyVisionTags = listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, +) + +private val hdrTags = listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, +) 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 new file mode 100644 index 00000000..62fddac4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -0,0 +1,34 @@ +package com.nuvio.app.features.debrid + +import kotlinx.serialization.json.JsonObject + +internal expect object DebridSettingsStorage { + fun loadEnabled(): Boolean? + fun saveEnabled(enabled: Boolean) + fun loadTorboxApiKey(): String? + fun saveTorboxApiKey(apiKey: String) + fun loadRealDebridApiKey(): String? + fun saveRealDebridApiKey(apiKey: String) + fun loadInstantPlaybackPreparationLimit(): Int? + fun saveInstantPlaybackPreparationLimit(limit: Int) + fun loadStreamMaxResults(): Int? + fun saveStreamMaxResults(maxResults: Int) + fun loadStreamSortMode(): String? + fun saveStreamSortMode(mode: String) + fun loadStreamMinimumQuality(): String? + fun saveStreamMinimumQuality(quality: String) + fun loadStreamDolbyVisionFilter(): String? + fun saveStreamDolbyVisionFilter(filter: String) + fun loadStreamHdrFilter(): String? + fun saveStreamHdrFilter(filter: String) + fun loadStreamCodecFilter(): String? + fun saveStreamCodecFilter(filter: String) + fun loadStreamPreferences(): String? + fun saveStreamPreferences(preferences: String) + fun loadStreamNameTemplate(): String? + fun saveStreamNameTemplate(template: String) + fun loadStreamDescriptionTemplate(): String? + fun saveStreamDescriptionTemplate(template: String) + fun exportToSyncPayload(): JsonObject + fun replaceFromSyncPayload(payload: JsonObject) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt new file mode 100644 index 00000000..dd73d303 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt @@ -0,0 +1,143 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamClientResolveParsed +import com.nuvio.app.features.streams.StreamItem + +class DebridStreamFormatter( + private val engine: DebridStreamTemplateEngine = DebridStreamTemplateEngine(), +) { + fun format(stream: StreamItem, settings: DebridSettings): StreamItem { + if (!stream.isDirectDebridStream) return stream + val values = buildValues(stream) + val formattedName = engine.render(settings.streamNameTemplate, values) + .lineSequence() + .joinToString(" ") { it.trim() } + .replace(Regex("\\s+"), " ") + .trim() + val formattedDescription = engine.render(settings.streamDescriptionTemplate, values) + .lineSequence() + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString("\n") + .trim() + + return stream.copy( + name = formattedName.ifBlank { stream.name ?: DebridProviders.instantName(stream.clientResolve?.service) }, + description = formattedDescription.ifBlank { stream.description ?: stream.title }, + ) + } + + private fun buildValues(stream: StreamItem): Map { + val resolve = stream.clientResolve + val raw = resolve?.stream?.raw + val parsed = raw?.parsed + val seasons = parsed?.seasons.orEmpty() + val episodes = parsed?.episodes.orEmpty() + val season = resolve?.season ?: seasons.singleOrFirstOrNull() + val episode = resolve?.episode ?: episodes.singleOrFirstOrNull() + val visualTags = buildList { + addAll(parsed?.hdr.orEmpty()) + parsed?.bitDepth?.takeIf { it.isNotBlank() }?.let { add(it) } + } + val edition = parsed?.edition ?: buildEdition(parsed) + + return linkedMapOf( + "stream.title" to (parsed?.parsedTitle ?: resolve?.title ?: stream.title), + "stream.year" to parsed?.year, + "stream.season" to season, + "stream.episode" to episode, + "stream.seasons" to seasons, + "stream.episodes" to episodes, + "stream.seasonEpisode" to buildSeasonEpisodeList(season, episode, seasons, episodes), + "stream.formattedEpisodes" to formatEpisodes(episodes), + "stream.formattedSeasons" to formatSeasons(seasons), + "stream.resolution" to parsed?.resolution, + "stream.library" to false, + "stream.quality" to parsed?.quality, + "stream.visualTags" to visualTags, + "stream.audioTags" to parsed?.audio.orEmpty(), + "stream.audioChannels" to parsed?.channels.orEmpty(), + "stream.languages" to parsed?.languages.orEmpty(), + "stream.languageEmojis" to parsed?.languages.orEmpty().map { languageEmoji(it) }, + "stream.size" to (raw?.size ?: stream.behaviorHints.videoSize)?.let(::DebridTemplateBytes), + "stream.folderSize" to raw?.folderSize?.let(::DebridTemplateBytes), + "stream.encode" to parsed?.codec?.uppercase(), + "stream.indexer" to (raw?.indexer ?: raw?.tracker), + "stream.network" to (parsed?.network ?: raw?.network), + "stream.releaseGroup" to parsed?.group, + "stream.duration" to parsed?.duration, + "stream.edition" to edition, + "stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename), + "stream.regexMatched" to null, + "stream.type" to streamType(resolve), + "service.cached" to resolve?.isCached, + "service.shortName" to serviceShortName(resolve), + "service.name" to serviceName(resolve), + "addon.name" to "Nuvio Direct Debrid", + ) + } + + private fun streamType(resolve: StreamClientResolve?): String = + when { + resolve?.type.equals("debrid", ignoreCase = true) -> "Debrid" + resolve?.type.equals("torrent", ignoreCase = true) -> "p2p" + else -> resolve?.type.orEmpty() + } + + private fun serviceShortName(resolve: StreamClientResolve?): String = + resolve?.serviceExtension?.takeIf { it.isNotBlank() } + ?: DebridProviders.shortName(resolve?.service) + + private fun serviceName(resolve: StreamClientResolve?): String = + DebridProviders.displayName(resolve?.service) + + private fun buildEdition(parsed: StreamClientResolveParsed?): String? { + if (parsed == null) return null + return buildList { + if (parsed.extended == true) add("extended") + if (parsed.theatrical == true) add("theatrical") + if (parsed.remastered == true) add("remastered") + if (parsed.unrated == true) add("unrated") + }.joinToString(" ").takeIf { it.isNotBlank() } + } + + private fun buildSeasonEpisodeList( + season: Int?, + episode: Int?, + seasons: List, + episodes: List, + ): List { + if (season != null && episode != null) return listOf("S${season.twoDigits()}E${episode.twoDigits()}") + if (seasons.isEmpty() || episodes.isEmpty()) return emptyList() + return seasons.flatMap { s -> episodes.map { e -> "S${s.twoDigits()}E${e.twoDigits()}" } } + } + + private fun formatEpisodes(episodes: List): String = + episodes.joinToString(" | ") { "E${it.twoDigits()}" } + + private fun formatSeasons(seasons: List): String = + seasons.joinToString(" | ") { "S${it.twoDigits()}" } + + private fun List.singleOrFirstOrNull(): Int? = + singleOrNull() ?: firstOrNull() + + private fun Int.twoDigits(): String = toString().padStart(2, '0') + + private fun languageEmoji(language: String): String = + when (language.lowercase()) { + "en", "eng", "english" -> "GB" + "hi", "hin", "hindi" -> "IN" + "ml", "mal", "malayalam" -> "IN" + "ta", "tam", "tamil" -> "IN" + "te", "tel", "telugu" -> "IN" + "ja", "jpn", "japanese" -> "JP" + "ko", "kor", "korean" -> "KR" + "fr", "fre", "fra", "french" -> "FR" + "es", "spa", "spanish" -> "ES" + "de", "ger", "deu", "german" -> "DE" + "it", "ita", "italian" -> "IT" + "multi" -> "Multi" + else -> language + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt new file mode 100644 index 00000000..bb5d25b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.features.debrid + +object DebridStreamFormatterDefaults { + const val NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{stream.resolution::exists[\"\"||\"Direct \"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}Instant" + + const val DESCRIPTION_TEMPLATE = "{stream.title::exists[\"{stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year})\"||\"\"]}\n{stream.quality::exists[\"{stream.quality} \"||\"\"]}{stream.visualTags::exists[\"{stream.visualTags::join(' | ')} \"||\"\"]}{stream.encode::exists[\"{stream.encode} \"||\"\"]}\n{stream.audioTags::exists[\"{stream.audioTags::join(' | ')}\"||\"\"]}{stream.audioTags::exists::and::stream.audioChannels::exists[\" | \"||\"\"]}{stream.audioChannels::exists[\"{stream.audioChannels::join(' | ')}\"||\"\"]}\n{stream.size::>0[\"{stream.size::bytes} \"||\"\"]}{stream.releaseGroup::exists[\"{stream.releaseGroup} \"||\"\"]}{stream.indexer::exists[\"{stream.indexer}\"||\"\"]}\n{service.cached::istrue[\"Ready\"||\"Not Ready\"]}{service.shortName::exists[\" ({service.shortName})\"||\"\"]}{stream.filename::exists[\"\n{stream.filename}\"||\"\"]}" +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt new file mode 100644 index 00000000..23e635e9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt @@ -0,0 +1,394 @@ +package com.nuvio.app.features.debrid + +import kotlin.math.abs +import kotlin.math.roundToLong + +internal data class DebridTemplateBytes(val value: Long) + +class DebridStreamTemplateEngine { + fun render(template: String, values: Map): String { + if (template.isEmpty()) return "" + val out = StringBuilder() + var index = 0 + while (index < template.length) { + val start = template.indexOf('{', index) + if (start < 0) { + out.append(template.substring(index)) + break + } + out.append(template.substring(index, start)) + val end = findPlaceholderEnd(template, start + 1) + if (end < 0) { + out.append(template.substring(start)) + break + } + val expression = template.substring(start + 1, end) + out.append(renderExpression(expression, values)) + index = end + 1 + } + return out.toString() + } + + private fun renderExpression(expression: String, values: Map): String { + val bracket = findTopLevelChar(expression, '[') + if (bracket >= 0 && expression.endsWith("]")) { + val condition = expression.substring(0, bracket) + val branches = parseBranches(expression.substring(bracket + 1, expression.length - 1)) + val selected = if (evaluateCondition(condition, values)) branches.first else branches.second + return render(selected, values) + } + + val tokens = splitOps(expression) + if (tokens.isEmpty()) return "" + var value: Any? = values[tokens.first()] + tokens.drop(1).forEach { op -> + value = applyTransform(value, op) + } + return valueToText(value) + } + + private fun evaluateCondition(expression: String, values: Map): Boolean { + val tokens = splitOps(expression).filter { it.isNotBlank() } + if (tokens.isEmpty()) return false + val groups = mutableListOf>() + var currentGroup = mutableListOf() + var index = 0 + while (index < tokens.size) { + when (tokens[index]) { + "or" -> { + groups += currentGroup + currentGroup = mutableListOf() + index++ + } + "and" -> index++ + else -> { + val field = tokens[index] + index++ + val ops = mutableListOf() + while ( + index < tokens.size && + tokens[index] != "and" && + tokens[index] != "or" && + !tokens[index].isFieldPath() + ) { + ops += tokens[index] + index++ + } + currentGroup += evaluateSingleCondition(values[field], ops) + } + } + } + groups += currentGroup + return groups.any { group -> group.isNotEmpty() && group.all { it } } + } + + private fun evaluateSingleCondition(value: Any?, ops: List): Boolean { + if (ops.isEmpty()) return isTruthy(value) + var result = false + var hasResult = false + ops.forEach { op -> + when { + op == "exists" -> { + result = exists(value) + hasResult = true + } + op == "istrue" -> { + result = if (hasResult) result else asBoolean(value) == true + hasResult = true + } + op == "isfalse" -> { + result = if (hasResult) !result else asBoolean(value) == false + hasResult = true + } + op.startsWith("~=") -> { + result = containsText(value, op.drop(2).trim()) + hasResult = true + } + op.startsWith("~") -> { + result = containsText(value, op.drop(1).trim()) + hasResult = true + } + op.startsWith("=") -> { + result = equalsText(value, op.drop(1).trim()) + hasResult = true + } + op.startsWith(">=") -> { + result = compareNumber(value, op.drop(2)) { left, right -> left >= right } + hasResult = true + } + op.startsWith("<=") -> { + result = compareNumber(value, op.drop(2)) { left, right -> left <= right } + hasResult = true + } + op.startsWith(">") -> { + result = compareNumber(value, op.drop(1)) { left, right -> left > right } + hasResult = true + } + op.startsWith("<") -> { + result = compareNumber(value, op.drop(1)) { left, right -> left < right } + hasResult = true + } + } + } + return result + } + + private fun applyTransform(value: Any?, op: String): Any? = + when { + op == "title" -> valueToText(value).titleCased() + op == "lower" -> valueToText(value).lowercase() + op == "upper" -> valueToText(value).uppercase() + op == "bytes" -> asNumber(value)?.let { formatBytes(it) }.orEmpty() + op == "time" -> asNumber(value)?.let { formatTime(it) }.orEmpty() + op.startsWith("join(") -> { + val separator = parseArgs(op).firstOrNull() ?: ", " + when (value) { + is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }.joinToString(separator) + else -> valueToText(value) + } + } + op.startsWith("replace(") -> { + val args = parseArgs(op) + if (args.size < 2) valueToText(value) else valueToText(value).replace(args[0], args[1]) + } + else -> value + } + + private fun findPlaceholderEnd(text: String, start: Int): Int { + var quote: Char? = null + var index = start + while (index < text.length) { + val char = text[index] + if (quote != null) { + if (char == quote && (index == 0 || text[index - 1] != '\\')) quote = null + } else { + when (char) { + '\'', '"' -> quote = char + '}' -> return index + } + } + index++ + } + return -1 + } + + private fun findTopLevelChar(text: String, target: Char): Int { + var quote: Char? = null + var parenDepth = 0 + text.forEachIndexed { index, char -> + if (quote != null) { + if (char == quote && (index == 0 || text[index - 1] != '\\')) quote = null + return@forEachIndexed + } + when (char) { + '\'', '"' -> quote = char + '(' -> parenDepth++ + ')' -> parenDepth = (parenDepth - 1).coerceAtLeast(0) + target -> if (parenDepth == 0) return index + } + } + return -1 + } + + private fun splitOps(text: String): List { + val tokens = mutableListOf() + var quote: Char? = null + var parenDepth = 0 + var start = 0 + var index = 0 + while (index < text.length) { + val char = text[index] + if (quote != null) { + if (char == quote && text.getOrNull(index - 1) != '\\') quote = null + index++ + continue + } + when (char) { + '\'', '"' -> quote = char + '(' -> parenDepth++ + ')' -> parenDepth = (parenDepth - 1).coerceAtLeast(0) + ':' -> { + if (parenDepth == 0 && text.getOrNull(index + 1) == ':') { + tokens += text.substring(start, index).trim() + index += 2 + start = index + continue + } + } + } + index++ + } + tokens += text.substring(start).trim() + return tokens.filter { it.isNotEmpty() } + } + + private fun parseBranches(text: String): Pair { + val split = findBranchSeparator(text) + if (split < 0) return parseQuoted(text) to "" + return parseQuoted(text.substring(0, split)) to parseQuoted(text.substring(split + 2)) + } + + private fun findBranchSeparator(text: String): Int { + var quote: Char? = null + text.forEachIndexed { index, char -> + if (quote != null) { + if (char == quote && text.getOrNull(index - 1) != '\\') quote = null + return@forEachIndexed + } + when (char) { + '\'', '"' -> quote = char + '|' -> if (text.getOrNull(index + 1) == '|') return index + } + } + return -1 + } + + private fun parseArgs(op: String): List { + val start = op.indexOf('(') + val end = op.lastIndexOf(')') + if (start < 0 || end <= start) return emptyList() + val body = op.substring(start + 1, end) + val args = mutableListOf() + var quote: Char? = null + var argStart = 0 + body.forEachIndexed { index, char -> + if (quote != null) { + if (char == quote && body.getOrNull(index - 1) != '\\') quote = null + return@forEachIndexed + } + when (char) { + '\'', '"' -> quote = char + ',' -> { + args += parseQuoted(body.substring(argStart, index)) + argStart = index + 1 + } + } + } + args += parseQuoted(body.substring(argStart)) + return args + } + + private fun parseQuoted(raw: String): String { + val trimmed = raw.trim() + val unquoted = if ( + trimmed.length >= 2 && + ((trimmed.first() == '"' && trimmed.last() == '"') || + (trimmed.first() == '\'' && trimmed.last() == '\'')) + ) { + trimmed.substring(1, trimmed.length - 1) + } else { + trimmed + } + return unquoted + .replace("\\n", "\n") + .replace("\\\"", "\"") + .replace("\\'", "'") + .replace("\\\\", "\\") + } + + private fun String.isFieldPath(): Boolean = + startsWith("stream.") || startsWith("service.") || startsWith("addon.") + + private fun exists(value: Any?): Boolean = + when (value) { + null -> false + is String -> value.isNotBlank() + is Iterable<*> -> value.any() + is Array<*> -> value.isNotEmpty() + else -> true + } + + private fun isTruthy(value: Any?): Boolean = + when (value) { + is Boolean -> value + is DebridTemplateBytes -> value.value != 0L + is Number -> value.toDouble() != 0.0 + else -> exists(value) + } + + private fun asBoolean(value: Any?): Boolean? = + when (value) { + is Boolean -> value + is String -> value.toBooleanStrictOrNull() + else -> null + } + + private fun asNumber(value: Any?): Double? = + when (value) { + is Number -> value.toDouble() + is DebridTemplateBytes -> value.value.toDouble() + is String -> value.toDoubleOrNull() + else -> null + } + + private fun compareNumber(value: Any?, rawTarget: String, compare: (Double, Double) -> Boolean): Boolean { + val left = asNumber(value) ?: return false + val right = rawTarget.trim().toDoubleOrNull() ?: return false + return compare(left, right) + } + + private fun equalsText(value: Any?, target: String): Boolean = + when (value) { + is Iterable<*> -> value.any { valueToText(it).trim().equals(target, ignoreCase = true) } + else -> valueToText(value).trim().equals(target, ignoreCase = true) + } + + private fun containsText(value: Any?, target: String): Boolean = + when (value) { + is Iterable<*> -> value.any { valueToText(it).contains(target, ignoreCase = true) } + else -> valueToText(value).contains(target, ignoreCase = true) + } + + private fun valueToText(value: Any?): String = + when (value) { + null -> "" + is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }.joinToString(", ") + is DebridTemplateBytes -> formatBytes(value.value.toDouble()) + is Double -> if (value % 1.0 == 0.0) value.toLong().toString() else value.toString() + is Float -> if (value % 1f == 0f) value.toLong().toString() else value.toString() + else -> value.toString() + } + + private fun String.titleCased(): String = + split(Regex("\\s+")) + .joinToString(" ") { word -> + if (word.isBlank()) { + word + } else { + word.lowercase().replaceFirstChar { char -> + if (char.isLowerCase()) char.titlecase() else char.toString() + } + } + } + + private fun formatBytes(value: Double): String { + val bytes = abs(value) + if (bytes < 1024.0) return "${value.toLong()} B" + val units = listOf("KB", "MB", "GB", "TB") + var current = bytes + var unitIndex = -1 + while (current >= 1024.0 && unitIndex < units.lastIndex) { + current /= 1024.0 + unitIndex++ + } + val signed = if (value < 0) -current else current + return if (signed >= 10 || signed % 1.0 == 0.0) { + "${signed.toLong()} ${units[unitIndex]}" + } else { + val tenths = (signed * 10.0).roundToLong() + "${tenths / 10}.${abs(tenths % 10)} ${units[unitIndex]}" + } + } + + private fun formatTime(value: Double): String { + val seconds = value.toLong() + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val remainingSeconds = seconds % 60 + return when { + hours > 0 -> "${hours}h ${minutes}m" + minutes > 0 -> "${minutes}m ${remainingSeconds}s" + else -> "${remainingSeconds}s" + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt new file mode 100644 index 00000000..2d9465d1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt @@ -0,0 +1,38 @@ +package com.nuvio.app.features.debrid + +internal fun encodePathSegment(value: String): String = + percentEncode(value, spaceAsPlus = false) + +internal fun encodeFormValue(value: String): String = + percentEncode(value, spaceAsPlus = true) + +internal fun queryString(vararg pairs: Pair): String = + pairs + .mapNotNull { (key, value) -> + value?.let { "${encodePathSegment(key)}=${encodePathSegment(it)}" } + } + .joinToString("&") + +private fun percentEncode(value: String, spaceAsPlus: Boolean): String = buildString { + val hex = "0123456789ABCDEF" + value.encodeToByteArray().forEach { byte -> + val code = byte.toInt() and 0xFF + val isUnreserved = (code in 'A'.code..'Z'.code) || + (code in 'a'.code..'z'.code) || + (code in '0'.code..'9'.code) || + code == '-'.code || + code == '.'.code || + code == '_'.code || + code == '~'.code + when { + isUnreserved -> append(code.toChar()) + spaceAsPlus && code == 0x20 -> append('+') + else -> { + append('%') + append(hex[code shr 4]) + append(hex[code and 0x0F]) + } + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt new file mode 100644 index 00000000..855e9124 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt @@ -0,0 +1,39 @@ +package com.nuvio.app.features.debrid + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +class DirectDebridConfigEncoder { + @OptIn(ExperimentalEncodingApi::class) + fun encode(service: DebridServiceCredential): String { + val servicesJson = """{"service":"${service.provider.id.jsonEscaped()}","apiKey":"${service.apiKey.jsonEscaped()}"}""" + val json = """{"cachedOnly":true,"debridServices":[$servicesJson],"enableTorrent":false}""" + return Base64.Default.encode(json.encodeToByteArray()) + } + + fun encodeTorbox(apiKey: String): String = + encode(DebridServiceCredential(DebridProviders.Torbox, apiKey)) +} + +private fun String.jsonEscaped(): String = buildString { + this@jsonEscaped.forEach { char -> + when (char) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> { + if (char.code < 0x20) { + append("\\u") + append(char.code.toString(16).padStart(4, '0')) + } else { + append(char) + } + } + } + } +} + 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 new file mode 100644 index 00000000..6b8e3425 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt @@ -0,0 +1,375 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamBehaviorHints +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.epochMs +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.debrid_missing_api_key +import nuvio.composeapp.generated.resources.debrid_resolve_failed +import nuvio.composeapp.generated.resources.debrid_stream_stale +import org.jetbrains.compose.resources.getString + +object DirectDebridPlaybackResolver { + private val torboxResolver = TorboxDirectDebridResolver() + private val realDebridResolver = RealDebridDirectDebridResolver() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + private val resolvedCache = mutableMapOf() + private val inFlightResolves = mutableMapOf>() + + suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { + val cacheKey = stream.directDebridResolveCacheKey(season, episode) + if (cacheKey == null) { + return resolveUncached(stream, season, episode) + } + getCachedResult(cacheKey)?.let { + return it + } + + var ownsResolve = false + val newResolve = scope.async(start = CoroutineStart.LAZY) { + resolveUncached(stream, season, episode) + } + val activeResolve = mutex.withLock { + getCachedResultLocked(cacheKey)?.let { cached -> + return@withLock null to cached + } + val existing = inFlightResolves[cacheKey] + if (existing != null) { + existing to null + } else { + inFlightResolves[cacheKey] = newResolve + ownsResolve = true + newResolve to null + } + } + activeResolve.second?.let { + newResolve.cancel() + return it + } + val deferred = activeResolve.first ?: return DirectDebridResolveResult.Error + if (!ownsResolve) newResolve.cancel() + if (ownsResolve) deferred.start() + + return try { + val result = deferred.await() + if (ownsResolve && result is DirectDebridResolveResult.Success) { + mutex.withLock { + resolvedCache[cacheKey] = CachedDirectDebridResolve( + result = result, + cachedAtMs = epochMs(), + ) + } + } + result + } finally { + if (ownsResolve) { + mutex.withLock { + if (inFlightResolves[cacheKey] === deferred) { + inFlightResolves.remove(cacheKey) + } + } + } + } + } + + suspend fun cachedPlayableStream(stream: StreamItem, season: Int?, episode: Int?): StreamItem? { + val cacheKey = stream.directDebridResolveCacheKey(season, episode) ?: return null + return getCachedResult(cacheKey) + ?.let { result -> stream.withResolvedDebridUrl(result) } + } + + private suspend fun getCachedResult(cacheKey: String): DirectDebridResolveResult.Success? = + mutex.withLock { getCachedResultLocked(cacheKey) } + + private fun getCachedResultLocked(cacheKey: String): DirectDebridResolveResult.Success? { + val cached = resolvedCache[cacheKey] ?: return null + val age = epochMs() - cached.cachedAtMs + return if (age in 0..DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS) { + cached.result + } else { + resolvedCache.remove(cacheKey) + null + } + } + + private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult = + 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 + } + + suspend fun resolveToPlayableStream( + stream: StreamItem, + season: Int?, + episode: Int?, + ): DirectDebridPlayableResult { + if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) { + return DirectDebridPlayableResult.Success(stream) + } + return when (val result = resolve(stream, season, episode)) { + is DirectDebridResolveResult.Success -> DirectDebridPlayableResult.Success(stream.withResolvedDebridUrl(result)) + DirectDebridResolveResult.MissingApiKey -> DirectDebridPlayableResult.MissingApiKey + DirectDebridResolveResult.Stale -> DirectDebridPlayableResult.Stale + DirectDebridResolveResult.Error -> DirectDebridPlayableResult.Error + } + } +} + +private const val DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS = 15L * 60L * 1000L + +private data class CachedDirectDebridResolve( + val result: DirectDebridResolveResult.Success, + val cachedAtMs: Long, +) + +sealed class DirectDebridPlayableResult { + data class Success(val stream: StreamItem) : DirectDebridPlayableResult() + data object MissingApiKey : DirectDebridPlayableResult() + data object Stale : DirectDebridPlayableResult() + data object Error : DirectDebridPlayableResult() +} + +sealed class DirectDebridResolveResult { + data class Success( + val url: String, + val filename: String?, + val videoSize: Long?, + ) : DirectDebridResolveResult() + + data object MissingApiKey : DirectDebridResolveResult() + data object Stale : DirectDebridResolveResult() + data object Error : DirectDebridResolveResult() +} + +fun DirectDebridPlayableResult.toastMessage(): String? = + when (this) { + is DirectDebridPlayableResult.Success -> null + DirectDebridPlayableResult.MissingApiKey -> runBlocking { getString(Res.string.debrid_missing_api_key) } + DirectDebridPlayableResult.Stale -> runBlocking { getString(Res.string.debrid_stream_stale) } + DirectDebridPlayableResult.Error -> runBlocking { getString(Res.string.debrid_resolve_failed) } + } + +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 fun DebridApiResponse>.toFailureForCreate(): DirectDebridResolveResult = + when (status) { + 401, 403 -> DirectDebridResolveResult.Error + else -> DirectDebridResolveResult.Stale + } +} + +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 + .filter { it.isNotBlank() } + .forEach { source -> + append("&tr=") + append(encodePathSegment(source)) + } + } +} + +private fun StreamItem.directDebridResolveCacheKey(season: Int?, episode: Int?): String? { + val resolve = clientResolve ?: 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 identity = resolve.infoHash + ?: resolve.magnetUri + ?: resolve.torrentName + ?: resolve.filename + ?: return null + + return listOf( + providerId, + apiKey.stableFingerprint(), + identity.trim().lowercase(), + resolve.fileIdx?.toString().orEmpty(), + (resolve.filename ?: behaviorHints.filename).orEmpty().trim().lowercase(), + (season ?: resolve.season)?.toString().orEmpty(), + (episode ?: resolve.episode)?.toString().orEmpty(), + ).joinToString("|") +} + +private fun String.stableFingerprint(): String { + val hash = fold(1125899906842597L) { acc, char -> (acc * 31L) + char.code } + return hash.toULong().toString(16) +} + +private fun StreamItem.withResolvedDebridUrl(result: DirectDebridResolveResult.Success): StreamItem = + copy( + url = result.url, + externalUrl = null, + behaviorHints = behaviorHints.mergeResolvedDebridHints(result), + ) + +private fun StreamBehaviorHints.mergeResolvedDebridHints(result: DirectDebridResolveResult.Success): StreamBehaviorHints = + copy( + filename = result.filename ?: filename, + videoSize = result.videoSize ?: videoSize, + ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt new file mode 100644 index 00000000..6647d607 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt @@ -0,0 +1,425 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamItem + +object DirectDebridStreamFilter { + const val FALLBACK_SOURCE_NAME = "Direct Debrid" + + fun filterInstant(streams: List, settings: DebridSettings? = null): List { + val instantStreams = streams + .filter(::isInstantCandidate) + .map { stream -> + val providerId = stream.clientResolve?.service + val sourceName = DebridProviders.instantName(providerId) + stream.copy( + name = stream.name ?: sourceName, + addonName = sourceName, + addonId = DebridProviders.addonId(providerId), + sourceName = stream.sourceName ?: FALLBACK_SOURCE_NAME, + ) + } + .distinctBy { stream -> + listOf( + stream.clientResolve?.infoHash?.lowercase(), + stream.clientResolve?.fileIdx?.toString(), + stream.clientResolve?.filename, + stream.name, + stream.title, + ).joinToString("|") + } + return if (settings == null) instantStreams else applyPreferences(instantStreams, settings) + } + + fun isInstantCandidate(stream: StreamItem): Boolean { + val resolve = stream.clientResolve ?: return false + return resolve.type.equals("debrid", ignoreCase = true) && + DebridProviders.isSupported(resolve.service) && + resolve.isCached == true + } + + fun isDirectDebridSourceName(addonName: String): Boolean = + DebridProviders.all().any { addonName == DebridProviders.instantName(it.id) } + + private fun applyPreferences(streams: List, settings: DebridSettings): List { + val preferences = effectivePreferences(settings) + return streams.map { it to streamFacts(it, preferences) } + .filter { (_, facts) -> facts.matchesFilters(preferences) } + .sortedWith { left, right -> compareFacts(left.second, right.second, preferences.sortCriteria) } + .let { sorted -> applyLimits(sorted, preferences) } + .map { it.first } + } + + private fun effectivePreferences(settings: DebridSettings): DebridStreamPreferences { + val default = DebridStreamPreferences() + if (settings.streamPreferences != default) return settings.streamPreferences.normalized() + if ( + settings.streamMaxResults == 0 && + settings.streamSortMode == DebridStreamSortMode.DEFAULT && + settings.streamMinimumQuality == DebridStreamMinimumQuality.ANY && + settings.streamDolbyVisionFilter == DebridStreamFeatureFilter.ANY && + settings.streamHdrFilter == DebridStreamFeatureFilter.ANY && + settings.streamCodecFilter == DebridStreamCodecFilter.ANY + ) { + return default + } + var preferences = default.copy( + maxResults = settings.streamMaxResults, + sortCriteria = when (settings.streamSortMode) { + DebridStreamSortMode.DEFAULT -> default.sortCriteria + DebridStreamSortMode.QUALITY_DESC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + }, + requiredResolutions = DebridStreamResolution.defaultOrder.filter { + it.value >= settings.streamMinimumQuality.minResolution && it != DebridStreamResolution.UNKNOWN + }, + ) + preferences = when (settings.streamDolbyVisionFilter) { + DebridStreamFeatureFilter.ANY -> preferences + DebridStreamFeatureFilter.EXCLUDE -> preferences.copy( + excludedVisualTags = preferences.excludedVisualTags + listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + DebridStreamFeatureFilter.ONLY -> preferences.copy( + requiredVisualTags = preferences.requiredVisualTags + listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + } + preferences = when (settings.streamHdrFilter) { + DebridStreamFeatureFilter.ANY -> preferences + DebridStreamFeatureFilter.EXCLUDE -> preferences.copy( + excludedVisualTags = preferences.excludedVisualTags + listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + DebridStreamFeatureFilter.ONLY -> preferences.copy( + requiredVisualTags = preferences.requiredVisualTags + listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + } + return when (settings.streamCodecFilter) { + DebridStreamCodecFilter.ANY -> preferences + DebridStreamCodecFilter.H264 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AVC)) + DebridStreamCodecFilter.HEVC -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.HEVC)) + DebridStreamCodecFilter.AV1 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AV1)) + }.normalized() + } + + private fun applyLimits( + streams: List>, + preferences: DebridStreamPreferences, + ): List> { + val resolutionCounts = mutableMapOf() + val qualityCounts = mutableMapOf() + val result = mutableListOf>() + for (stream in streams) { + if (preferences.maxResults > 0 && result.size >= preferences.maxResults) break + if (preferences.maxPerResolution > 0) { + val count = resolutionCounts[stream.second.resolution] ?: 0 + if (count >= preferences.maxPerResolution) continue + } + if (preferences.maxPerQuality > 0) { + val count = qualityCounts[stream.second.quality] ?: 0 + if (count >= preferences.maxPerQuality) continue + } + resolutionCounts[stream.second.resolution] = (resolutionCounts[stream.second.resolution] ?: 0) + 1 + qualityCounts[stream.second.quality] = (qualityCounts[stream.second.quality] ?: 0) + 1 + result += stream + } + return result + } + + private fun StreamFacts.matchesFilters(preferences: DebridStreamPreferences): Boolean { + if (preferences.requiredResolutions.isNotEmpty() && resolution !in preferences.requiredResolutions) return false + if (resolution in preferences.excludedResolutions) return false + if (preferences.requiredQualities.isNotEmpty() && quality !in preferences.requiredQualities) return false + if (quality in preferences.excludedQualities) return false + if (preferences.requiredVisualTags.isNotEmpty() && visualTags.none { it in preferences.requiredVisualTags }) return false + if (visualTags.any { it in preferences.excludedVisualTags }) return false + if (preferences.requiredAudioTags.isNotEmpty() && audioTags.none { it in preferences.requiredAudioTags }) return false + if (audioTags.any { it in preferences.excludedAudioTags }) return false + if (preferences.requiredAudioChannels.isNotEmpty() && audioChannels.none { it in preferences.requiredAudioChannels }) return false + if (audioChannels.any { it in preferences.excludedAudioChannels }) return false + if (preferences.requiredEncodes.isNotEmpty() && encode !in preferences.requiredEncodes) return false + if (encode in preferences.excludedEncodes) return false + if (preferences.requiredLanguages.isNotEmpty() && languages.none { it in preferences.requiredLanguages }) return false + if (languages.isNotEmpty() && languages.all { it in preferences.excludedLanguages }) return false + if (preferences.requiredReleaseGroups.isNotEmpty() && preferences.requiredReleaseGroups.none { releaseGroup.equals(it, ignoreCase = true) }) return false + if (preferences.excludedReleaseGroups.any { releaseGroup.equals(it, ignoreCase = true) }) return false + if (preferences.sizeMinGb > 0 && size != null && size < preferences.sizeMinGb.gigabytes()) return false + if (preferences.sizeMaxGb > 0 && size != null && size > preferences.sizeMaxGb.gigabytes()) return false + return true + } + + private fun compareFacts( + left: StreamFacts, + right: StreamFacts, + criteria: List, + ): Int { + for (criterion in criteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }) { + val comparison = compareKey(left, right, criterion) + if (comparison != 0) return comparison + } + return 0 + } + + private fun compareKey( + left: StreamFacts, + right: StreamFacts, + criterion: DebridStreamSortCriterion, + ): Int { + val direction = if (criterion.direction == DebridStreamSortDirection.ASC) 1 else -1 + return when (criterion.key) { + DebridStreamSortKey.RESOLUTION -> left.resolutionRank.compareTo(right.resolutionRank) * -direction + DebridStreamSortKey.QUALITY -> left.qualityRank.compareTo(right.qualityRank) * -direction + DebridStreamSortKey.VISUAL_TAG -> left.visualRank.compareTo(right.visualRank) * -direction + DebridStreamSortKey.AUDIO_TAG -> left.audioRank.compareTo(right.audioRank) * -direction + DebridStreamSortKey.AUDIO_CHANNEL -> left.channelRank.compareTo(right.channelRank) * -direction + DebridStreamSortKey.ENCODE -> left.encodeRank.compareTo(right.encodeRank) * -direction + DebridStreamSortKey.SIZE -> (left.size ?: 0L).compareTo(right.size ?: 0L) * direction + DebridStreamSortKey.LANGUAGE -> left.languageRank.compareTo(right.languageRank) * -direction + DebridStreamSortKey.RELEASE_GROUP -> left.releaseGroup.compareTo(right.releaseGroup, ignoreCase = true) + } + } + + private fun streamFacts(stream: StreamItem, preferences: DebridStreamPreferences): StreamFacts { + val parsed = stream.clientResolve?.stream?.raw?.parsed + val searchText = streamSearchText(stream) + val resolution = streamResolution(parsed?.resolution, parsed?.quality, searchText) + val quality = streamQuality(parsed?.quality, searchText) + val visualTags = streamVisualTags(parsed?.hdr.orEmpty(), searchText) + val audioTags = streamAudioTags(parsed?.audio.orEmpty(), searchText) + val audioChannels = streamAudioChannels(parsed?.channels.orEmpty(), searchText) + val encode = streamEncode(parsed?.codec, searchText) + val languages = parsed?.languages.orEmpty().mapNotNull { languageFor(it) }.ifEmpty { + DebridStreamLanguage.entries.filter { searchText.hasToken(it.code) } + } + val releaseGroup = parsed?.group?.takeIf { it.isNotBlank() } ?: releaseGroupFromText(searchText) + return StreamFacts( + resolution = resolution, + quality = quality, + visualTags = visualTags, + audioTags = audioTags, + audioChannels = audioChannels, + encode = encode, + languages = languages, + releaseGroup = releaseGroup, + size = streamSize(stream), + resolutionRank = rank(resolution, preferences.preferredResolutions), + qualityRank = rank(quality, preferences.preferredQualities), + visualRank = rankAny(visualTags, preferences.preferredVisualTags), + audioRank = rankAny(audioTags, preferences.preferredAudioTags), + channelRank = rankAny(audioChannels, preferences.preferredAudioChannels), + encodeRank = rank(encode, preferences.preferredEncodes), + languageRank = if (languages.isEmpty()) Int.MAX_VALUE else languages.minOf { rank(it, preferences.preferredLanguages) }, + ) + } + + private fun streamResolution(vararg values: String?): DebridStreamResolution = + values.firstNotNullOfOrNull { resolutionValue(it) } ?: DebridStreamResolution.UNKNOWN + + private fun resolutionValue(value: String?): DebridStreamResolution? { + val normalized = value?.lowercase().orEmpty() + return when { + normalized.hasResolutionToken("2160p?", "4k", "uhd") -> DebridStreamResolution.P2160 + normalized.hasResolutionToken("1440p?", "2k") -> DebridStreamResolution.P1440 + normalized.hasResolutionToken("1080p?", "fhd") -> DebridStreamResolution.P1080 + normalized.hasResolutionToken("720p?", "hd") -> DebridStreamResolution.P720 + normalized.hasResolutionToken("576p?") -> DebridStreamResolution.P576 + normalized.hasResolutionToken("480p?", "sd") -> DebridStreamResolution.P480 + normalized.hasResolutionToken("360p?") -> DebridStreamResolution.P360 + else -> null + } + } + + private fun streamQuality(parsedQuality: String?, searchText: String): DebridStreamQuality { + val text = listOfNotNull(parsedQuality, searchText).joinToString(" ").lowercase() + return when { + text.contains("remux") -> DebridStreamQuality.BLURAY_REMUX + text.contains("blu-ray") || text.contains("bluray") || text.contains("bdrip") || text.contains("brrip") -> DebridStreamQuality.BLURAY + text.contains("web-dl") || text.contains("webdl") -> DebridStreamQuality.WEB_DL + text.contains("webrip") || text.contains("web-rip") -> DebridStreamQuality.WEBRIP + text.contains("hdrip") -> DebridStreamQuality.HDRIP + text.contains("hd-rip") || text.contains("hcrip") -> DebridStreamQuality.HD_RIP + text.contains("dvdrip") -> DebridStreamQuality.DVDRIP + text.contains("hdtv") -> DebridStreamQuality.HDTV + text.hasToken("cam") -> DebridStreamQuality.CAM + text.hasToken("ts") -> DebridStreamQuality.TS + text.hasToken("tc") -> DebridStreamQuality.TC + text.hasToken("scr") -> DebridStreamQuality.SCR + else -> DebridStreamQuality.UNKNOWN + } + } + + private fun streamVisualTags(parsedHdr: List, searchText: String): List { + val text = (parsedHdr + searchText).joinToString(" ").lowercase() + val tags = mutableListOf() + val hasDv = parsedHdr.any { it.isDolbyVisionToken() } || + Regex("(^|[^a-z0-9])(dv|dovi|dolby[ ._-]?vision)([^a-z0-9]|$)").containsMatchIn(searchText) + val hasHdr = parsedHdr.any { it.isHdrToken() } || + Regex("(^|[^a-z0-9])(hdr|hdr10|hdr10plus|hdr10\\+|hlg)([^a-z0-9]|$)").containsMatchIn(searchText) + if (hasDv && hasHdr) tags += DebridStreamVisualTag.HDR_DV + if (hasDv && !hasHdr) tags += DebridStreamVisualTag.DV_ONLY + if (hasHdr && !hasDv) tags += DebridStreamVisualTag.HDR_ONLY + if (text.contains("hdr10+") || text.contains("hdr10plus")) tags += DebridStreamVisualTag.HDR10_PLUS + if (text.contains("hdr10")) tags += DebridStreamVisualTag.HDR10 + if (hasDv) tags += DebridStreamVisualTag.DV + if (hasHdr) tags += DebridStreamVisualTag.HDR + if (text.hasToken("hlg")) tags += DebridStreamVisualTag.HLG + if (text.contains("10bit") || text.contains("10 bit")) tags += DebridStreamVisualTag.TEN_BIT + if (text.hasToken("3d")) tags += DebridStreamVisualTag.THREE_D + if (text.hasToken("imax")) tags += DebridStreamVisualTag.IMAX + if (text.hasToken("ai")) tags += DebridStreamVisualTag.AI + if (text.hasToken("sdr")) tags += DebridStreamVisualTag.SDR + if (text.contains("h-ou")) tags += DebridStreamVisualTag.H_OU + if (text.contains("h-sbs")) tags += DebridStreamVisualTag.H_SBS + return tags.distinct().ifEmpty { listOf(DebridStreamVisualTag.UNKNOWN) } + } + + private fun streamAudioTags(parsedAudio: List, searchText: String): List { + val text = (parsedAudio + searchText).joinToString(" ").lowercase() + val tags = mutableListOf() + if (text.hasToken("atmos")) tags += DebridStreamAudioTag.ATMOS + if (text.contains("dd+") || text.contains("ddp") || text.contains("dolby digital plus")) tags += DebridStreamAudioTag.DD_PLUS + if (text.hasToken("dd") || text.contains("ac3") || text.contains("dolby digital")) tags += DebridStreamAudioTag.DD + if (text.contains("dts:x") || text.contains("dtsx")) tags += DebridStreamAudioTag.DTS_X + if (text.contains("dts-hd ma") || text.contains("dtshd ma")) tags += DebridStreamAudioTag.DTS_HD_MA + if (text.contains("dts-hd") || text.contains("dtshd")) tags += DebridStreamAudioTag.DTS_HD + if (text.contains("dts-es") || text.contains("dtses")) tags += DebridStreamAudioTag.DTS_ES + if (text.hasToken("dts")) tags += DebridStreamAudioTag.DTS + if (text.contains("truehd") || text.contains("true hd")) tags += DebridStreamAudioTag.TRUEHD + if (text.hasToken("opus")) tags += DebridStreamAudioTag.OPUS + if (text.hasToken("flac")) tags += DebridStreamAudioTag.FLAC + if (text.hasToken("aac")) tags += DebridStreamAudioTag.AAC + return tags.distinct().ifEmpty { listOf(DebridStreamAudioTag.UNKNOWN) } + } + + private fun streamAudioChannels(parsedChannels: List, searchText: String): List { + val text = (parsedChannels + searchText).joinToString(" ").lowercase() + val channels = mutableListOf() + if (text.hasToken("7.1")) channels += DebridStreamAudioChannel.CH_7_1 + if (text.hasToken("6.1")) channels += DebridStreamAudioChannel.CH_6_1 + if (text.hasToken("5.1") || text.hasToken("6ch")) channels += DebridStreamAudioChannel.CH_5_1 + if (text.hasToken("2.0")) channels += DebridStreamAudioChannel.CH_2_0 + return channels.distinct().ifEmpty { listOf(DebridStreamAudioChannel.UNKNOWN) } + } + + private fun streamEncode(parsedCodec: String?, searchText: String): DebridStreamEncode { + val text = listOfNotNull(parsedCodec, searchText).joinToString(" ").lowercase() + return when { + text.hasToken("av1") -> DebridStreamEncode.AV1 + text.hasToken("hevc") || text.hasToken("h265") || text.hasToken("x265") -> DebridStreamEncode.HEVC + text.hasToken("avc") || text.hasToken("h264") || text.hasToken("x264") -> DebridStreamEncode.AVC + text.hasToken("xvid") -> DebridStreamEncode.XVID + text.hasToken("divx") -> DebridStreamEncode.DIVX + else -> DebridStreamEncode.UNKNOWN + } + } + + private fun languageFor(value: String): DebridStreamLanguage? { + val normalized = value.lowercase() + return DebridStreamLanguage.entries.firstOrNull { + normalized == it.code || normalized == it.label.lowercase() + } + } + + private fun releaseGroupFromText(text: String): String = + Regex("-([a-z0-9][a-z0-9._]{1,24})($|\\.)", RegexOption.IGNORE_CASE) + .find(text) + ?.groupValues + ?.getOrNull(1) + .orEmpty() + + private fun rank(value: T, preferred: List): Int { + val index = preferred.indexOf(value) + return if (index >= 0) index else Int.MAX_VALUE + } + + private fun rankAny(values: List, preferred: List): Int = + values.minOfOrNull { rank(it, preferred) } ?: Int.MAX_VALUE + + private fun String.hasResolutionToken(vararg tokens: String): Boolean = + Regex("(^|[^a-z0-9])(${tokens.joinToString("|")})([^a-z0-9]|\$)").containsMatchIn(this) + + private fun String.hasToken(token: String): Boolean = + Regex("(^|[^a-z0-9])${Regex.escape(token.lowercase())}([^a-z0-9]|\$)").containsMatchIn(lowercase()) + + private fun String.isDolbyVisionToken(): Boolean { + val normalized = lowercase().replace(Regex("[^a-z0-9]"), "") + return normalized == "dv" || normalized == "dovi" || normalized == "dolbyvision" + } + + private fun String.isHdrToken(): Boolean { + val normalized = lowercase().replace(Regex("[^a-z0-9+]"), "") + return normalized == "hdr" || + normalized == "hdr10" || + normalized == "hdr10+" || + normalized == "hdr10plus" || + normalized == "hlg" + } + + private fun streamSize(stream: StreamItem): Long? = + stream.clientResolve?.stream?.raw?.size ?: stream.behaviorHints.videoSize + + private fun streamSearchText(stream: StreamItem): String { + val resolve = stream.clientResolve + val raw = resolve?.stream?.raw + val parsed = raw?.parsed + return listOfNotNull( + stream.name, + stream.title, + stream.description, + resolve?.torrentName, + resolve?.filename, + raw?.torrentName, + raw?.filename, + parsed?.resolution, + parsed?.quality, + parsed?.codec, + parsed?.hdr?.joinToString(" "), + parsed?.audio?.joinToString(" "), + ).joinToString(" ").lowercase() + } + + private fun Int.gigabytes(): Long = this * 1_000_000_000L + + private data class StreamFacts( + val resolution: DebridStreamResolution, + val quality: DebridStreamQuality, + val visualTags: List, + val audioTags: List, + val audioChannels: List, + val encode: DebridStreamEncode, + val languages: List, + val releaseGroup: String, + val size: Long?, + val resolutionRank: Int, + val qualityRank: Int, + val visualRank: Int, + val audioRank: Int, + val channelRank: Int, + val encodeRank: Int, + val languageRank: Int, + ) +} 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 new file mode 100644 index 00000000..61952674 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt @@ -0,0 +1,196 @@ +package com.nuvio.app.features.debrid + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.player.PlayerSettingsUiState +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamAutoPlayMode +import com.nuvio.app.features.streams.StreamAutoPlaySelector +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.epochMs +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +object DirectDebridStreamPreparer { + private val log = Logger.withTag("DirectDebridPreparer") + private val budgetMutex = Mutex() + private val minuteStarts = ArrayDeque() + private val hourStarts = ArrayDeque() + + suspend fun prepare( + streams: List, + season: Int?, + episode: Int?, + playerSettings: PlayerSettingsUiState, + installedAddonNames: Set, + onPrepared: (original: StreamItem, prepared: StreamItem) -> Unit, + ) { + val settings = DebridSettingsRepository.snapshot() + val limit = settings.instantPlaybackPreparationLimit + if (!settings.enabled || limit <= 0 || !settings.hasAnyApiKey) return + + val candidates = prioritizeCandidates( + streams = streams, + limit = limit, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) + for (stream in candidates) { + DirectDebridPlaybackResolver.cachedPlayableStream(stream, season, episode)?.let { cached -> + onPrepared(stream, cached) + continue + } + + if (!consumeBackgroundBudget()) { + log.d { "Skipping instant playback preparation; local Torbox budget reached" } + return + } + + try { + when (val result = DirectDebridPlaybackResolver.resolveToPlayableStream(stream, season, episode)) { + is DirectDebridPlayableResult.Success -> { + if (result.stream.directPlaybackUrl != null) { + onPrepared(stream, result.stream) + } + } + else -> Unit + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + log.d(error) { "Instant playback preparation failed" } + } + } + } + + internal fun prioritizeCandidates( + streams: List, + limit: Int, + playerSettings: PlayerSettingsUiState, + installedAddonNames: Set, + ): List { + if (limit <= 0) return emptyList() + val candidates = streams + .filter { it.isDirectDebridStream && it.directPlaybackUrl == null } + .distinctBy { it.preparationKey() } + if (candidates.isEmpty()) return emptyList() + + val prioritized = mutableListOf() + val autoPlaySelection = StreamAutoPlaySelector.selectAutoPlayStream( + streams = streams, + mode = playerSettings.streamAutoPlayMode, + regexPattern = playerSettings.streamAutoPlayRegex, + source = playerSettings.streamAutoPlaySource, + installedAddonNames = installedAddonNames, + selectedAddons = playerSettings.streamAutoPlaySelectedAddons, + selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + ) + if (autoPlaySelection?.isDirectDebridStream == true) { + candidates.firstOrNull { it.preparationKey() == autoPlaySelection.preparationKey() } + ?.let(prioritized::add) + } + + if (playerSettings.streamAutoPlayMode == StreamAutoPlayMode.REGEX_MATCH) { + val regex = runCatching { + Regex(playerSettings.streamAutoPlayRegex.trim(), RegexOption.IGNORE_CASE) + }.getOrNull() + if (regex != null) { + candidates + .filter { candidate -> + prioritized.none { it.preparationKey() == candidate.preparationKey() } && + regex.containsMatchIn(candidate.searchableText()) + } + .forEach(prioritized::add) + } + } + + candidates + .filter { candidate -> prioritized.none { it.preparationKey() == candidate.preparationKey() } } + .forEach(prioritized::add) + + return prioritized.take(limit) + } + + fun replacePreparedStream( + groups: List, + original: StreamItem, + prepared: StreamItem, + ): List { + val key = original.preparationKey() + return groups.map { group -> + var changed = false + val updatedStreams = group.streams.map { stream -> + if (stream.preparationKey() == key) { + changed = true + prepared.copy( + addonName = stream.addonName, + addonId = stream.addonId, + sourceName = stream.sourceName, + ) + } else { + stream + } + } + if (changed) group.copy(streams = updatedStreams) else group + } + } + + private suspend fun consumeBackgroundBudget(): Boolean { + val now = epochMs() + return budgetMutex.withLock { + minuteStarts.removeOlderThan(now - BACKGROUND_PREPARES_PER_MINUTE_WINDOW_MS) + hourStarts.removeOlderThan(now - BACKGROUND_PREPARES_PER_HOUR_WINDOW_MS) + if ( + minuteStarts.size >= MAX_BACKGROUND_PREPARES_PER_MINUTE || + hourStarts.size >= MAX_BACKGROUND_PREPARES_PER_HOUR + ) { + false + } else { + minuteStarts.addLast(now) + hourStarts.addLast(now) + true + } + } + } +} + +private const val MAX_BACKGROUND_PREPARES_PER_MINUTE = 6 +private const val MAX_BACKGROUND_PREPARES_PER_HOUR = 30 +private const val BACKGROUND_PREPARES_PER_MINUTE_WINDOW_MS = 60L * 1000L +private const val BACKGROUND_PREPARES_PER_HOUR_WINDOW_MS = 60L * 60L * 1000L + +private fun ArrayDeque.removeOlderThan(cutoffMs: Long) { + while (firstOrNull()?.let { it < cutoffMs } == true) { + removeFirst() + } +} + +private fun StreamItem.preparationKey(): String { + val resolve = clientResolve + if (resolve != null) { + return listOf( + resolve.service.orEmpty().lowercase(), + resolve.infoHash.orEmpty().lowercase(), + resolve.fileIdx?.toString().orEmpty(), + resolve.filename.orEmpty().lowercase(), + resolve.torrentName.orEmpty().lowercase(), + resolve.magnetUri.orEmpty().lowercase(), + ).joinToString("|") + } + + return listOf( + addonId.lowercase(), + directPlaybackUrl.orEmpty().lowercase(), + name.orEmpty().lowercase(), + title.orEmpty().lowercase(), + ).joinToString("|") +} + +private fun StreamItem.searchableText(): String = + buildString { + append(addonName).append(' ') + append(name.orEmpty()).append(' ') + append(title.orEmpty()).append(' ') + append(description.orEmpty()).append(' ') + append(directPlaybackUrl.orEmpty()) + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt new file mode 100644 index 00000000..6cd5573d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt @@ -0,0 +1,253 @@ +package com.nuvio.app.features.debrid + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamParser +import com.nuvio.app.features.streams.epochMs +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private const val DIRECT_DEBRID_TAG = "DirectDebridStreams" +private const val STREAM_CACHE_TTL_MS = 5L * 60L * 1000L + +data class DirectDebridStreamTarget( + val provider: DebridProvider, + val apiKey: String, +) { + val addonId: String = DebridProviders.addonId(provider.id) + val addonName: String = DebridProviders.instantName(provider.id) +} + +object DirectDebridStreamSource { + private val log = Logger.withTag(DIRECT_DEBRID_TAG) + private val encoder = DirectDebridConfigEncoder() + private val formatter = DebridStreamFormatter() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + private val streamCache = mutableMapOf() + private val inFlightFetches = mutableMapOf>() + + fun configuredTargets(): List { + DebridSettingsRepository.ensureLoaded() + val settings = DebridSettingsRepository.snapshot() + if (!settings.enabled || DebridConfig.DIRECT_DEBRID_API_BASE_URL.isBlank()) return emptyList() + return DebridProviders.configuredServices(settings).map { credential -> + DirectDebridStreamTarget( + provider = credential.provider, + apiKey = credential.apiKey, + ) + } + } + + fun sourceNames(): List = + configuredTargets().map { it.addonName } + + fun isEnabled(): Boolean = + sourceNames().isNotEmpty() + + fun placeholders(): List = + configuredTargets().map { target -> + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = emptyList(), + isLoading = true, + ) + } + + fun preloadStreams(type: String, videoId: String) { + if (type.isBlank() || videoId.isBlank()) return + configuredTargets().forEach { target -> + scope.launch { + runCatching { fetchProviderStreams(type, videoId, target) } + } + } + } + + suspend fun fetchStreams(type: String, videoId: String): DirectDebridStreamFetchResult { + val targets = configuredTargets() + if (targets.isEmpty()) return DirectDebridStreamFetchResult.Disabled + + val results = mutableListOf() + val errors = mutableListOf() + targets.forEach { target -> + val group = fetchProviderStreams(type, videoId, target) + when { + group.streams.isNotEmpty() -> results += group + !group.error.isNullOrBlank() -> errors += group.error + } + } + + return when { + results.isNotEmpty() -> DirectDebridStreamFetchResult.Success(results) + errors.isNotEmpty() -> DirectDebridStreamFetchResult.Error(errors.first()) + else -> DirectDebridStreamFetchResult.Empty + } + } + + suspend fun fetchProviderStreams( + type: String, + videoId: String, + target: DirectDebridStreamTarget, + ): AddonStreamGroup { + val settings = DebridSettingsRepository.snapshot() + val baseUrl = DebridConfig.DIRECT_DEBRID_API_BASE_URL.trim().trimEnd('/') + if (!settings.enabled || baseUrl.isBlank()) { + return target.emptyGroup() + } + + val cacheKey = DirectDebridStreamCacheKey( + providerId = target.provider.id, + type = type.trim().lowercase(), + videoId = videoId.trim(), + baseUrl = baseUrl, + settingsFingerprint = settings.toString(), + ) + cachedGroup(cacheKey)?.let { return it } + + var ownsFetch = false + val newFetch = scope.async(start = CoroutineStart.LAZY) { + fetchProviderStreamsUncached( + baseUrl = baseUrl, + type = type, + videoId = videoId, + target = target, + settings = settings, + ) + } + val activeFetch = mutex.withLock { + cachedGroupLocked(cacheKey)?.let { cached -> + return@withLock null to cached + } + val existing = inFlightFetches[cacheKey] + if (existing != null) { + existing to null + } else { + inFlightFetches[cacheKey] = newFetch + ownsFetch = true + newFetch to null + } + } + activeFetch.second?.let { + newFetch.cancel() + return it + } + val deferred = activeFetch.first ?: return target.errorGroup("Could not start Direct Debrid fetch") + if (!ownsFetch) newFetch.cancel() + if (ownsFetch) deferred.start() + + return try { + val result = deferred.await() + if (ownsFetch && result.streams.isNotEmpty() && result.error == null) { + mutex.withLock { + streamCache[cacheKey] = CachedDirectDebridStreams( + group = result, + createdAtMs = epochMs(), + ) + } + } + result + } finally { + if (ownsFetch) { + mutex.withLock { + if (inFlightFetches[cacheKey] === deferred) { + inFlightFetches.remove(cacheKey) + } + } + } + } + } + + private suspend fun cachedGroup(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? = + mutex.withLock { cachedGroupLocked(cacheKey) } + + private fun cachedGroupLocked(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? { + val cached = streamCache[cacheKey] ?: return null + val age = epochMs() - cached.createdAtMs + return if (age in 0..STREAM_CACHE_TTL_MS) { + cached.group + } else { + streamCache.remove(cacheKey) + null + } + } + + private suspend fun fetchProviderStreamsUncached( + baseUrl: String, + type: String, + videoId: String, + target: DirectDebridStreamTarget, + settings: DebridSettings, + ): AddonStreamGroup { + val credential = DebridServiceCredential(target.provider, target.apiKey) + val url = "$baseUrl/${encoder.encode(credential)}/client-stream/${encodePathSegment(type)}/${encodePathSegment(videoId)}.json" + return try { + val payload = httpGetText(url) + val streams = StreamParser.parse( + payload = payload, + addonName = DirectDebridStreamFilter.FALLBACK_SOURCE_NAME, + addonId = target.addonId, + ) + .let { DirectDebridStreamFilter.filterInstant(it, settings) } + .filter { stream -> stream.clientResolve?.service.equals(target.provider.id, ignoreCase = true) } + .map { stream -> formatter.format(stream.copy(addonId = target.addonId), settings) } + + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = streams, + isLoading = false, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + log.w(error) { "Direct debrid ${target.provider.id} stream fetch failed" } + target.errorGroup(error.message) + } + } + + private fun DirectDebridStreamTarget.emptyGroup(): AddonStreamGroup = + AddonStreamGroup( + addonName = addonName, + addonId = addonId, + streams = emptyList(), + isLoading = false, + ) + + private fun DirectDebridStreamTarget.errorGroup(message: String?): AddonStreamGroup = + AddonStreamGroup( + addonName = addonName, + addonId = addonId, + streams = emptyList(), + isLoading = false, + error = message, + ) +} + +private data class DirectDebridStreamCacheKey( + val providerId: String, + val type: String, + val videoId: String, + val baseUrl: String, + val settingsFingerprint: String, +) + +private data class CachedDirectDebridStreams( + val group: AddonStreamGroup, + val createdAtMs: Long, +) + +sealed class DirectDebridStreamFetchResult { + data object Disabled : DirectDebridStreamFetchResult() + data object Empty : DirectDebridStreamFetchResult() + data class Success(val streams: List) : DirectDebridStreamFetchResult() + data class Error(val message: String) : DirectDebridStreamFetchResult() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 899747c7..378e3a8c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -62,6 +62,7 @@ import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.features.debrid.DirectDebridStreamSource import com.nuvio.app.features.details.components.DetailActionButtons import com.nuvio.app.features.details.components.CommentDetailSheet import com.nuvio.app.features.details.components.DetailAdditionalInfoSection @@ -378,6 +379,16 @@ fun MetaDetailsScreen( seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId } val hasEpisodes = meta.videos.any { it.season != null || it.episode != null } + val debridPreloadVideoId = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction?.videoId) { + if (meta.isSeriesLikeForDebridPreload(hasEpisodes)) { + seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id + } else { + meta.id + } + } + LaunchedEffect(meta.type, debridPreloadVideoId) { + DirectDebridStreamSource.preloadStreams(meta.type, debridPreloadVideoId) + } val hasProductionSection = remember(meta) { meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty() } @@ -1367,3 +1378,8 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp = } else { (maxWidth * 0.6f).coerceIn(520.dp, 680.dp) } + +private fun MetaDetails.isSeriesLikeForDebridPreload(hasEpisodes: Boolean): Boolean = + hasEpisodes || type.equals("series", ignoreCase = true) || + type.equals("show", ignoreCase = true) || + type.equals("tv", ignoreCase = true) 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 69eb462e..255205cd 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 @@ -597,7 +597,7 @@ private fun EpisodeStreamsSubView( ) { itemsIndexed( items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" }, + key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" }, ) { _, stream -> EpisodeSourceStreamRow( stream = stream, 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 fcfcadd1..a18b195d 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 @@ -38,6 +38,10 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioToastController +import com.nuvio.app.features.debrid.DirectDebridPlayableResult +import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver +import com.nuvio.app.features.debrid.toastMessage import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonResource import com.nuvio.app.features.addons.ManagedAddon @@ -857,7 +861,55 @@ fun PlayerScreen( playerController?.seekTo(targetPositionMs) } + fun resolveDebridForPlayer( + stream: StreamItem, + season: Int?, + episode: Int?, + onResolved: (StreamItem) -> Unit, + onStale: () -> Unit, + ): Boolean { + if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) return false + scope.launch { + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = stream, + season = season, + episode = episode, + ) + when (resolved) { + is DirectDebridPlayableResult.Success -> onResolved(resolved.stream) + else -> { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + if (resolved == DirectDebridPlayableResult.Stale) { + onStale() + } + } + } + } + return true + } + fun switchToSource(stream: StreamItem) { + if ( + resolveDebridForPlayer( + stream = stream, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + onResolved = ::switchToSource, + onStale = { + val type = contentType ?: parentMetaType + val vid = activeVideoId + if (vid != null) { + PlayerStreamsRepository.loadSources( + type = type, + videoId = vid, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + forceRefresh = true, + ) + } + }, + ) + ) return val url = stream.directPlaybackUrl ?: return if (url == activeSourceUrl) return val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) @@ -899,6 +951,26 @@ fun PlayerScreen( } fun switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) { + if ( + resolveDebridForPlayer( + stream = stream, + season = episode.season, + episode = episode.episode, + onResolved = { resolvedStream -> + switchToEpisodeStream(resolvedStream, episode) + }, + onStale = { + val type = contentType ?: parentMetaType + PlayerStreamsRepository.loadEpisodeStreams( + type = type, + videoId = episode.id, + season = episode.season, + episode = episode.episode, + forceRefresh = true, + ) + }, + ) + ) return val url = stream.directPlaybackUrl ?: return showNextEpisodeCard = false showSourcesPanel = false 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 9e64a911..d57dd46d 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 @@ -203,7 +203,7 @@ fun PlayerSourcesPanel( ) { itemsIndexed( items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" }, + key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" }, ) { _, stream -> val isCurrent = isCurrentStream( stream = stream, 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 24ea2129..013460c3 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 @@ -5,6 +5,8 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.debrid.DirectDebridStreamPreparer +import com.nuvio.app.features.debrid.DirectDebridStreamSource import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.pluginContentId @@ -154,6 +156,10 @@ object PlayerStreamsRepository { } val installedAddons = AddonRepository.uiState.value.addons + val installedAddonNames = installedAddons.map { it.displayTitle }.toSet() + PlayerSettingsRepository.ensureLoaded() + val playerSettings = PlayerSettingsRepository.uiState.value + val debridTargets = DirectDebridStreamSource.configuredTargets() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.initialize() PluginRepository.getEnabledScrapersForType(type) @@ -161,7 +167,7 @@ object PlayerStreamsRepository { emptyList() } - if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) { + if (installedAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) { stateFlow.value = StreamsUiState( isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled, @@ -187,7 +193,7 @@ object PlayerStreamsRepository { ) } - if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) { + if (streamAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) { stateFlow.value = StreamsUiState( isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons, @@ -210,6 +216,13 @@ object PlayerStreamsRepository { streams = emptyList(), isLoading = true, ) + } + debridTargets.map { target -> + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = emptyList(), + isLoading = true, + ) }, installedAddonOrder) stateFlow.value = StreamsUiState( groups = initialGroups, @@ -278,13 +291,24 @@ object PlayerStreamsRepository { } } - val jobs = addonJobs + pluginJobs + val debridJobs = debridTargets.map { target -> + async { + DirectDebridStreamSource.fetchProviderStreams( + type = type, + videoId = videoId, + target = target, + ) + } + } + + val jobs = addonJobs + pluginJobs + debridJobs val completions = Channel(capacity = Channel.BUFFERED) jobs.forEach { deferred -> launch { completions.send(deferred.await()) } } + var debridPreparationLaunched = false repeat(jobs.size) { val result = completions.receive() stateFlow.update { current -> @@ -305,6 +329,28 @@ object PlayerStreamsRepository { } else null, ) } + if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) { + debridPreparationLaunched = true + launch { + DirectDebridStreamPreparer.prepare( + streams = stateFlow.value.groups.flatMap { it.streams }, + season = season, + episode = episode, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) { original, prepared -> + stateFlow.update { current -> + current.copy( + groups = DirectDebridStreamPreparer.replacePreparedStream( + groups = current.groups, + original = original, + prepared = prepared, + ), + ) + } + } + } + } } completions.close() } 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 new file mode 100644 index 00000000..30d59534 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -0,0 +1,1295 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.debrid.DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT +import com.nuvio.app.features.debrid.DebridCredentialValidator +import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.debrid.DebridSettings +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults +import com.nuvio.app.features.debrid.DebridStreamAudioChannel +import com.nuvio.app.features.debrid.DebridStreamAudioTag +import com.nuvio.app.features.debrid.DebridStreamEncode +import com.nuvio.app.features.debrid.DebridStreamLanguage +import com.nuvio.app.features.debrid.DebridStreamPreferences +import com.nuvio.app.features.debrid.DebridStreamQuality +import com.nuvio.app.features.debrid.DebridStreamResolution +import com.nuvio.app.features.debrid.DebridStreamSortCriterion +import com.nuvio.app.features.debrid.DebridStreamSortDirection +import com.nuvio.app.features.debrid.DebridStreamSortKey +import com.nuvio.app.features.debrid.DebridStreamVisualTag +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.action_clear +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_dialog_placeholder +import nuvio.composeapp.generated.resources.settings_debrid_dialog_subtitle +import nuvio.composeapp.generated.resources.settings_debrid_dialog_title +import nuvio.composeapp.generated.resources.settings_debrid_enable +import nuvio.composeapp.generated.resources.settings_debrid_enable_description +import nuvio.composeapp.generated.resources.settings_debrid_experimental_notice +import nuvio.composeapp.generated.resources.settings_debrid_description_template +import nuvio.composeapp.generated.resources.settings_debrid_description_template_description +import nuvio.composeapp.generated.resources.settings_debrid_formatter_reset_subtitle +import nuvio.composeapp.generated.resources.settings_debrid_formatter_reset_title +import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_many +import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_one +import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback +import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback_description +import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count +import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count_warning +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_section_instant_playback +import nuvio.composeapp.generated.resources.settings_debrid_section_formatting +import nuvio.composeapp.generated.resources.settings_debrid_section_providers +import nuvio.composeapp.generated.resources.settings_debrid_section_title +import org.jetbrains.compose.resources.stringResource + +internal fun LazyListScope.debridSettingsContent( + isTablet: Boolean, + settings: DebridSettings, +) { + item { + SettingsSection( + title = stringResource(Res.string.settings_debrid_section_title), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + DebridInfoRow( + isTablet = isTablet, + text = stringResource(Res.string.settings_debrid_experimental_notice), + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_debrid_enable), + description = stringResource(Res.string.settings_debrid_enable_description), + checked = settings.enabled && settings.hasAnyApiKey, + enabled = settings.hasAnyApiKey, + isTablet = isTablet, + onCheckedChange = DebridSettingsRepository::setEnabled, + ) + if (!settings.hasAnyApiKey) { + SettingsGroupDivider(isTablet = isTablet) + DebridInfoRow( + isTablet = isTablet, + text = stringResource(Res.string.settings_debrid_add_key_first), + ) + } + } + } + } + + item { + var showApiKeyDialog by rememberSaveable { mutableStateOf(false) } + + 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 }, + ) + } + } + + 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 }, + ) + } + } + + item { + var showPrepareCountDialog by rememberSaveable { mutableStateOf(false) } + val prepareLimit = settings.instantPlaybackPreparationLimit + val prepareEnabled = settings.enabled && prepareLimit > 0 + + SettingsSection( + title = stringResource(Res.string.settings_debrid_section_instant_playback), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + 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, + isTablet = isTablet, + onCheckedChange = { enabled -> + DebridSettingsRepository.setInstantPlaybackPreparationLimit( + if (enabled) DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT else 0, + ) + }, + ) + if (prepareEnabled) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.settings_debrid_prepare_stream_count), + description = prepareCountLabel(prepareLimit), + isTablet = isTablet, + onClick = { showPrepareCountDialog = true }, + ) + } + } + } + + if (showPrepareCountDialog) { + DebridPrepareCountDialog( + selectedLimit = prepareLimit, + onLimitSelected = { limit -> + DebridSettingsRepository.setInstantPlaybackPreparationLimit(limit) + showPrepareCountDialog = false + }, + onDismiss = { showPrepareCountDialog = false }, + ) + } + } + + item { + var activeStreamPicker by rememberSaveable { mutableStateOf(null) } + val preferences = settings.streamPreferences + val rows = debridRuleRows(preferences) + + SettingsSection( + title = "Filters & Sorting", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + DebridPreferenceRow( + isTablet = isTablet, + title = "Max results", + description = "Limit how many Direct Debrid sources appear.", + value = streamMaxResultsLabel(preferences.maxResults), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Sort streams", + description = "Choose how Direct Debrid sources are ordered.", + value = sortProfileLabel(preferences.sortCriteria), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.SORT_MODE }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Per resolution limit", + description = "Cap repeated 2160p, 1080p, 720p results after sorting.", + value = streamMaxResultsLabel(preferences.maxPerResolution), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_RESOLUTION }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Per quality limit", + description = "Cap repeated BluRay, WEB-DL, REMUX results after sorting.", + value = streamMaxResultsLabel(preferences.maxPerQuality), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_QUALITY }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Size range", + description = "Filter streams by file size.", + value = sizeRangeLabel(preferences), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.SIZE_RANGE }, + ) + rows.forEach { row -> + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = row.title, + description = row.description, + value = row.value, + enabled = settings.enabled, + onClick = { activeStreamPicker = row.picker }, + ) + } + } + } + + activeStreamPicker?.let { picker -> + DebridStreamPreferenceDialog( + picker = picker, + preferences = preferences, + onPreferencesChanged = DebridSettingsRepository::setStreamPreferences, + onDismiss = { activeStreamPicker = null }, + ) + } + } + + item { + var activeTemplateField by rememberSaveable { mutableStateOf(null) } + + SettingsSection( + title = stringResource(Res.string.settings_debrid_section_formatting), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + DebridPreferenceRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_debrid_name_template), + description = stringResource(Res.string.settings_debrid_name_template_description), + value = templatePreview(settings.streamNameTemplate), + enabled = settings.enabled, + onClick = { activeTemplateField = DebridTemplateField.NAME }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_debrid_description_template), + description = stringResource(Res.string.settings_debrid_description_template_description), + value = templatePreview(settings.streamDescriptionTemplate), + enabled = settings.enabled, + onClick = { activeTemplateField = DebridTemplateField.DESCRIPTION }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + 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, + onClick = DebridSettingsRepository::resetStreamTemplates, + ) + } + } + + when (activeTemplateField) { + DebridTemplateField.NAME -> DebridTemplateDialog( + title = stringResource(Res.string.settings_debrid_name_template), + description = stringResource(Res.string.settings_debrid_name_template_description), + currentValue = settings.streamNameTemplate, + defaultValue = DebridStreamFormatterDefaults.NAME_TEMPLATE, + onSave = DebridSettingsRepository::setStreamNameTemplate, + onDismiss = { activeTemplateField = null }, + ) + DebridTemplateField.DESCRIPTION -> DebridTemplateDialog( + title = stringResource(Res.string.settings_debrid_description_template), + description = stringResource(Res.string.settings_debrid_description_template_description), + currentValue = settings.streamDescriptionTemplate, + defaultValue = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, + onSave = DebridSettingsRepository::setStreamDescriptionTemplate, + onDismiss = { activeTemplateField = null }, + ) + null -> Unit + } + } +} + +private enum class DebridTemplateField { + NAME, + DESCRIPTION, +} + +private fun templatePreview(value: String): String { + val firstLine = value + .lineSequence() + .map { it.trim() } + .firstOrNull { it.isNotBlank() } + ?: return "" + return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..." +} + +@Composable +private fun prepareCountLabel(limit: Int): String = + if (limit == 1) { + stringResource(Res.string.settings_debrid_prepare_count_one) + } else { + stringResource(Res.string.settings_debrid_prepare_count_many, limit) + } + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridPrepareCountDialog( + selectedLimit: Int, + onLimitSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val options = listOf(1, 2, 3, 5) + + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.settings_debrid_prepare_stream_count), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { limit -> + val isSelected = limit == selectedLimit + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onLimitSelected(limit) }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = prepareCountLabel(limit), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + + Text( + text = stringResource(Res.string.settings_debrid_prepare_stream_count_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridTemplateDialog( + title: String, + description: String, + currentValue: String, + defaultValue: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit, +) { + var draft by rememberSaveable(currentValue) { mutableStateOf(currentValue) } + + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 140.dp, max = 280.dp), + minLines = 5, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TextButton(onClick = { draft = defaultValue }) { + Text( + text = stringResource(Res.string.action_reset), + maxLines = 1, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(Res.string.action_cancel), + maxLines = 1, + ) + } + Button( + onClick = { + onSave(draft) + onDismiss() + }, + ) { + Text( + text = stringResource(Res.string.action_save), + maxLines = 1, + ) + } + } + } + } + } +} + +@Composable +private fun DebridPreferenceRow( + isTablet: Boolean, + title: String, + description: String, + value: String, + enabled: Boolean, + onClick: () -> Unit, +) { + val horizontalPadding = if (isTablet) 20.dp else 16.dp + val verticalPadding = if (isTablet) 16.dp else 14.dp + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun DebridStreamPreferenceDialog( + picker: DebridStreamPicker, + preferences: DebridStreamPreferences, + onPreferencesChanged: (DebridStreamPreferences) -> Unit, + onDismiss: () -> Unit, +) { + when (picker) { + DebridStreamPicker.MAX_RESULTS -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxResults, + options = listOf(0, 5, 10, 20, 50), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxResults = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.MAX_PER_RESOLUTION -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxPerResolution, + options = listOf(0, 1, 2, 3, 5), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxPerResolution = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.MAX_PER_QUALITY -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxPerQuality, + options = listOf(0, 1, 2, 3, 5), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxPerQuality = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.SORT_MODE -> DebridSingleChoiceDialog( + title = "Sort streams", + selectedValue = sortProfileFor(preferences.sortCriteria), + options = listOf( + DebridSortProfile.DEFAULT, + DebridSortProfile.LARGEST, + DebridSortProfile.SMALLEST, + DebridSortProfile.AUDIO, + DebridSortProfile.LANGUAGE, + ), + label = { sortProfileLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(sortCriteria = sortCriteriaForProfile(value))) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.SIZE_RANGE -> DebridSingleChoiceDialog( + title = "Size range", + selectedValue = preferences.sizeMinGb to preferences.sizeMaxGb, + options = listOf(0 to 0, 0 to 5, 0 to 10, 5 to 20, 10 to 50, 20 to 100), + label = { sizeRangeLabel(it.first, it.second) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(sizeMinGb = value.first, sizeMaxGb = value.second)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Preferred resolutions", + selectedValues = preferences.preferredResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredResolutions = value.ifEmpty { DebridStreamResolution.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Required resolutions", + selectedValues = preferences.requiredResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredResolutions = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Excluded resolutions", + selectedValues = preferences.excludedResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedResolutions = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_QUALITIES -> DebridMultiChoiceDialog( + title = "Preferred qualities", + selectedValues = preferences.preferredQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredQualities = value.ifEmpty { DebridStreamQuality.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_QUALITIES -> DebridMultiChoiceDialog( + title = "Required qualities", + selectedValues = preferences.requiredQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredQualities = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_QUALITIES -> DebridMultiChoiceDialog( + title = "Excluded qualities", + selectedValues = preferences.excludedQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedQualities = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Preferred visual tags", + selectedValues = preferences.preferredVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredVisualTags = value.ifEmpty { DebridStreamVisualTag.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Required visual tags", + selectedValues = preferences.requiredVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredVisualTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Excluded visual tags", + selectedValues = preferences.excludedVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedVisualTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Preferred audio tags", + selectedValues = preferences.preferredAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredAudioTags = value.ifEmpty { DebridStreamAudioTag.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Required audio tags", + selectedValues = preferences.requiredAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredAudioTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Excluded audio tags", + selectedValues = preferences.excludedAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedAudioTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Preferred channels", + selectedValues = preferences.preferredAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredAudioChannels = value.ifEmpty { DebridStreamAudioChannel.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Required channels", + selectedValues = preferences.requiredAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredAudioChannels = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Excluded channels", + selectedValues = preferences.excludedAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedAudioChannels = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_ENCODES -> DebridMultiChoiceDialog( + title = "Preferred encodes", + selectedValues = preferences.preferredEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredEncodes = value.ifEmpty { DebridStreamEncode.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_ENCODES -> DebridMultiChoiceDialog( + title = "Required encodes", + selectedValues = preferences.requiredEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredEncodes = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_ENCODES -> DebridMultiChoiceDialog( + title = "Excluded encodes", + selectedValues = preferences.excludedEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedEncodes = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Preferred languages", + selectedValues = preferences.preferredLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Required languages", + selectedValues = preferences.requiredLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Excluded languages", + selectedValues = preferences.excludedLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_RELEASE_GROUPS -> DebridTextListDialog( + title = "Required release groups", + selectedValues = preferences.requiredReleaseGroups, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredReleaseGroups = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_RELEASE_GROUPS -> DebridTextListDialog( + title = "Excluded release groups", + selectedValues = preferences.excludedReleaseGroups, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedReleaseGroups = value)) }, + onDismiss = onDismiss, + ) + } +} + +@Composable +private fun DebridIntChoiceDialog( + title: String, + selectedValue: Int, + options: List, + label: @Composable (Int) -> String, + onSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + DebridSingleChoiceDialog( + title = title, + selectedValue = selectedValue, + options = options, + label = label, + onSelected = onSelected, + onDismiss = onDismiss, + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridSingleChoiceDialog( + title: String, + selectedValue: T, + options: List, + label: @Composable (T) -> String, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(options) { option -> + DebridDialogOptionRow( + text = label(option), + selected = option == selectedValue, + onClick = { + onSelected(option) + onDismiss() + }, + ) + } + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridMultiChoiceDialog( + title: String, + selectedValues: List, + values: List, + label: @Composable (T) -> String, + onSelected: (List) -> Unit, + onDismiss: () -> Unit, +) { + var draft by remember(selectedValues) { mutableStateOf(selectedValues) } + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(values) { option -> + val selected = option in draft + DebridDialogOptionRow( + text = label(option), + selected = selected, + showCheckbox = true, + onClick = { + draft = if (selected) { + draft - option + } else { + draft + option + } + }, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = { draft = emptyList() }) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = { + onSelected(draft) + onDismiss() + }, + ) { + Text(stringResource(Res.string.action_save)) + } + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridTextListDialog( + title: String, + selectedValues: List, + onSelected: (List) -> Unit, + onDismiss: () -> Unit, +) { + var value by remember(selectedValues) { mutableStateOf(selectedValues.joinToString("\n")) } + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + Text( + text = "Enter one group per line.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = value, + onValueChange = { value = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp), + minLines = 4, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = { value = "" }) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = { + onSelected(value.split('\n', ',').map { it.trim() }.filter { it.isNotBlank() }.distinct()) + onDismiss() + }, + ) { + Text(stringResource(Res.string.action_save)) + } + } + } + } +} + +@Composable +private fun DebridDialogSurface( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + content() + Spacer(modifier = Modifier.height(2.dp)) + } + } +} + +@Composable +private fun DebridDialogOptionRow( + text: String, + selected: Boolean, + showCheckbox: Boolean = false, + onClick: () -> Unit, +) { + val containerColor = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (showCheckbox) { + Checkbox( + checked = selected, + onCheckedChange = { onClick() }, + ) + } else { + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } +} + +@Composable +private fun streamMaxResultsLabel(value: Int): String = + if (value <= 0) "All streams" else "$value streams" + +private fun sortProfileLabel(value: DebridSortProfile): String = + when (value) { + DebridSortProfile.DEFAULT -> "Default" + DebridSortProfile.LARGEST -> "Largest first" + DebridSortProfile.SMALLEST -> "Smallest first" + DebridSortProfile.AUDIO -> "Best audio first" + DebridSortProfile.LANGUAGE -> "Language first" + } + +private fun debridRuleRows(preferences: DebridStreamPreferences): List = + listOf( + DebridRuleRow(DebridStreamPicker.PREFERRED_RESOLUTIONS, "Preferred resolutions", "Sort selected resolutions first, in default order.", selectionCountLabel(preferences.preferredResolutions)), + DebridRuleRow(DebridStreamPicker.REQUIRED_RESOLUTIONS, "Required resolutions", "Only show selected resolutions.", selectionCountLabel(preferences.requiredResolutions)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_RESOLUTIONS, "Excluded resolutions", "Hide selected resolutions.", selectionCountLabel(preferences.excludedResolutions)), + DebridRuleRow(DebridStreamPicker.PREFERRED_QUALITIES, "Preferred qualities", "Sort selected qualities first, in default order.", selectionCountLabel(preferences.preferredQualities)), + DebridRuleRow(DebridStreamPicker.REQUIRED_QUALITIES, "Required qualities", "Only show selected source qualities.", selectionCountLabel(preferences.requiredQualities)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_QUALITIES, "Excluded qualities", "Hide selected source qualities.", selectionCountLabel(preferences.excludedQualities)), + DebridRuleRow(DebridStreamPicker.PREFERRED_VISUAL_TAGS, "Preferred visual tags", "Sort DV, HDR, 10bit, IMAX and similar tags.", selectionCountLabel(preferences.preferredVisualTags)), + DebridRuleRow(DebridStreamPicker.REQUIRED_VISUAL_TAGS, "Required visual tags", "Require DV, HDR, 10bit, IMAX, SDR and similar tags.", selectionCountLabel(preferences.requiredVisualTags)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_VISUAL_TAGS, "Excluded visual tags", "Hide DV, HDR, 10bit, 3D and similar tags.", selectionCountLabel(preferences.excludedVisualTags)), + DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_TAGS, "Preferred audio tags", "Sort Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.preferredAudioTags)), + DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_TAGS, "Required audio tags", "Require Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.requiredAudioTags)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_TAGS, "Excluded audio tags", "Hide selected audio tags.", selectionCountLabel(preferences.excludedAudioTags)), + DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_CHANNELS, "Preferred channels", "Sort preferred channel layouts first.", selectionCountLabel(preferences.preferredAudioChannels)), + DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_CHANNELS, "Required channels", "Only show selected channel layouts.", selectionCountLabel(preferences.requiredAudioChannels)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS, "Excluded channels", "Hide selected channel layouts.", selectionCountLabel(preferences.excludedAudioChannels)), + DebridRuleRow(DebridStreamPicker.PREFERRED_ENCODES, "Preferred encodes", "Sort AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.preferredEncodes)), + DebridRuleRow(DebridStreamPicker.REQUIRED_ENCODES, "Required encodes", "Require AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.requiredEncodes)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_ENCODES, "Excluded encodes", "Hide selected encodes.", selectionCountLabel(preferences.excludedEncodes)), + DebridRuleRow(DebridStreamPicker.PREFERRED_LANGUAGES, "Preferred languages", "Sort preferred audio languages first.", selectionCountLabel(preferences.preferredLanguages)), + DebridRuleRow(DebridStreamPicker.REQUIRED_LANGUAGES, "Required languages", "Only show streams with selected languages.", selectionCountLabel(preferences.requiredLanguages)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_LANGUAGES, "Excluded languages", "Hide streams where every language is excluded.", selectionCountLabel(preferences.excludedLanguages)), + DebridRuleRow(DebridStreamPicker.REQUIRED_RELEASE_GROUPS, "Required release groups", "Only show selected release groups.", selectionCountLabel(preferences.requiredReleaseGroups)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_RELEASE_GROUPS, "Excluded release groups", "Hide selected release groups.", selectionCountLabel(preferences.excludedReleaseGroups)), + ) + +private fun selectionCountLabel(values: List<*>): String = + if (values.isEmpty()) "Any" else "${values.size} selected" + +private fun sizeRangeLabel(preferences: DebridStreamPreferences): String = + sizeRangeLabel(preferences.sizeMinGb, preferences.sizeMaxGb) + +private fun sizeRangeLabel(minGb: Int, maxGb: Int): String = + when { + minGb <= 0 && maxGb <= 0 -> "Any" + minGb <= 0 -> "Up to ${maxGb}GB" + maxGb <= 0 -> "${minGb}GB+" + else -> "${minGb}-${maxGb}GB" + } + +private fun sortProfileFor(criteria: List): DebridSortProfile { + val normalized = criteria.map { it.key to it.direction } + return when { + normalized == listOf(DebridStreamSortKey.SIZE to DebridStreamSortDirection.DESC) -> DebridSortProfile.LARGEST + normalized == listOf(DebridStreamSortKey.SIZE to DebridStreamSortDirection.ASC) -> DebridSortProfile.SMALLEST + normalized.take(2) == listOf( + DebridStreamSortKey.AUDIO_TAG to DebridStreamSortDirection.DESC, + DebridStreamSortKey.AUDIO_CHANNEL to DebridStreamSortDirection.DESC, + ) -> DebridSortProfile.AUDIO + normalized.firstOrNull() == DebridStreamSortKey.LANGUAGE to DebridStreamSortDirection.DESC -> DebridSortProfile.LANGUAGE + else -> DebridSortProfile.DEFAULT + } +} + +private fun sortProfileLabel(criteria: List): String = + sortProfileLabel(sortProfileFor(criteria)) + +private fun sortCriteriaForProfile(profile: DebridSortProfile): List = + when (profile) { + DebridSortProfile.DEFAULT -> DebridStreamSortCriterion.defaultOrder + DebridSortProfile.LARGEST -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridSortProfile.SMALLEST -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + DebridSortProfile.AUDIO -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridSortProfile.LANGUAGE -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.LANGUAGE, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + } + +private data class DebridRuleRow( + val picker: DebridStreamPicker, + val title: String, + val description: String, + val value: String, +) + +private enum class DebridSortProfile { + DEFAULT, + LARGEST, + SMALLEST, + AUDIO, + LANGUAGE, +} + +private enum class DebridStreamPicker { + MAX_RESULTS, + MAX_PER_RESOLUTION, + MAX_PER_QUALITY, + SORT_MODE, + SIZE_RANGE, + PREFERRED_RESOLUTIONS, + REQUIRED_RESOLUTIONS, + EXCLUDED_RESOLUTIONS, + PREFERRED_QUALITIES, + REQUIRED_QUALITIES, + EXCLUDED_QUALITIES, + PREFERRED_VISUAL_TAGS, + REQUIRED_VISUAL_TAGS, + EXCLUDED_VISUAL_TAGS, + PREFERRED_AUDIO_TAGS, + REQUIRED_AUDIO_TAGS, + EXCLUDED_AUDIO_TAGS, + PREFERRED_AUDIO_CHANNELS, + REQUIRED_AUDIO_CHANNELS, + EXCLUDED_AUDIO_CHANNELS, + PREFERRED_ENCODES, + REQUIRED_ENCODES, + EXCLUDED_ENCODES, + PREFERRED_LANGUAGES, + REQUIRED_LANGUAGES, + EXCLUDED_LANGUAGES, + REQUIRED_RELEASE_GROUPS, + EXCLUDED_RELEASE_GROUPS, +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridApiKeyDialog( + providerId: String, + title: String, + subtitle: String, + placeholder: String, + currentValue: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + var draft by rememberSaveable(currentValue) { mutableStateOf(currentValue) } + var isValidating by rememberSaveable(providerId) { mutableStateOf(false) } + var validationMessage by rememberSaveable(providerId, currentValue) { mutableStateOf(null) } + val normalizedDraft = draft.trim() + val invalidMessage = stringResource(Res.string.settings_debrid_key_invalid) + val saveAndDismiss: () -> Unit = { + scope.launch { + isValidating = true + validationMessage = null + val valid = normalizedDraft.isNotBlank() && runCatching { + DebridCredentialValidator.validateProvider(providerId, normalizedDraft) + }.getOrDefault(false) + if (valid) { + onSave(normalizedDraft) + isValidating = false + onDismiss() + } else { + validationMessage = invalidMessage + isValidating = false + } + } + } + + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = draft, + onValueChange = { + draft = it + validationMessage = null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text(placeholder) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + validationMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_cancel)) + } + TextButton( + onClick = { + onSave("") + onDismiss() + }, + enabled = !isValidating, + ) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = saveAndDismiss, + enabled = normalizedDraft.isNotBlank() && !isValidating, + ) { + Text( + if (isValidating) { + stringResource(Res.string.action_saving) + } else { + stringResource(Res.string.action_save) + }, + ) + } + } + } + } +} + +private fun maskDebridApiKey(key: String, notSetLabel: String): String { + val trimmed = key.trim() + if (trimmed.isBlank()) return notSetLabel + return if (trimmed.length <= 4) "****" else "******${trimmed.takeLast(4)}" +} + +@Composable +private fun DebridInfoRow( + isTablet: Boolean, + text: String, +) { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = if (isTablet) 14.dp else 12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt index 7602c3e2..a4999c89 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt @@ -1,10 +1,14 @@ package com.nuvio.app.features.settings +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudQueue import androidx.compose.foundation.lazy.LazyListScope +import nuvio.composeapp.generated.resources.compose_settings_page_debrid import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment import nuvio.composeapp.generated.resources.settings_integrations_mdblist_description +import nuvio.composeapp.generated.resources.settings_integrations_debrid_description import nuvio.composeapp.generated.resources.settings_integrations_section_title import nuvio.composeapp.generated.resources.settings_integrations_tmdb_description import org.jetbrains.compose.resources.stringResource @@ -13,6 +17,7 @@ internal fun LazyListScope.integrationsContent( isTablet: Boolean, onTmdbClick: () -> Unit, onMdbListClick: () -> Unit, + onDebridClick: () -> Unit, ) { item { SettingsSection( @@ -35,6 +40,14 @@ internal fun LazyListScope.integrationsContent( isTablet = isTablet, onClick = onMdbListClick, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_debrid), + description = stringResource(Res.string.settings_integrations_debrid_description), + icon = Icons.Rounded.CloudQueue, + isTablet = isTablet, + onClick = onDebridClick, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index d030a785..a6eb2a40 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -13,6 +13,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_account import nuvio.composeapp.generated.resources.compose_settings_page_addons import nuvio.composeapp.generated.resources.compose_settings_page_appearance import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery +import nuvio.composeapp.generated.resources.compose_settings_page_debrid import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching import nuvio.composeapp.generated.resources.compose_settings_page_homescreen import nuvio.composeapp.generated.resources.compose_settings_page_integrations @@ -129,6 +130,11 @@ internal enum class SettingsPage( category = SettingsCategory.General, parentPage = Integrations, ), + Debrid( + titleRes = Res.string.compose_settings_page_debrid, + category = SettingsCategory.General, + parentPage = Integrations, + ), TraktAuthentication( titleRes = Res.string.compose_settings_page_trakt, category = SettingsCategory.Account, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 08864811..21442208 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -59,6 +59,8 @@ import com.nuvio.app.features.details.MetaScreenSettingsUiState import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.core.ui.PosterCardStyleUiState import com.nuvio.app.features.collection.CollectionRepository +import com.nuvio.app.features.debrid.DebridSettings +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsItem import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.mdblist.MdbListSettings @@ -132,6 +134,10 @@ fun SettingsScreen( MdbListSettingsRepository.ensureLoaded() MdbListSettingsRepository.uiState }.collectAsStateWithLifecycle() + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() val traktAuthUiState by remember { TraktAuthRepository.ensureLoaded() TraktAuthRepository.uiState @@ -251,6 +257,7 @@ fun SettingsScreen( episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, + debridSettings = debridSettings, traktAuthUiState = traktAuthUiState, traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, @@ -299,6 +306,7 @@ fun SettingsScreen( episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, + debridSettings = debridSettings, traktAuthUiState = traktAuthUiState, traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, @@ -357,6 +365,7 @@ private fun MobileSettingsScreen( episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, + debridSettings: DebridSettings, traktAuthUiState: TraktAuthUiState, traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, @@ -571,6 +580,7 @@ private fun MobileSettingsScreen( isTablet = false, onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, + onDebridClick = { onPageChange(SettingsPage.Debrid) }, ) SettingsPage.TmdbEnrichment -> tmdbSettingsContent( isTablet = false, @@ -580,6 +590,10 @@ private fun MobileSettingsScreen( isTablet = false, settings = mdbListSettings, ) + SettingsPage.Debrid -> debridSettingsContent( + isTablet = false, + settings = debridSettings, + ) SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = false, uiState = traktAuthUiState, @@ -665,6 +679,7 @@ private fun TabletSettingsScreen( episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, + debridSettings: DebridSettings, traktAuthUiState: TraktAuthUiState, traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, @@ -937,6 +952,7 @@ private fun TabletSettingsScreen( isTablet = true, onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, + onDebridClick = { onPageChange(SettingsPage.Debrid) }, ) SettingsPage.TmdbEnrichment -> tmdbSettingsContent( isTablet = true, @@ -946,6 +962,10 @@ private fun TabletSettingsScreen( isTablet = true, settings = mdbListSettings, ) + SettingsPage.Debrid -> debridSettingsContent( + isTablet = true, + settings = debridSettings, + ) SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = true, uiState = traktAuthUiState, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index 46d38159..a5e97de5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -15,15 +15,19 @@ object StreamAutoPlaySelector { } } - if (installedOrder.isEmpty()) return groups + val (directDebridEntries, remainingEntries) = groups.partition { group -> + group.addonId.startsWith("debrid:") || + group.streams.any { stream -> stream.isDirectDebridStream } + } + if (installedOrder.isEmpty()) return directDebridEntries + remainingEntries - val (addonEntries, pluginEntries) = groups.partition { group -> + val (addonEntries, pluginEntries) = remainingEntries.partition { group -> group.addonName in addonRankByName } val orderedAddons = addonEntries.sortedBy { group -> addonRankByName.getValue(group.addonName) } - return orderedAddons + pluginEntries + return directDebridEntries + orderedAddons + pluginEntries } fun selectAutoPlayStream( @@ -119,5 +123,5 @@ object StreamAutoPlaySelector { } private fun StreamItem.isAutoPlayable(): Boolean = - directPlaybackUrl != null + directPlaybackUrl != null || isDirectDebridStream } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index fe223534..0b3d8b24 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -17,6 +17,7 @@ data class StreamItem( val addonName: String, val addonId: String, val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), + val clientResolve: StreamClientResolve? = null, ) { val streamLabel: String get() = name ?: runBlocking { getString(Res.string.stream_default_name) } @@ -27,13 +28,18 @@ data class StreamItem( val directPlaybackUrl: String? get() = url ?: externalUrl + val isDirectDebridStream: Boolean + get() = clientResolve?.isDirectDebridCandidate == true + val isTorrentStream: Boolean - get() = !infoHash.isNullOrBlank() || + get() = !isDirectDebridStream && ( + !infoHash.isNullOrBlank() || url.isMagnetLink() || externalUrl.isMagnetLink() + ) val hasPlayableSource: Boolean - get() = url != null || infoHash != null || externalUrl != null + get() = url != null || infoHash != null || externalUrl != null || clientResolve != null } private fun String?.isMagnetLink(): Boolean = @@ -53,6 +59,71 @@ data class StreamProxyHeaders( val response: Map? = null, ) +data class StreamClientResolve( + val type: String? = null, + val infoHash: String? = null, + val fileIdx: Int? = null, + val magnetUri: String? = null, + val sources: List = emptyList(), + val torrentName: String? = null, + val filename: String? = null, + val mediaType: String? = null, + val mediaId: String? = null, + val mediaOnlyId: String? = null, + val title: String? = null, + val season: Int? = null, + val episode: Int? = null, + val service: String? = null, + val serviceIndex: Int? = null, + val serviceExtension: String? = null, + val isCached: Boolean? = null, + val stream: StreamClientResolveStream? = null, +) { + val isDirectDebridCandidate: Boolean + get() = type.equals("debrid", ignoreCase = true) && + !service.isNullOrBlank() && + isCached == true +} + +data class StreamClientResolveStream( + val raw: StreamClientResolveRaw? = null, +) + +data class StreamClientResolveRaw( + val torrentName: String? = null, + val filename: String? = null, + val size: Long? = null, + val folderSize: Long? = null, + val tracker: String? = null, + val indexer: String? = null, + val network: String? = null, + val parsed: StreamClientResolveParsed? = null, +) + +data class StreamClientResolveParsed( + val rawTitle: String? = null, + val parsedTitle: String? = null, + val year: Int? = null, + val resolution: String? = null, + val seasons: List = emptyList(), + val episodes: List = emptyList(), + val quality: String? = null, + val hdr: List = emptyList(), + val codec: String? = null, + val audio: List = emptyList(), + val channels: List = emptyList(), + val languages: List = emptyList(), + val group: String? = null, + val network: String? = null, + val edition: String? = null, + val duration: Long? = null, + val bitDepth: String? = null, + val extended: Boolean? = null, + val theatrical: Boolean? = null, + val remastered: Boolean? = null, + val unrated: Boolean? = null, +) + data class AddonStreamGroup( val addonName: String, val addonId: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt index 72a6fc5c..9a6aa866 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt @@ -26,8 +26,10 @@ object StreamParser { val url = obj.string("url") val infoHash = obj.string("infoHash") val externalUrl = obj.string("externalUrl") + val clientResolve = obj.objectValue("clientResolve")?.toClientResolve() - if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null + // Must have at least one playable source + if (url == null && infoHash == null && externalUrl == null && clientResolve == null) return@mapNotNull null val hintsObj = obj["behaviorHints"] as? JsonObject val proxyHeaders = hintsObj @@ -44,6 +46,7 @@ object StreamParser { sources = obj.stringList("sources"), addonName = addonName, addonId = addonId, + clientResolve = clientResolve, behaviorHints = StreamBehaviorHints( bingeGroup = hintsObj?.string("bingeGroup"), notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null, @@ -80,6 +83,11 @@ object StreamParser { ?.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) } .orEmpty() + private fun JsonObject.intList(name: String): List = + (this[name] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.intOrNull } + .orEmpty() + private fun JsonObject.stringMap(): Map = entries.mapNotNull { (key, value) -> (value as? JsonPrimitive)?.contentOrNull @@ -99,4 +107,67 @@ object StreamParser { ) } + private fun JsonObject.toClientResolve(): StreamClientResolve = + StreamClientResolve( + type = string("type"), + infoHash = string("infoHash"), + fileIdx = int("fileIdx"), + magnetUri = string("magnetUri"), + sources = stringList("sources"), + torrentName = string("torrentName"), + filename = string("filename"), + mediaType = string("mediaType"), + mediaId = string("mediaId"), + mediaOnlyId = string("mediaOnlyId"), + title = string("title"), + season = int("season"), + episode = int("episode"), + service = string("service"), + serviceIndex = int("serviceIndex"), + serviceExtension = string("serviceExtension"), + isCached = boolean("isCached"), + stream = objectValue("stream")?.toClientResolveStream(), + ) + + private fun JsonObject.toClientResolveStream(): StreamClientResolveStream = + StreamClientResolveStream( + raw = objectValue("raw")?.toClientResolveRaw(), + ) + + private fun JsonObject.toClientResolveRaw(): StreamClientResolveRaw = + StreamClientResolveRaw( + torrentName = string("torrentName"), + filename = string("filename"), + size = long("size"), + folderSize = long("folderSize"), + tracker = string("tracker"), + indexer = string("indexer"), + network = string("network"), + parsed = objectValue("parsed")?.toClientResolveParsed(), + ) + + private fun JsonObject.toClientResolveParsed(): StreamClientResolveParsed = + StreamClientResolveParsed( + rawTitle = string("raw_title"), + parsedTitle = string("parsed_title"), + year = int("year"), + resolution = string("resolution"), + seasons = intList("seasons"), + episodes = intList("episodes"), + quality = string("quality"), + hdr = stringList("hdr"), + codec = string("codec"), + audio = stringList("audio"), + channels = stringList("channels"), + languages = stringList("languages"), + group = string("group"), + network = string("network"), + edition = string("edition"), + duration = long("duration"), + bitDepth = string("bit_depth"), + extended = boolean("extended"), + theatrical = boolean("theatrical"), + remastered = boolean("remastered"), + unrated = boolean("unrated"), + ) } 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 56a76194..5441ce47 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 @@ -5,6 +5,8 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.debrid.DirectDebridStreamPreparer +import com.nuvio.app.features.debrid.DirectDebridStreamSource import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.plugins.PluginRepository @@ -148,6 +150,7 @@ object StreamsRepository { } val installedAddons = AddonRepository.uiState.value.addons + val debridTargets = DirectDebridStreamSource.configuredTargets() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.getEnabledScrapersForType(type) } else { @@ -158,7 +161,7 @@ object StreamsRepository { groupByRepository = pluginUiState.groupStreamsByRepository, ) - if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) { + if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) { _uiState.value = StreamsUiState( requestToken = requestToken, isAnyLoading = false, @@ -187,7 +190,7 @@ object StreamsRepository { log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" } - if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) { + if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) { _uiState.value = StreamsUiState( requestToken = requestToken, isAnyLoading = false, @@ -212,6 +215,13 @@ object StreamsRepository { streams = emptyList(), isLoading = true, ) + } + debridTargets.map { target -> + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = emptyList(), + isLoading = true, + ) }, installedAddonOrder) _uiState.value = StreamsUiState( requestToken = requestToken, @@ -230,11 +240,13 @@ object StreamsRepository { .toMutableMap() val pluginFirstErrorByAddonId = mutableMapOf() val totalTasks = streamAddons.size + - pluginProviderGroups.sumOf { it.scrapers.size } + pluginProviderGroups.sumOf { it.scrapers.size } + + debridTargets.size val installedAddonNames = installedAddonOrder.toSet() var autoSelectTriggered = false var timeoutElapsed = false + var debridPreparationLaunched = false fun publishCompletion(completion: StreamLoadCompletion) { if (completions.trySend(completion).isFailure) { log.d { "Ignoring late stream load completion after channel close" } @@ -410,6 +422,20 @@ object StreamsRepository { } } + debridTargets.forEach { target -> + launch { + publishCompletion( + StreamLoadCompletion.Debrid( + DirectDebridStreamSource.fetchProviderStreams( + type = type, + videoId = videoId, + target = target, + ), + ), + ) + } + } + repeat(totalTasks) { when (val completion = completions.receive()) { is StreamLoadCompletion.Addon -> { @@ -472,6 +498,45 @@ object StreamsRepository { } } + is StreamLoadCompletion.Debrid -> { + val result = completion.group + _uiState.update { current -> + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { group -> + if (group.addonId == result.addonId) result else group + }, + installedOrder = installedAddonOrder, + ) + val anyLoading = updated.any { it.isLoading } + current.copy( + groups = updated, + isAnyLoading = anyLoading, + emptyStateReason = updated.toEmptyStateReason(anyLoading), + ) + } + if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) { + debridPreparationLaunched = true + launch { + DirectDebridStreamPreparer.prepare( + streams = _uiState.value.groups.flatMap { it.streams }, + season = season, + episode = episode, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) { original, prepared -> + _uiState.update { current -> + current.copy( + groups = DirectDebridStreamPreparer.replacePreparedStream( + groups = current.groups, + original = original, + prepared = prepared, + ), + ) + } + } + } + } + } } // Early match / timeout-elapsed auto-select on each addon response @@ -612,6 +677,7 @@ private data class PluginProviderGroup( private sealed interface StreamLoadCompletion { data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion + data class Debrid(val group: AddonStreamGroup) : StreamLoadCompletion data class PluginScraper( val addonId: String, val streams: List, 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 41302ad0..ee5b52e0 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 @@ -866,7 +866,7 @@ private fun LazyListScope.streamSection( StreamCard( stream = stream, onClick = { - if (stream.directPlaybackUrl != null || stream.isTorrentStream) { + if (stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream) { onStreamSelected(stream, resumePositionMs, resumeProgressFraction) } }, @@ -898,7 +898,7 @@ internal fun streamCardRenderKey( append(':') append(itemIndex) append(':') - append(stream.url ?: stream.infoHash ?: stream.streamLabel) + append(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel) } // --------------------------------------------------------------------------- @@ -972,7 +972,7 @@ private fun StreamCard( onLongClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { - val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream + val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream val cardShape = RoundedCornerShape(12.dp) Row( modifier = modifier diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt new file mode 100644 index 00000000..ad4f9eab --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt @@ -0,0 +1,148 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamClientResolve +import kotlin.test.Test +import kotlin.test.assertEquals + +class DebridFileSelectorTest { + @Test + fun `Torbox selector prefers exact file id`() { + val files = listOf( + TorboxTorrentFileDto(id = 1, name = "small.mkv", size = 1), + TorboxTorrentFileDto(id = 8, name = "target.mkv", size = 2), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve(fileIdx = 8), + season = null, + episode = null, + ) + + assertEquals(8, selected?.id) + } + + @Test + fun `Torbox selector prefers filename match before provider file id`() { + val files = listOf( + TorboxTorrentFileDto(id = 0, name = "Request High Bitrate Stuff in Here.txt", size = 1), + TorboxTorrentFileDto( + id = 85, + name = "The Office US S01-S09/The.Office.US.S01E01.Pilot.1080p.BluRay.Remux.mkv", + size = 5_303_936_915, + ), + TorboxTorrentFileDto( + id = 1, + name = "The Office US S01-S09/The.Office.US.S08E13.Jury.Duty.1080p.BluRay.Remux.mkv", + size = 5_859_312_140, + ), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve( + fileIdx = 1, + season = 1, + episode = 1, + filename = "The.Office.US.S01E01.Pilot.1080p.BluRay.Remux.mkv", + ), + season = 1, + episode = 1, + ) + + assertEquals(85, selected?.id) + } + + @Test + fun `Torbox selector treats fileIdx as source list index before provider file id`() { + val files = listOf( + TorboxTorrentFileDto(id = 0, name = "Request High Bitrate Stuff in Here.txt", size = 1), + TorboxTorrentFileDto(id = 85, name = "Show.S01E01.mkv", size = 500), + TorboxTorrentFileDto(id = 1, name = "Show.S08E13.mkv", size = 900), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve(fileIdx = 1), + season = null, + episode = null, + ) + + assertEquals(85, selected?.id) + } + + @Test + fun `Torbox selector uses episode pattern before broad title`() { + val files = listOf( + TorboxTorrentFileDto(id = 1, name = "The.Office.US.S08E13.Jury.Duty.mkv", size = 900), + TorboxTorrentFileDto(id = 85, name = "The.Office.US.S01E01.Pilot.mkv", size = 500), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve( + season = 1, + episode = 1, + title = "The Office", + ), + season = 1, + episode = 1, + ) + + assertEquals(85, selected?.id) + } + + @Test + fun `Torbox selector falls back to largest playable video`() { + val files = listOf( + TorboxTorrentFileDto(id = 1, name = "sample.txt", size = 999), + TorboxTorrentFileDto(id = 2, name = "episode.mkv", size = 200), + TorboxTorrentFileDto(id = 3, name = "episode-1080p.mp4", size = 500), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve(), + season = null, + episode = null, + ) + + assertEquals(3, selected?.id) + } + + @Test + fun `Real-Debrid selector matches episode pattern before largest file`() { + val files = listOf( + RealDebridTorrentFileDto(id = 1, path = "/Show.S01E01.mkv", bytes = 1_000), + RealDebridTorrentFileDto(id = 2, path = "/Show.S01E02.mkv", bytes = 2_000), + ) + + val selected = RealDebridFileSelector().selectFile( + files = files, + resolve = resolve(season = 1, episode = 1), + season = null, + episode = null, + ) + + assertEquals(1, selected?.id) + } + + private fun resolve( + fileIdx: Int? = null, + season: Int? = null, + episode: Int? = null, + filename: String? = null, + title: String? = null, + ): StreamClientResolve = + StreamClientResolve( + type = "debrid", + service = DebridProviders.TORBOX_ID, + isCached = true, + infoHash = "hash", + fileIdx = fileIdx, + filename = filename, + title = title, + season = season, + episode = episode, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt new file mode 100644 index 00000000..83b127cc --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt @@ -0,0 +1,122 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamParser +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class DebridStreamFormatterTest { + private val formatter = DebridStreamFormatter() + + @Test + fun `formats real client stream episode fields and behavior size`() { + val stream = StreamParser.parse( + payload = clientStreamPayload(), + addonName = "Torbox Instant", + addonId = "debrid:torbox", + ).single() + + val formatted = formatter.format( + stream = stream, + settings = DebridSettings( + enabled = true, + torboxApiKey = "key", + streamDescriptionTemplate = CLIENT_TEMPLATE, + ), + ) + + val description = formatted.description.orEmpty() + assertEquals(0, stream.clientResolve?.fileIdx) + assertContains(description, "S05") + assertContains(description, "E02") + assertContains(description, "6.3 GB") + assertFalse(description.contains("6761331156")) + } + + @Test + fun `formats season episode from parsed fields when top level resolve omits them`() { + val stream = StreamParser.parse( + payload = clientStreamPayload(includeTopLevelSeasonEpisode = false), + addonName = "Torbox Instant", + addonId = "debrid:torbox", + ).single() + + val formatted = formatter.format( + stream = stream, + settings = DebridSettings( + enabled = true, + torboxApiKey = "key", + streamDescriptionTemplate = CLIENT_TEMPLATE, + ), + ) + + val description = formatted.description.orEmpty() + assertContains(description, "S05") + assertContains(description, "E02") + assertContains(description, "6.3 GB") + } + + private fun clientStreamPayload(includeTopLevelSeasonEpisode: Boolean = true): String { + val seasonEpisode = if (includeTopLevelSeasonEpisode) { + """ + "season": 5, + "episode": 2, + """.trimIndent() + } else { + "" + } + return """ + { + "streams": [ + { + "name": "TB 2160p cached", + "description": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", + "clientResolve": { + "type": "debrid", + "service": "torbox", + "isCached": true, + "infoHash": "cb7286fb422ed0643037523e7b09446734e9dbc4", + "sources": [], + "fileIdx": "0", + "filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", + "title": "The Boys", + "torrentName": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", + $seasonEpisode + "stream": { + "raw": { + "parsed": { + "resolution": "2160p", + "quality": "WEB-DL", + "codec": "hevc", + "audio": ["Atmos", "Dolby Digital Plus"], + "channels": ["5.1"], + "hdr": ["DV", "HDR10+"], + "group": "Kitsune", + "seasons": [5], + "episodes": [2], + "raw_title": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv" + } + } + } + }, + "behaviorHints": { + "filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", + "videoSize": 6761331156 + } + } + ] + } + """.trimIndent() + } + + private companion object { + private const val CLIENT_TEMPLATE = + "{stream.title::exists[\"🍿 {stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year}) \"||\"\"]}\n" + + "{stream.season::>=0[\"πŸ‚ S\"||\"\"]}{stream.season::<=9[\"0\"||\"\"]}{stream.season::>0[\"{stream.season} \"||\"\"]}{stream.episode::>=0[\"🎞️ E\"||\"\"]}{stream.episode::<=9[\"0\"||\"\"]}{stream.episode::>0[\"{stream.episode} \"||\"\"]}\n" + + "{stream.quality::exists[\"πŸŽ₯ {stream.quality} \"||\"\"]}{stream.visualTags::exists[\"πŸ“Ί {stream.visualTags::join(' | ')} \"||\"\"]}\n" + + "{stream.audioTags::exists[\"🎧 {stream.audioTags::join(' | ')} \"||\"\"]}{stream.audioChannels::exists[\"πŸ”Š {stream.audioChannels::join(' | ')}\"||\"\"]}\n" + + "{stream.size::>0[\"πŸ“¦ {stream.size::bytes} \"||\"\"]}{stream.encode::exists[\"🎞️ {stream.encode} \"||\"\"]}{stream.indexer::exists[\"πŸ“‘{stream.indexer}\"||\"\"]}\n" + + "{service.cached::istrue[\"⚑Ready \"||\"\"]}{service.cached::isfalse[\"❌ Not Ready \"||\"\"]}{service.shortName::exists[\"({service.shortName}) \"||\"\"]}{stream.type::=Debrid[\"☁️ Debrid \"||\"\"]}πŸ”{addon.name}" + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt new file mode 100644 index 00000000..7a670339 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt @@ -0,0 +1,45 @@ +package com.nuvio.app.features.debrid + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DebridStreamTemplateEngineTest { + private val engine = DebridStreamTemplateEngine() + + @Test + fun `renders nested condition branches and transforms`() { + val rendered = engine.render( + "{stream.resolution::=2160p[\"4K {service.shortName} \"||\"\"]}{stream.title::title}", + mapOf( + "stream.resolution" to "2160p", + "stream.title" to "sample movie", + "service.shortName" to "RD", + ), + ) + + assertEquals("4K RD Sample Movie", rendered) + } + + @Test + fun `formats bytes and joins list values`() { + val rendered = engine.render( + "{stream.size::bytes} {stream.audioTags::join(' | ')}", + mapOf( + "stream.size" to 1_610_612_736L, + "stream.audioTags" to listOf("DTS", "Atmos"), + ), + ) + + assertEquals("1.5 GB DTS | Atmos", rendered) + } + + @Test + fun `renders Debrid size values as readable text while keeping numeric comparisons`() { + val rendered = engine.render( + "{stream.size::>0[\"{stream.size}\"||\"\"]}", + mapOf("stream.size" to DebridTemplateBytes(7_361_184_308L)), + ) + + assertEquals("6.9 GB", rendered) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt new file mode 100644 index 00000000..15fcf1e2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt @@ -0,0 +1,27 @@ +package com.nuvio.app.features.debrid + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DirectDebridConfigEncoderTest { + @Test + fun `encodes Torbox config exactly like TV`() { + val encoded = DirectDebridConfigEncoder().encodeTorbox("tb_key") + + assertEquals( + "eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InRvcmJveCIsImFwaUtleSI6InRiX2tleSJ9XSwiZW5hYmxlVG9ycmVudCI6ZmFsc2V9", + encoded, + ) + } + + @Test + fun `escapes API key before base64 encoding`() { + val encoded = DirectDebridConfigEncoder().encode( + DebridServiceCredential(DebridProviders.RealDebrid, "rd\"key\\line"), + ) + + val expected = "eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InJlYWxkZWJyaWQiLCJhcGlLZXkiOiJyZFwia2V5XFxsaW5lIn1dLCJlbmFibGVUb3JyZW50IjpmYWxzZX0=" + assertEquals(expected, encoded) + } +} + diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt new file mode 100644 index 00000000..593fa6af --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt @@ -0,0 +1,210 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamClientResolveParsed +import com.nuvio.app.features.streams.StreamClientResolveRaw +import com.nuvio.app.features.streams.StreamClientResolveStream +import com.nuvio.app.features.streams.StreamItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DirectDebridStreamFilterTest { + @Test + fun `keeps only cached supported debrid streams`() { + val torbox = stream(service = DebridProviders.TORBOX_ID, cached = true) + val uncached = stream(service = DebridProviders.TORBOX_ID, cached = false) + val unsupported = stream(service = "other", cached = true) + val torrent = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, type = "torrent") + + val filtered = DirectDebridStreamFilter.filterInstant(listOf(torbox, uncached, unsupported, torrent)) + + assertEquals(1, filtered.size) + assertEquals("Torbox Instant", filtered.single().addonName) + assertEquals("debrid:torbox", filtered.single().addonId) + } + + @Test + fun `dedupes by hash file and filename identity`() { + val first = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "ABC", fileIdx = 2) + val duplicate = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 2) + val otherFile = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 3) + + val filtered = DirectDebridStreamFilter.filterInstant(listOf(first, duplicate, otherFile)) + + assertEquals(2, filtered.size) + } + + @Test + fun `direct debrid stream is not treated as unsupported torrent`() { + val direct = stream(service = DebridProviders.TORBOX_ID, cached = true, infoHash = "hash") + val plainTorrent = StreamItem( + name = "Torrent", + infoHash = "hash", + addonName = "Addon", + addonId = "addon", + ) + + assertTrue(direct.isDirectDebridStream) + assertFalse(direct.isTorrentStream) + assertTrue(plainTorrent.isTorrentStream) + } + + @Test + fun `sorts and limits streams by quality and size`() { + val streams = listOf( + stream(resolution = "1080p", size = 20), + stream(resolution = "2160p", size = 10), + stream(resolution = "2160p", size = 30), + stream(resolution = "720p", size = 40), + ) + + val filtered = DirectDebridStreamFilter.filterInstant( + streams, + DebridSettings( + streamMaxResults = 2, + streamSortMode = DebridStreamSortMode.QUALITY_DESC, + ), + ) + + assertEquals(listOf(30L, 10L), filtered.map { it.clientResolve?.stream?.raw?.size }) + } + + @Test + fun `filters minimum quality dolby vision hdr and codec`() { + val hdrHevc = stream(resolution = "2160p", hdr = listOf("HDR10"), codec = "HEVC", size = 10) + val dvHevc = stream(resolution = "2160p", hdr = listOf("DV", "HDR10"), codec = "HEVC", size = 20) + val sdrAvc = stream(resolution = "1080p", codec = "AVC", size = 30) + val hdHevc = stream(resolution = "720p", codec = "HEVC", size = 40) + + val noDvHdrHevc4k = DirectDebridStreamFilter.filterInstant( + listOf(hdrHevc, dvHevc, sdrAvc, hdHevc), + DebridSettings( + streamMinimumQuality = DebridStreamMinimumQuality.P2160, + streamDolbyVisionFilter = DebridStreamFeatureFilter.EXCLUDE, + streamHdrFilter = DebridStreamFeatureFilter.ONLY, + streamCodecFilter = DebridStreamCodecFilter.HEVC, + ), + ) + + assertEquals(listOf(10L), noDvHdrHevc4k.map { it.clientResolve?.stream?.raw?.size }) + + val dvOnly = DirectDebridStreamFilter.filterInstant( + listOf(hdrHevc, dvHevc, sdrAvc, hdHevc), + DebridSettings(streamDolbyVisionFilter = DebridStreamFeatureFilter.ONLY), + ) + + assertEquals(listOf(20L), dvOnly.map { it.clientResolve?.stream?.raw?.size }) + } + + @Test + fun `applies stream preference filters and sort criteria`() { + val remuxAtmos = stream( + resolution = "2160p", + quality = "BluRay REMUX", + codec = "HEVC", + audio = listOf("Atmos", "TrueHD"), + channels = listOf("7.1"), + languages = listOf("en"), + group = "GOOD", + size = 40_000_000_000, + ) + val webAac = stream( + resolution = "2160p", + quality = "WEB-DL", + codec = "AVC", + audio = listOf("AAC"), + channels = listOf("2.0"), + languages = listOf("en"), + group = "NOPE", + size = 4_000_000_000, + ) + val blurayDts = stream( + resolution = "1080p", + quality = "BluRay", + codec = "AVC", + audio = listOf("DTS"), + channels = listOf("5.1"), + languages = listOf("hi"), + group = "GOOD", + size = 12_000_000_000, + ) + + val filtered = DirectDebridStreamFilter.filterInstant( + listOf(webAac, blurayDts, remuxAtmos), + DebridSettings( + streamPreferences = DebridStreamPreferences( + maxResults = 2, + maxPerResolution = 1, + sizeMinGb = 5, + requiredResolutions = listOf(DebridStreamResolution.P2160, DebridStreamResolution.P1080), + excludedQualities = listOf(DebridStreamQuality.WEB_DL), + requiredAudioChannels = listOf(DebridStreamAudioChannel.CH_7_1, DebridStreamAudioChannel.CH_5_1), + excludedEncodes = listOf(DebridStreamEncode.UNKNOWN), + excludedLanguages = listOf(DebridStreamLanguage.IT), + requiredReleaseGroups = listOf("GOOD"), + sortCriteria = listOf( + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC), + ), + ), + ), + ) + + assertEquals(listOf(40_000_000_000L, 12_000_000_000L), filtered.map { it.clientResolve?.stream?.raw?.size }) + } + + private fun stream( + service: String? = DebridProviders.TORBOX_ID, + cached: Boolean? = true, + type: String = "debrid", + infoHash: String = "hash", + fileIdx: Int = 1, + resolution: String? = null, + quality: String? = null, + hdr: List = emptyList(), + codec: String? = null, + audio: List = emptyList(), + channels: List = emptyList(), + languages: List = emptyList(), + group: String? = null, + size: Long? = null, + ): StreamItem = + StreamItem( + name = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}", + description = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}", + addonName = "Direct Debrid", + addonId = "debrid", + clientResolve = StreamClientResolve( + type = type, + service = service, + isCached = cached, + infoHash = infoHash + size.orEmptyHashPart() + resolution.orEmpty() + quality.orEmpty() + codec.orEmpty(), + fileIdx = fileIdx, + filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv", + torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}", + stream = StreamClientResolveStream( + raw = StreamClientResolveRaw( + torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}", + filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv", + size = size, + folderSize = size, + parsed = StreamClientResolveParsed( + resolution = resolution, + quality = quality, + hdr = hdr, + codec = codec, + audio = audio, + channels = channels, + languages = languages, + group = group, + ), + ), + ), + ), + ) +} + +private fun Long?.orEmptyHashPart(): String = + this?.toString().orEmpty() 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 new file mode 100644 index 00000000..68acd752 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt @@ -0,0 +1,70 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.player.PlayerSettingsUiState +import com.nuvio.app.features.streams.StreamAutoPlayMode +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamItem +import kotlin.test.Test +import kotlin.test.assertEquals + +class DirectDebridStreamPreparerTest { + + @Test + fun `prioritizes autoplay direct debrid match before display order`() { + val first = directDebridStream(name = "1080p", infoHash = "hash-1") + val autoPlayMatch = directDebridStream(name = "2160p WEB", infoHash = "hash-2") + val remaining = directDebridStream(name = "720p", infoHash = "hash-3") + + val selected = DirectDebridStreamPreparer.prioritizeCandidates( + streams = listOf(first, autoPlayMatch, remaining), + limit = 2, + playerSettings = PlayerSettingsUiState( + streamAutoPlayMode = StreamAutoPlayMode.REGEX_MATCH, + streamAutoPlayRegex = "2160p", + ), + installedAddonNames = emptySet(), + ) + + assertEquals(listOf(autoPlayMatch, first), selected) + } + + @Test + fun `skips already resolved and duplicate direct debrid candidates`() { + val unresolved = directDebridStream(name = "1080p", infoHash = "hash-1") + val duplicate = directDebridStream(name = "1080p Duplicate", infoHash = "HASH-1") + val alreadyResolved = directDebridStream( + name = "2160p", + infoHash = "hash-2", + url = "https://example.com/ready.mp4", + ) + + val selected = DirectDebridStreamPreparer.prioritizeCandidates( + streams = listOf(unresolved, duplicate, alreadyResolved), + limit = 5, + playerSettings = PlayerSettingsUiState(), + installedAddonNames = emptySet(), + ) + + assertEquals(listOf(unresolved), selected) + } + + private fun directDebridStream( + name: String, + infoHash: String, + url: String? = null, + ): StreamItem = + StreamItem( + name = name, + url = url, + addonName = "Torbox Instant", + addonId = "debrid:torbox", + clientResolve = StreamClientResolve( + type = "debrid", + service = DebridProviders.TORBOX_ID, + isCached = true, + infoHash = infoHash, + fileIdx = 1, + filename = "video.mkv", + ), + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt index 45fa4740..1ebf6b84 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt @@ -145,16 +145,49 @@ class StreamAutoPlaySelectorTest { assertNull(selected) } + @Test + fun `first stream mode can select direct debrid candidate without resolved URL`() { + val directDebrid = stream( + addonName = "Torbox Instant", + url = null, + name = "TB Instant", + directDebrid = true, + ) + + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = listOf(directDebrid), + mode = StreamAutoPlayMode.FIRST_STREAM, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = emptySet(), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + ) + + assertEquals(directDebrid, selected) + } + private fun stream( addonName: String, url: String? = null, name: String? = null, bingeGroup: String? = null, + directDebrid: Boolean = false, ): StreamItem = StreamItem( name = name, url = url, addonName = addonName, addonId = addonName, + clientResolve = if (directDebrid) { + StreamClientResolve( + type = "debrid", + service = "torbox", + isCached = true, + infoHash = "hash", + ) + } else { + null + }, behaviorHints = StreamBehaviorHints( bingeGroup = bingeGroup, ), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt index 09434fac..9260e883 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt @@ -120,4 +120,55 @@ class StreamParserTest { assertEquals("ok", responseHeaders["x-test"]) } + @Test + fun `parse keeps client resolve metadata without direct URL`() { + val streams = StreamParser.parse( + payload = + """ + { + "streams": [ + { + "name": "Instant", + "clientResolve": { + "type": "debrid", + "infoHash": "abc123", + "fileIdx": 4, + "sources": ["udp://tracker.example"], + "torrentName": "Movie Pack", + "filename": "Movie.2024.2160p.mkv", + "service": "torbox", + "isCached": true, + "stream": { + "raw": { + "size": 1610612736, + "indexer": "Indexer", + "parsed": { + "parsed_title": "Movie", + "year": 2024, + "resolution": "2160p", + "hdr": ["DV"], + "audio": ["Atmos"], + "episodes": [1, 2], + "bit_depth": "10bit" + } + } + } + } + } + ] + } + """.trimIndent(), + addonName = "Direct Debrid", + addonId = "debrid:torbox", + ) + + val stream = streams.single() + assertTrue(stream.isDirectDebridStream) + assertFalse(stream.isTorrentStream) + assertEquals("abc123", stream.clientResolve?.infoHash) + assertEquals(4, stream.clientResolve?.fileIdx) + assertEquals("udp://tracker.example", stream.clientResolve?.sources?.single()) + assertEquals("2160p", stream.clientResolve?.stream?.raw?.parsed?.resolution) + assertEquals(listOf(1, 2), stream.clientResolve?.stream?.raw?.parsed?.episodes) + } } 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 new file mode 100644 index 00000000..dc85c449 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -0,0 +1,193 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncInt +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncInt +import com.nuvio.app.core.sync.encodeSyncString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import platform.Foundation.NSUserDefaults + +actual object DebridSettingsStorage { + private const val enabledKey = "debrid_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" + private const val streamMaxResultsKey = "debrid_stream_max_results" + private const val streamSortModeKey = "debrid_stream_sort_mode" + private const val streamMinimumQualityKey = "debrid_stream_minimum_quality" + private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter" + private const val streamHdrFilterKey = "debrid_stream_hdr_filter" + private const val streamCodecFilterKey = "debrid_stream_codec_filter" + 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, + ) + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + + actual fun saveEnabled(enabled: Boolean) { + saveBoolean(enabledKey, enabled) + } + + actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey) + + actual fun saveTorboxApiKey(apiKey: String) { + saveString(torboxApiKeyKey, apiKey) + } + + actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey) + + actual fun saveRealDebridApiKey(apiKey: String) { + saveString(realDebridApiKeyKey, apiKey) + } + + actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey) + + actual fun saveInstantPlaybackPreparationLimit(limit: Int) { + saveInt(instantPlaybackPreparationLimitKey, limit) + } + + actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey) + + actual fun saveStreamMaxResults(maxResults: Int) { + saveInt(streamMaxResultsKey, maxResults) + } + + actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey) + + actual fun saveStreamSortMode(mode: String) { + saveString(streamSortModeKey, mode) + } + + actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey) + + actual fun saveStreamMinimumQuality(quality: String) { + saveString(streamMinimumQualityKey, quality) + } + + actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey) + + actual fun saveStreamDolbyVisionFilter(filter: String) { + saveString(streamDolbyVisionFilterKey, filter) + } + + actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey) + + actual fun saveStreamHdrFilter(filter: String) { + saveString(streamHdrFilterKey, filter) + } + + actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey) + + actual fun saveStreamCodecFilter(filter: String) { + saveString(streamCodecFilterKey, filter) + } + + actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey) + + actual fun saveStreamPreferences(preferences: String) { + saveString(streamPreferencesKey, preferences) + } + + actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) + + actual fun saveStreamNameTemplate(template: String) { + saveString(streamNameTemplateKey, template) + } + + actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey) + + actual fun saveStreamDescriptionTemplate(template: String) { + saveString(streamDescriptionTemplateKey, template) + } + + private fun loadBoolean(key: String): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val scopedKey = ProfileScopedKey.of(key) + return if (defaults.objectForKey(scopedKey) != null) { + defaults.boolForKey(scopedKey) + } else { + null + } + } + + private fun saveBoolean(key: String, enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(key)) + } + + private fun loadInt(key: String): Int? { + val defaults = NSUserDefaults.standardUserDefaults + val scopedKey = ProfileScopedKey.of(key) + return if (defaults.objectForKey(scopedKey) != null) { + defaults.integerForKey(scopedKey).toInt() + } else { + null + } + } + + private fun saveInt(key: String, value: Int) { + NSUserDefaults.standardUserDefaults.setInteger(value.toLong(), forKey = ProfileScopedKey.of(key)) + } + + private fun loadString(key: String): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(key)) + + private fun saveString(key: String, value: String) { + NSUserDefaults.standardUserDefaults.setObject(value, forKey = ProfileScopedKey.of(key)) + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) } + loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) } + loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } + loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) } + loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) } + loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) } + loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) } + loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) } + loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) } + loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) } + loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } + loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + syncKeys.forEach { key -> + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key)) + } + + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) + payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) + payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) + payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults) + payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode) + payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality) + payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter) + payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter) + payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter) + payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences) + payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) + payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) + } +} From e85ea4f32223e1fb034de1685251df47b6b5b60d Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 22 May 2026 17:11:34 +0530 Subject: [PATCH 18/18] feat: save resolved links in memory --- .../features/cloud/CloudLibraryRepository.kt | 60 +++++++++++++++++- .../features/cloud/CloudLibraryStoreTest.kt | 62 ++++++++++++++++++- 2 files changed, 119 insertions(+), 3 deletions(-) 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 d5f7669d..492b70a3 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 @@ -64,6 +64,13 @@ internal class CloudLibraryStore( ?: return CloudLibraryPlaybackResult.MissingCredentials val api = providerApis.firstOrNull { it.provider.id == item.providerId } ?: return CloudLibraryPlaybackResult.Failed() + file.playbackUrl?.takeIf { it.isNotBlank() }?.let { url -> + return CloudLibraryPlaybackResult.Success( + url = url, + filename = file.name.takeIf { it.isNotBlank() }, + videoSizeBytes = file.sizeBytes, + ) + } return api.resolvePlayback( apiKey = credential.apiKey, item = item, @@ -186,7 +193,26 @@ object CloudLibraryRepository { if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) { return CloudLibraryPlaybackResult.Failed("Cloud library is disabled.") } - return store.resolvePlayback(item, file) + val result = store.resolvePlayback(item, file) + if (result is CloudLibraryPlaybackResult.Success) { + rememberResolvedPlaybackUrl(item = item, file = file, url = result.url) + } + return result + } + + private fun rememberResolvedPlaybackUrl( + item: CloudLibraryItem, + file: CloudLibraryFile, + url: String, + ) { + if (url.isBlank()) return + _uiState.update { current -> + current.withResolvedPlaybackUrl( + item = item, + file = file, + url = url, + ) + } } private fun connectedCloudCredentials(): List = @@ -248,3 +274,35 @@ internal fun CloudLibraryUiState.findPlaybackTargetForProgress( val singleFile = singleItem.playableFiles.singleOrNull() ?: return null return CloudLibraryPlaybackTarget(item = singleItem, file = singleFile) } + +internal fun CloudLibraryUiState.withResolvedPlaybackUrl( + item: CloudLibraryItem, + file: CloudLibraryFile, + url: String, +): CloudLibraryUiState { + val normalizedUrl = url.trim().takeIf { it.isNotBlank() } ?: return this + val targetItemKey = item.stableKey + val targetFileKey = file.stableKey + var didUpdate = false + val updatedProviders = providers.map { providerState -> + if (providerState.providerId != item.providerId) return@map providerState + val updatedItems = providerState.items.map { candidateItem -> + if (candidateItem.stableKey != targetItemKey) return@map candidateItem + val updatedFiles = candidateItem.files.map { candidateFile -> + if (candidateFile.stableKey != targetFileKey) { + candidateFile + } else { + didUpdate = true + candidateFile.copy(playbackUrl = normalizedUrl) + } + } + candidateItem.copy(files = updatedFiles) + } + providerState.copy(items = updatedItems) + } + return if (didUpdate) { + copy(providers = updatedProviders) + } else { + this + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt index 5610846a..25f25e39 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt @@ -130,6 +130,59 @@ class CloudLibraryStoreTest { assertEquals(item.playableFiles.single(), target.file) } + @Test + fun `resolve playback reuses already resolved file url`() = runBlocking { + val provider = cloudProvider(id = "premiumize", name = "Premiumize") + val api = FakeCloudProviderApi( + provider = provider, + items = emptyList(), + ) + val store = CloudLibraryStore( + credentialsProvider = { + listOf(DebridServiceCredential(provider, "token")) + }, + providerApis = listOf(api), + ) + val item = cloudItem(provider, "ready") + val file = item.playableFiles.single().copy(playbackUrl = "https://cached.example/video.mkv") + + val result = store.resolvePlayback(item = item, file = file) + + assertTrue(result is CloudLibraryPlaybackResult.Success) + assertEquals("https://cached.example/video.mkv", result.url) + assertEquals(0, api.resolvePlaybackCalls) + } + + @Test + fun `resolved playback url is remembered in cloud library state`() { + val provider = cloudProvider(id = "torbox", name = "TorBox") + val item = cloudItem(provider, "29773238") + val file = item.playableFiles.single() + val state = CloudLibraryUiState( + isLoaded = true, + providers = listOf( + CloudLibraryProviderState( + provider = provider, + items = listOf(item), + ), + ), + ) + + val updated = state.withResolvedPlaybackUrl( + item = item, + file = file, + url = "https://resolved.example/movie.mkv", + ) + + val target = assertNotNull( + updated.findPlaybackTargetForProgress( + contentId = item.stableKey, + videoId = item.playbackVideoId(file), + ), + ) + assertEquals("https://resolved.example/movie.mkv", target.file.playbackUrl) + } + @Test fun `provider poster urls are mapped for cloud services`() { assertEquals( @@ -155,6 +208,9 @@ private class FakeCloudProviderApi( override val provider: DebridProvider, private val items: List, ) : CloudLibraryProviderApi { + var resolvePlaybackCalls: Int = 0 + private set + override suspend fun listItems(apiKey: String): Result> = Result.success(items) @@ -162,8 +218,10 @@ private class FakeCloudProviderApi( apiKey: String, item: CloudLibraryItem, file: CloudLibraryFile, - ): CloudLibraryPlaybackResult = - CloudLibraryPlaybackResult.Success(url = "https://example.test/${item.id}/${file.id}") + ): CloudLibraryPlaybackResult { + resolvePlaybackCalls += 1 + return CloudLibraryPlaybackResult.Success(url = "https://example.test/${item.id}/${file.id}") + } } private fun cloudProvider(id: String, name: String): DebridProvider =