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