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