feat: debrid hybrid approach

This commit is contained in:
tapframe 2026-05-20 13:16:22 +05:30
parent 9818458b9f
commit 2a550c4356
34 changed files with 895 additions and 1089 deletions

View file

@ -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(

View file

@ -1153,6 +1153,7 @@
<string name="streams_size">SIZE %1$s</string>
<string name="streams_torrent_not_supported">This stream type is not supported</string>
<string name="debrid_missing_api_key">Add a Debrid API key in Settings.</string>
<string name="debrid_not_cached">Not cached on Torbox.</string>
<string name="debrid_stream_stale">This Debrid result expired. Refreshing streams.</string>
<string name="debrid_resolve_failed">Could not resolve this Debrid stream.</string>
<string name="external_player_failed">Couldn&apos;t open external player</string>

View file

@ -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,

View file

@ -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<T>(
@ -38,6 +39,33 @@ internal object TorboxApiClient {
body = "",
)
suspend fun checkCached(
apiKey: String,
hashes: List<String>,
): DebridApiResponse<TorboxEnvelopeDto<Map<String, TorboxCachedItemDto>>> {
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<TorboxEnvelopeDto<TorboxCreateTorrentDataDto>> {
val boundary = "NuvioDebrid${magnet.hashCode().toUInt()}"
val body = multipartFormBody(

View file

@ -44,6 +44,18 @@ internal data class TorboxTorrentFileDto(
.orEmpty()
}
@Serializable
internal data class TorboxCheckCachedRequestDto(
val hashes: List<String>,
)
@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,
)

View file

@ -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() }
}
}

View file

@ -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<String, Any?> {
private fun buildValues(stream: StreamItem, settings: DebridSettings): Map<String, Any?> {
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<String> =
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 <T> List<T>.mapNotUnknown(label: (T) -> String): List<String> =
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 }
}

View file

@ -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}\"||\"\"]}"
}

View file

@ -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<StreamItem>, settings: DebridSettings? = null): List<StreamItem> {
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<AddonStreamGroup>, settings: DebridSettings): List<AddonStreamGroup> {
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<StreamItem>, settings: DebridSettings): List<StreamItem> {
val preferences = effectivePreferences(settings)
return streams.map { it to streamFacts(it, preferences) }
internal fun applyPreferences(streams: List<StreamItem>, settings: DebridSettings): List<StreamItem> {
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<Pair<StreamItem, DebridStreamFacts>>,
preferences: DebridStreamPreferences,
): List<Pair<StreamItem, DebridStreamFacts>> {
val resolutionCounts = mutableMapOf<DebridStreamResolution, Int>()
val qualityCounts = mutableMapOf<DebridStreamQuality, Int>()
val result = mutableListOf<Pair<StreamItem, DebridStreamFacts>>()
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<DebridStreamSortCriterion>,
): 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<Pair<StreamItem, StreamFacts>>,
preferences: DebridStreamPreferences,
): List<Pair<StreamItem, StreamFacts>> {
val resolutionCounts = mutableMapOf<DebridStreamResolution, Int>()
val qualityCounts = mutableMapOf<DebridStreamQuality, Int>()
val result = mutableListOf<Pair<StreamItem, StreamFacts>>()
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<DebridStreamSortCriterion>,
): 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<DebridStreamVisualTag>()
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<DebridStreamVisualTag>,
val audioTags: List<DebridStreamAudioTag>,
val audioChannels: List<DebridStreamAudioChannel>,
val encode: DebridStreamEncode,
val languages: List<DebridStreamLanguage>,
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<DebridStreamVisualTag>,
val audioTags: List<DebridStreamAudioTag>,
val audioChannels: List<DebridStreamAudioChannel>,
val encode: DebridStreamEncode,
val languages: List<DebridStreamLanguage>,
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

View file

@ -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()

View file

@ -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)
}
}
}
}
}

View file

@ -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<String, Deferred<DirectDebridResolveResult>>()
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<TorboxEnvelopeDto<TorboxCreateTorrentDataDto>>.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<TorboxEnvelopeDto<TorboxCreateTorrentDataDto>>.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)

View file

@ -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<StreamItem> {
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())
}

View file

@ -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<DirectDebridStreamCacheKey, CachedDirectDebridStreams>()
private val inFlightFetches = mutableMapOf<DirectDebridStreamCacheKey, Deferred<AddonStreamGroup>>()
fun configuredTargets(): List<DirectDebridStreamTarget> {
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<String> =
configuredTargets().map { it.addonName }
fun isEnabled(): Boolean =
sourceNames().isNotEmpty()
fun placeholders(): List<AddonStreamGroup> =
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<AddonStreamGroup>()
val errors = mutableListOf<String>()
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<AddonStreamGroup>) : DirectDebridStreamFetchResult()
data class Error(val message: String) : DirectDebridStreamFetchResult()
}

View file

@ -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<AddonStreamGroup>): List<AddonStreamGroup> {
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<AddonStreamGroup>): List<AddonStreamGroup> {
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<String>,
): Map<String, TorboxCachedItemDto>? =
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<AddonStreamGroup>.updateAvailabilityStatus(
transform: (StreamItem) -> StreamItem,
): List<AddonStreamGroup> =
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
}

View file

@ -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)

View file

@ -117,7 +117,7 @@ object DownloadsRepository {
): DownloadEnqueueResult {
ensureLoaded()
val sourceUrl = stream.directPlaybackUrl
val sourceUrl = stream.playableDirectUrl
?.trim()
?.takeIf { it.isNotBlank() }
?: return DownloadEnqueueResult.MissingUrl

View file

@ -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),

View file

@ -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

View file

@ -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
}

View file

@ -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<AddonStreamGroup>(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,
),
)
}
}
}

View file

@ -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 },

View file

@ -40,6 +40,7 @@ object StreamAutoPlaySelector {
selectedPlugins: Set<String>,
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))
}

View file

@ -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<String, String>? = 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,

View file

@ -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<String, String>()
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<StreamItem>,

View file

@ -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<StreamItem?>(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

View file

@ -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,

View file

@ -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}"
}
}

View file

@ -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,
),
)
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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<String> = emptyList(),
codec: String? = null,
audio: List<String> = emptyList(),
channels: List<String> = emptyList(),
languages: List<String> = 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()

View file

@ -158,7 +158,7 @@ class StreamParserTest {
]
}
""".trimIndent(),
addonName = "Direct Debrid",
addonName = "Debrid Fixture",
addonId = "debrid:torbox",
)

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=64
MARKETING_VERSION=0.1.22
MARKETING_VERSION=0.1.0