mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-22 17:52:06 +00:00
feat: debrid hybrid approach
This commit is contained in:
parent
9818458b9f
commit
2a550c4356
34 changed files with 895 additions and 1089 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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't open external player</string>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}\"||\"\"]}"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ object DownloadsRepository {
|
|||
): DownloadEnqueueResult {
|
||||
ensureLoaded()
|
||||
|
||||
val sourceUrl = stream.directPlaybackUrl
|
||||
val sourceUrl = stream.playableDirectUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return DownloadEnqueueResult.MissingUrl
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
@ -158,7 +158,7 @@ class StreamParserTest {
|
|||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
addonName = "Direct Debrid",
|
||||
addonName = "Debrid Fixture",
|
||||
addonId = "debrid:torbox",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
CURRENT_PROJECT_VERSION=64
|
||||
MARKETING_VERSION=0.1.22
|
||||
MARKETING_VERSION=0.1.0
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue