Updated DebridStreamPresentation to filter out uncached streams and apply custom formatting based on settings.

This commit is contained in:
tapframe 2026-05-20 16:15:02 +05:30
parent 2a550c4356
commit 41b0bd95ad
17 changed files with 600 additions and 100 deletions

View file

@ -19,6 +19,9 @@ data class DebridSettings(
) {
val hasAnyApiKey: Boolean
get() = DebridProviders.configuredServices(this).isNotEmpty()
val hasCustomStreamFormatting: Boolean
get() = streamNameTemplate.isNotBlank() || streamDescriptionTemplate.isNotBlank()
}
const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2

View file

@ -160,7 +160,7 @@ object DebridSettingsRepository {
fun setStreamNameTemplate(value: String) {
ensureLoaded()
val normalized = value.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE }
val normalized = normalizeStreamTemplate(value, DebridTemplateKind.NAME)
if (streamNameTemplate == normalized) return
streamNameTemplate = normalized
publish()
@ -169,7 +169,7 @@ object DebridSettingsRepository {
fun setStreamDescriptionTemplate(value: String) {
ensureLoaded()
val normalized = value.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE }
val normalized = normalizeStreamTemplate(value, DebridTemplateKind.DESCRIPTION)
if (streamDescriptionTemplate == normalized) return
streamDescriptionTemplate = normalized
publish()
@ -178,8 +178,8 @@ object DebridSettingsRepository {
fun setStreamTemplates(nameTemplate: String, descriptionTemplate: String) {
ensureLoaded()
streamNameTemplate = nameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE }
streamDescriptionTemplate = descriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE }
streamNameTemplate = normalizeStreamTemplate(nameTemplate, DebridTemplateKind.NAME)
streamDescriptionTemplate = normalizeStreamTemplate(descriptionTemplate, DebridTemplateKind.DESCRIPTION)
publish()
DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate)
DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate)
@ -241,12 +241,14 @@ object DebridSettingsRepository {
hdrFilter = streamHdrFilter,
codecFilter = streamCodecFilter,
)
streamNameTemplate = DebridSettingsStorage.loadStreamNameTemplate()
?.takeIf { it.isNotBlank() }
?: DebridStreamFormatterDefaults.NAME_TEMPLATE
streamDescriptionTemplate = DebridSettingsStorage.loadStreamDescriptionTemplate()
?.takeIf { it.isNotBlank() }
?: DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE
streamNameTemplate = normalizeStreamTemplate(
DebridSettingsStorage.loadStreamNameTemplate().orEmpty(),
DebridTemplateKind.NAME,
)
streamDescriptionTemplate = normalizeStreamTemplate(
DebridSettingsStorage.loadStreamDescriptionTemplate().orEmpty(),
DebridTemplateKind.DESCRIPTION,
)
publish()
}
@ -285,6 +287,21 @@ object DebridSettingsRepository {
null
}
}
private enum class DebridTemplateKind {
NAME,
DESCRIPTION,
}
private fun normalizeStreamTemplate(value: String, kind: DebridTemplateKind): String {
val trimmed = value.trim()
return when {
trimmed.isBlank() -> ""
kind == DebridTemplateKind.NAME && trimmed == DebridStreamFormatterDefaults.LEGACY_NAME_TEMPLATE -> ""
kind == DebridTemplateKind.DESCRIPTION && trimmed == DebridStreamFormatterDefaults.LEGACY_DESCRIPTION_TEMPLATE -> ""
else -> value
}
}
}
internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences =

View file

@ -1,7 +1,11 @@
package com.nuvio.app.features.debrid
object DebridStreamFormatterDefaults {
const val NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}{service.cached::istrue[\"Ready\"||\"Not Ready\"]}"
const val NAME_TEMPLATE = ""
const val DESCRIPTION_TEMPLATE = "{stream.title::exists[\"{stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year})\"||\"\"]}\n{stream.quality::exists[\"{stream.quality} \"||\"\"]}{stream.visualTags::exists[\"{stream.visualTags::join(' | ')} \"||\"\"]}{stream.encode::exists[\"{stream.encode} \"||\"\"]}\n{stream.audioTags::exists[\"{stream.audioTags::join(' | ')}\"||\"\"]}{stream.audioTags::exists::and::stream.audioChannels::exists[\" | \"||\"\"]}{stream.audioChannels::exists[\"{stream.audioChannels::join(' | ')}\"||\"\"]}\n{stream.size::>0[\"{stream.size::bytes} \"||\"\"]}{stream.releaseGroup::exists[\"{stream.releaseGroup} \"||\"\"]}{stream.indexer::exists[\"{stream.indexer}\"||\"\"]}\n{service.cached::istrue[\"Ready\"||\"Not Ready\"]}{service.shortName::exists[\" ({service.shortName})\"||\"\"]}{stream.filename::exists[\"\n{stream.filename}\"||\"\"]}"
const val DESCRIPTION_TEMPLATE = ""
const val LEGACY_NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}{service.cached::istrue[\"Ready\"||\"Not Ready\"]}"
const val LEGACY_DESCRIPTION_TEMPLATE = "{stream.title::exists[\"{stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year})\"||\"\"]}\n{stream.quality::exists[\"{stream.quality} \"||\"\"]}{stream.visualTags::exists[\"{stream.visualTags::join(' | ')} \"||\"\"]}{stream.encode::exists[\"{stream.encode} \"||\"\"]}\n{stream.audioTags::exists[\"{stream.audioTags::join(' | ')}\"||\"\"]}{stream.audioTags::exists::and::stream.audioChannels::exists[\" | \"||\"\"]}{stream.audioChannels::exists[\"{stream.audioChannels::join(' | ')}\"||\"\"]}\n{stream.size::>0[\"{stream.size::bytes} \"||\"\"]}{stream.releaseGroup::exists[\"{stream.releaseGroup} \"||\"\"]}{stream.indexer::exists[\"{stream.indexer}\"||\"\"]}\n{service.cached::istrue[\"Ready\"||\"Not Ready\"]}{service.shortName::exists[\" ({service.shortName})\"||\"\"]}{stream.filename::exists[\"\n{stream.filename}\"||\"\"]}"
}

View file

@ -10,12 +10,19 @@ object DebridStreamPresentation {
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 visibleStreams = group.streams.filterNot { stream -> stream.isUncachedDebridStream }
val debridStreams = visibleStreams.filter { stream -> stream.isManagedDebridStream }
if (debridStreams.isEmpty()) return@map group.copy(streams = visibleStreams)
val presentedDebridStreams = applyPreferences(debridStreams, settings)
.map { stream -> formatter.format(stream, settings) }
val passthroughStreams = group.streams.filterNot { stream -> stream.isManagedDebridStream }
.map { stream ->
if (settings.hasCustomStreamFormatting) {
formatter.format(stream, settings)
} else {
stream
}
}
val passthroughStreams = visibleStreams.filterNot { stream -> stream.isManagedDebridStream }
group.copy(streams = presentedDebridStreams + passthroughStreams)
}
@ -33,14 +40,19 @@ object DebridStreamPresentation {
internal val StreamItem.isManagedDebridStream: Boolean
get() {
val status = debridCacheStatus
return isDirectDebridStream || (
return isAddonDebridCandidate && (isDirectDebridStream || (
isTorrentStream &&
status != null &&
status.providerId == DebridProviders.TORBOX_ID &&
status.state != StreamDebridCacheState.CHECKING
)
))
}
private val StreamItem.isUncachedDebridStream: Boolean
get() = isInstalledAddonStream &&
debridCacheStatus?.providerId == DebridProviders.TORBOX_ID &&
debridCacheStatus.state == StreamDebridCacheState.NOT_CACHED
private fun applyLimits(
streams: List<Pair<StreamItem, DebridStreamFacts>>,
preferences: DebridStreamPreferences,

View file

@ -115,9 +115,9 @@ object DirectDebridPlaybackResolver {
val settings = DebridSettingsRepository.snapshot()
if (!settings.enabled) return false
if (stream.needsLocalDebridResolve) {
return settings.torboxApiKey.isNotBlank()
return stream.isInstalledAddonStream && settings.torboxApiKey.isNotBlank()
}
if (!stream.isDirectDebridStream || stream.playableDirectUrl != null) {
if (!stream.isInstalledAddonStream || !stream.isDirectDebridStream || stream.playableDirectUrl != null) {
return false
}
return when (DebridProviders.byId(stream.clientResolve?.service)?.id) {

View file

@ -73,6 +73,7 @@ object DirectDebridStreamPreparer {
val candidates = streams
.filter { stream ->
stream.playableDirectUrl == null &&
stream.isAddonDebridCandidate &&
(stream.isDirectDebridStream || stream.isCachedDebridTorrentStream)
}
.distinctBy { it.preparationKey() }
@ -88,7 +89,7 @@ object DirectDebridStreamPreparer {
selectedAddons = playerSettings.streamAutoPlaySelectedAddons,
selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins,
)
if (autoPlaySelection?.let { it.isDirectDebridStream || it.isCachedDebridTorrentStream } == true) {
if (autoPlaySelection?.let { it.isAddonDebridCandidate && (it.isDirectDebridStream || it.isCachedDebridTorrentStream) } == true) {
candidates.firstOrNull { it.preparationKey() == autoPlaySelection.preparationKey() }
?.let(prioritized::add)
}
@ -118,9 +119,11 @@ object DirectDebridStreamPreparer {
groups: List<AddonStreamGroup>,
original: StreamItem,
prepared: StreamItem,
eligibleGroupIds: Set<String>? = null,
): List<AddonStreamGroup> {
val key = original.preparationKey()
return groups.map { group ->
if (eligibleGroupIds != null && group.addonId !in eligibleGroupIds) return@map group
var changed = false
val updatedStreams = group.streams.map { stream ->
if (stream.preparationKey() == key) {

View file

@ -7,10 +7,13 @@ import com.nuvio.app.features.streams.StreamItem
import kotlinx.coroutines.CancellationException
object TorboxAvailabilityService {
fun markChecking(groups: List<AddonStreamGroup>): List<AddonStreamGroup> {
fun markChecking(
groups: List<AddonStreamGroup>,
eligibleGroupIds: Set<String>? = null,
): List<AddonStreamGroup> {
val settings = DebridSettingsRepository.snapshot()
if (!settings.enabled || settings.torboxApiKey.isBlank()) return groups
return groups.updateAvailabilityStatus { stream ->
return groups.updateAvailabilityStatus(eligibleGroupIds) { stream ->
if (stream.torboxAvailabilityHash() == null || stream.debridCacheStatus?.state == StreamDebridCacheState.CACHED) {
stream
} else {
@ -25,18 +28,27 @@ object TorboxAvailabilityService {
}
}
suspend fun annotateCachedAvailability(groups: List<AddonStreamGroup>): List<AddonStreamGroup> {
suspend fun annotateCachedAvailability(
groups: List<AddonStreamGroup>,
eligibleGroupIds: Set<String>? = null,
): 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() } }
.filter { group -> eligibleGroupIds == null || group.addonId in eligibleGroupIds }
.flatMap { group ->
group.streams.mapNotNull { stream ->
stream.torboxAvailabilityHash()
?.takeUnless { stream.debridCacheStatus?.state in FINAL_CACHE_STATES }
}
}
.distinct()
if (hashes.isEmpty()) return groups
val cached = checkCached(apiKey = apiKey, hashes = hashes)
?: return groups.updateAvailabilityStatus { stream ->
?: return groups.updateAvailabilityStatus(eligibleGroupIds) { stream ->
val hash = stream.torboxAvailabilityHash()
if (hash == null) {
stream
@ -51,8 +63,9 @@ object TorboxAvailabilityService {
}
}
return groups.updateAvailabilityStatus { stream ->
return groups.updateAvailabilityStatus(eligibleGroupIds) { stream ->
val hash = stream.torboxAvailabilityHash() ?: return@updateAvailabilityStatus stream
if (stream.debridCacheStatus?.state in FINAL_CACHE_STATES) return@updateAvailabilityStatus stream
val cachedItem = cached[hash]
stream.copy(
debridCacheStatus = StreamDebridCacheStatus(
@ -91,16 +104,23 @@ object TorboxAvailabilityService {
}
}
private val FINAL_CACHE_STATES = setOf(
StreamDebridCacheState.CACHED,
StreamDebridCacheState.NOT_CACHED,
)
internal fun StreamItem.torboxAvailabilityHash(): String? =
infoHash
?.trim()
?.lowercase()
?.takeIf { needsLocalDebridResolve && it.isNotBlank() }
?.takeIf { isInstalledAddonStream && needsLocalDebridResolve && it.isNotBlank() }
private fun List<AddonStreamGroup>.updateAvailabilityStatus(
eligibleGroupIds: Set<String>?,
transform: (StreamItem) -> StreamItem,
): List<AddonStreamGroup> =
map { group ->
if (eligibleGroupIds != null && group.addonId !in eligibleGroupIds) return@map group
var changed = false
val updatedStreams = group.streams.map { stream ->
val updated = transform(stream)

View file

@ -80,6 +80,7 @@ import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.streams.AddonStreamWarmupRepository
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.trakt.TraktAuthRepository
@ -372,6 +373,29 @@ fun MetaDetailsScreen(
seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId
}
val hasEpisodes = meta.videos.any { it.season != null || it.episode != null }
val debridWarmupTarget = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction) {
if (meta.isSeriesLikeForDebridWarmup(hasEpisodes)) {
DetailDebridWarmupTarget(
videoId = seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id,
season = seriesAction?.seasonNumber,
episode = seriesAction?.episodeNumber,
)
} else {
DetailDebridWarmupTarget(
videoId = meta.id,
season = null,
episode = null,
)
}
}
LaunchedEffect(meta.type, debridWarmupTarget) {
AddonStreamWarmupRepository.preload(
type = meta.type,
videoId = debridWarmupTarget.videoId,
season = debridWarmupTarget.season,
episode = debridWarmupTarget.episode,
)
}
val hasProductionSection = remember(meta) {
meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty()
}
@ -1259,3 +1283,14 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp =
} else {
(maxWidth * 0.6f).coerceIn(520.dp, 680.dp)
}
private data class DetailDebridWarmupTarget(
val videoId: String,
val season: Int?,
val episode: Int?,
)
private fun MetaDetails.isSeriesLikeForDebridWarmup(hasEpisodes: Boolean): Boolean =
hasEpisodes || type.equals("series", ignoreCase = true) ||
type.equals("show", ignoreCase = true) ||
type.equals("tv", ignoreCase = true)

View file

@ -14,6 +14,7 @@ import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.plugins.pluginContentId
import com.nuvio.app.features.plugins.PluginRuntimeResult
import com.nuvio.app.features.plugins.PluginScraper
import com.nuvio.app.features.streams.AddonStreamWarmupRepository
import com.nuvio.app.features.streams.AddonStreamGroup
import com.nuvio.app.features.streams.StreamAutoPlaySelector
import com.nuvio.app.features.streams.StreamItem
@ -203,8 +204,13 @@ object PlayerStreamsRepository {
}
val installedAddonOrder = streamAddons.map { it.addonName }
val warmedAddonGroups = AddonStreamWarmupRepository
.cachedGroups(type = type, videoId = videoId, season = season, episode = episode)
.orEmpty()
.associateBy { it.addonId }
val warmedAddonIds = warmedAddonGroups.keys
val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon ->
AddonStreamGroup(
warmedAddonGroups[addon.addonId] ?: AddonStreamGroup(
addonName = addon.addonName,
addonId = addon.addonId,
streams = emptyList(),
@ -218,14 +224,17 @@ object PlayerStreamsRepository {
isLoading = true,
)
}, installedAddonOrder)
val isInitiallyLoading = initialGroups.any { it.isLoading }
stateFlow.value = StreamsUiState(
groups = initialGroups,
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
isAnyLoading = true,
isAnyLoading = isInitiallyLoading,
)
val job = scope.launch {
val addonJobs = streamAddons.map { addon ->
val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds }
val installedAddonIds = streamAddons.map { it.addonId }.toSet()
val addonJobs = pendingStreamAddons.map { addon ->
async {
val url = buildAddonResourceUrl(
manifestUrl = addon.manifest.transportUrl,
@ -316,9 +325,15 @@ object PlayerStreamsRepository {
}
if (!debridPreparationLaunched) {
debridPreparationLaunched = true
val checkingGroups = TorboxAvailabilityService.markChecking(stateFlow.value.groups)
val checkingGroups = TorboxAvailabilityService.markChecking(
groups = stateFlow.value.groups,
eligibleGroupIds = installedAddonIds,
)
stateFlow.update { current -> current.copy(groups = checkingGroups) }
val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability(stateFlow.value.groups)
val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability(
groups = stateFlow.value.groups,
eligibleGroupIds = installedAddonIds,
)
val presentedGroups = DebridStreamPresentation.apply(
groups = availabilityGroups,
settings = DebridSettingsRepository.snapshot(),
@ -326,7 +341,9 @@ object PlayerStreamsRepository {
stateFlow.update { current -> current.copy(groups = presentedGroups) }
launch {
DirectDebridStreamPreparer.prepare(
streams = stateFlow.value.groups.flatMap { it.streams },
streams = stateFlow.value.groups
.filter { it.addonId in installedAddonIds }
.flatMap { it.streams },
season = season,
episode = episode,
playerSettings = playerSettings,
@ -338,6 +355,7 @@ object PlayerStreamsRepository {
groups = current.groups,
original = original,
prepared = prepared,
eligibleGroupIds = installedAddonIds,
),
)
}

View file

@ -351,7 +351,7 @@ private fun templatePreview(value: String): String {
.lineSequence()
.map { it.trim() }
.firstOrNull { it.isNotBlank() }
?: return ""
?: return "Addon default"
return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..."
}

View file

@ -0,0 +1,304 @@
package com.nuvio.app.features.streams
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonManifest
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.debrid.DebridSettings
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.debrid.DebridStreamPresentation
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
import com.nuvio.app.features.debrid.TorboxAvailabilityService
import com.nuvio.app.features.player.PlayerSettingsRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
private const val ADDON_STREAM_WARMUP_CACHE_TTL_MS = 5L * 60L * 1000L
object AddonStreamWarmupRepository {
private val log = Logger.withTag("AddonStreamWarmup")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val mutex = Mutex()
private val cache = mutableMapOf<AddonStreamWarmupKey, CachedAddonStreamWarmup>()
private val inFlight = mutableMapOf<AddonStreamWarmupKey, Deferred<List<AddonStreamGroup>>>()
fun preload(type: String, videoId: String, season: Int? = null, episode: Int? = null) {
val key = currentKey(type = type, videoId = videoId, season = season, episode = episode) ?: return
scope.launch {
runCatching { fetchWarmup(key) }
.onFailure { error ->
if (error is CancellationException) throw error
log.d(error) { "Addon stream warmup failed" }
}
}
}
fun cachedGroups(type: String, videoId: String, season: Int? = null, episode: Int? = null): List<AddonStreamGroup>? {
val key = currentKey(type = type, videoId = videoId, season = season, episode = episode) ?: return null
if (!mutex.tryLock()) return null
return try {
cachedGroupsLocked(key)
} finally {
mutex.unlock()
}
}
private suspend fun fetchWarmup(key: AddonStreamWarmupKey): List<AddonStreamGroup> {
cachedGroups(key.type, key.videoId, key.season, key.episode)?.let { return it }
var ownsFetch = false
val newFetch = scope.async(start = CoroutineStart.LAZY) {
fetchWarmupUncached(key)
}
val activeFetch = mutex.withLock {
cachedGroupsLocked(key)?.let { cached ->
return@withLock null to cached
}
val existing = inFlight[key]
if (existing != null) {
existing to null
} else {
inFlight[key] = newFetch
ownsFetch = true
newFetch to null
}
}
activeFetch.second?.let {
newFetch.cancel()
return it
}
val deferred = activeFetch.first ?: return emptyList()
if (!ownsFetch) newFetch.cancel()
if (ownsFetch) deferred.start()
return try {
val result = deferred.await()
val cacheableGroups = result.filter { it.streams.isNotEmpty() }
if (ownsFetch && cacheableGroups.isNotEmpty()) {
mutex.withLock {
cache[key] = CachedAddonStreamWarmup(
groups = cacheableGroups,
createdAtMs = epochMs(),
)
}
}
result
} finally {
if (ownsFetch) {
mutex.withLock {
if (inFlight[key] === deferred) {
inFlight.remove(key)
}
}
}
}
}
private suspend fun fetchWarmupUncached(key: AddonStreamWarmupKey): List<AddonStreamGroup> {
val targets = key.addonTargets
if (targets.isEmpty()) return emptyList()
val orderedGroups = coroutineScope {
targets.map { target ->
async {
fetchAddonStreams(
target = target,
type = key.type,
videoId = key.videoId,
)
}
}.awaitAll()
}.let { groups ->
StreamAutoPlaySelector.orderAddonStreams(
groups = groups,
installedOrder = targets.map { it.addonName },
)
}
val addonIds = targets.map { it.addonId }.toSet()
val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability(
groups = TorboxAvailabilityService.markChecking(
groups = orderedGroups,
eligibleGroupIds = addonIds,
),
eligibleGroupIds = addonIds,
)
var preparedGroups = DebridStreamPresentation.apply(
groups = availabilityGroups,
settings = key.settings,
)
PlayerSettingsRepository.ensureLoaded()
DirectDebridStreamPreparer.prepare(
streams = preparedGroups.flatMap { it.streams },
season = key.season,
episode = key.episode,
playerSettings = PlayerSettingsRepository.uiState.value,
installedAddonNames = targets.map { it.addonName }.toSet(),
) { original, prepared ->
preparedGroups = DirectDebridStreamPreparer.replacePreparedStream(
groups = preparedGroups,
original = original,
prepared = prepared,
eligibleGroupIds = addonIds,
)
}
return preparedGroups
}
private suspend fun fetchAddonStreams(
target: AddonStreamWarmupTarget,
type: String,
videoId: String,
): AddonStreamGroup {
val url = buildAddonResourceUrl(
manifestUrl = target.manifest.transportUrl,
resource = "stream",
type = type,
id = videoId,
)
return runCatchingUnlessCancelled {
val payload = httpGetText(url)
StreamParser.parse(
payload = payload,
addonName = target.addonName,
addonId = target.addonId,
)
}.fold(
onSuccess = { streams ->
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = streams,
isLoading = false,
)
},
onFailure = { error ->
log.d(error) { "Failed to warm addon stream target ${target.addonName}" }
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = emptyList(),
isLoading = false,
error = error.message,
)
},
)
}
private fun currentKey(type: String, videoId: String, season: Int?, episode: Int?): AddonStreamWarmupKey? {
val normalizedType = type.trim().lowercase()
val normalizedVideoId = videoId.trim()
if (normalizedType.isBlank() || normalizedVideoId.isBlank()) return null
DebridSettingsRepository.ensureLoaded()
val settings = DebridSettingsRepository.snapshot()
if (!settings.enabled || settings.torboxApiKey.isBlank()) return null
AddonRepository.initialize()
val addonTargets = AddonRepository.uiState.value.addons
.mapNotNull { addon -> addon.toWarmupTarget(normalizedType, normalizedVideoId) }
if (addonTargets.isEmpty()) return null
return AddonStreamWarmupKey(
type = normalizedType,
videoId = normalizedVideoId,
season = season,
episode = episode,
addonFingerprint = addonTargets.joinToString("|") { it.fingerprint },
settingsFingerprint = settings.warmupFingerprint(),
settings = settings,
addonTargets = addonTargets,
)
}
private fun cachedGroupsLocked(key: AddonStreamWarmupKey): List<AddonStreamGroup>? {
val cached = cache[key] ?: return null
val age = epochMs() - cached.createdAtMs
return if (age in 0..ADDON_STREAM_WARMUP_CACHE_TTL_MS) {
cached.groups
} else {
cache.remove(key)
null
}
}
}
private data class AddonStreamWarmupKey(
val type: String,
val videoId: String,
val season: Int?,
val episode: Int?,
val addonFingerprint: String,
val settingsFingerprint: String,
val settings: DebridSettings,
val addonTargets: List<AddonStreamWarmupTarget>,
)
private data class AddonStreamWarmupTarget(
val addonName: String,
val addonId: String,
val manifest: AddonManifest,
val fingerprint: String,
)
private data class CachedAddonStreamWarmup(
val groups: List<AddonStreamGroup>,
val createdAtMs: Long,
)
private fun ManagedAddon.toWarmupTarget(type: String, videoId: String): AddonStreamWarmupTarget? {
val manifest = manifest ?: return null
val supportsRequestedStream = manifest.resources.any { resource ->
resource.name == "stream" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { videoId.startsWith(it) })
}
if (!supportsRequestedStream) return null
val addonName = displayTitle.ifBlank { manifest.name }
return AddonStreamWarmupTarget(
addonName = addonName,
addonId = "addon:${manifest.id}:$manifestUrl",
manifest = manifest,
fingerprint = "$manifestUrl:${manifest.id}:${manifest.version}:$addonName",
)
}
private fun DebridSettings.warmupFingerprint(): String =
listOf(
enabled,
torboxApiKey,
instantPlaybackPreparationLimit,
streamMaxResults,
streamSortMode,
streamMinimumQuality,
streamDolbyVisionFilter,
streamHdrFilter,
streamCodecFilter,
streamPreferences,
streamNameTemplate,
streamDescriptionTemplate,
).joinToString("|")
private suspend fun <T> runCatchingUnlessCancelled(block: suspend () -> T): Result<T> =
try {
Result.success(block())
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
Result.failure(error)
}

View file

@ -17,7 +17,7 @@ object StreamAutoPlaySelector {
val (directDebridEntries, remainingEntries) = groups.partition { group ->
group.addonId.startsWith("debrid:") ||
group.streams.any { stream -> stream.isDirectDebridStream }
group.streams.any { stream -> stream.isAddonDebridCandidate && stream.isDirectDebridStream }
}
if (installedOrder.isEmpty()) return directDebridEntries + remainingEntries
@ -117,5 +117,6 @@ object StreamAutoPlaySelector {
}
private fun StreamItem.isAutoPlayable(debridEnabled: Boolean): Boolean =
playableDirectUrl != null || (debridEnabled && (isDirectDebridStream || isCachedDebridTorrentStream))
playableDirectUrl != null ||
(debridEnabled && isAddonDebridCandidate && (isDirectDebridStream || isCachedDebridTorrentStream))
}

View file

@ -40,6 +40,9 @@ data class StreamItem(
val isDirectDebridStream: Boolean
get() = clientResolve?.isDirectDebridCandidate == true
val isInstalledAddonStream: Boolean
get() = addonId.startsWith("addon:")
val isTorrentStream: Boolean
get() = !isDirectDebridStream && (
!infoHash.isNullOrBlank() ||
@ -53,6 +56,9 @@ data class StreamItem(
val needsLocalDebridResolve: Boolean
get() = isTorrentStream && playableDirectUrl == null
val isAddonDebridCandidate: Boolean
get() = isInstalledAddonStream && (needsLocalDebridResolve || isDirectDebridStream)
val hasPlayableSource: Boolean
get() = url != null || infoHash != null || externalUrl != null || clientResolve != null
}
@ -61,7 +67,7 @@ private fun String?.isMagnetLink(): Boolean =
this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true
fun StreamItem.isSelectableForPlayback(debridEnabled: Boolean): Boolean =
playableDirectUrl != null || (debridEnabled && (needsLocalDebridResolve || isDirectDebridStream))
playableDirectUrl != null || (debridEnabled && isAddonDebridCandidate)
data class StreamBehaviorHints(
val bingeGroup: String? = null,

View file

@ -187,8 +187,13 @@ object StreamsRepository {
// Initialise loading placeholders
val installedAddonOrder = streamAddons.map { it.addonName }
val warmedAddonGroups = AddonStreamWarmupRepository
.cachedGroups(type = type, videoId = videoId, season = season, episode = episode)
.orEmpty()
.associateBy { it.addonId }
val warmedAddonIds = warmedAddonGroups.keys
val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon ->
AddonStreamGroup(
warmedAddonGroups[addon.addonId] ?: AddonStreamGroup(
addonName = addon.addonName,
addonId = addon.addonId,
streams = emptyList(),
@ -202,26 +207,29 @@ object StreamsRepository {
isLoading = true,
)
}, installedAddonOrder)
val isInitiallyLoading = initialGroups.any { it.isLoading }
_uiState.value = StreamsUiState(
requestToken = requestToken,
groups = initialGroups,
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
isAnyLoading = true,
isAnyLoading = isInitiallyLoading,
emptyStateReason = null,
isDirectAutoPlayFlow = isDirectAutoPlayFlow,
showDirectAutoPlayOverlay = isDirectAutoPlayFlow,
)
activeJob = scope.launch {
val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds }
val completions = Channel<StreamLoadCompletion>(capacity = Channel.BUFFERED)
val pluginRemainingByAddonId = pluginProviderGroups
.associate { it.addonId to it.scrapers.size }
.toMutableMap()
val pluginFirstErrorByAddonId = mutableMapOf<String, String>()
val totalTasks = streamAddons.size +
val totalTasks = pendingStreamAddons.size +
pluginProviderGroups.sumOf { it.scrapers.size }
val installedAddonNames = installedAddonOrder.toSet()
val installedAddonIds = streamAddons.map { it.addonId }.toSet()
var autoSelectTriggered = false
var timeoutElapsed = false
var debridPreparationLaunched = false
@ -273,7 +281,7 @@ object StreamsRepository {
null
}
streamAddons.forEach { addon ->
pendingStreamAddons.forEach { addon ->
launch {
val url = buildAddonResourceUrl(
manifestUrl = addon.manifest.transportUrl,
@ -425,9 +433,15 @@ object StreamsRepository {
if (!debridPreparationLaunched) {
debridPreparationLaunched = true
val checkingGroups = TorboxAvailabilityService.markChecking(_uiState.value.groups)
val checkingGroups = TorboxAvailabilityService.markChecking(
groups = _uiState.value.groups,
eligibleGroupIds = installedAddonIds,
)
_uiState.update { current -> current.copy(groups = checkingGroups) }
val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability(_uiState.value.groups)
val availabilityGroups = TorboxAvailabilityService.annotateCachedAvailability(
groups = _uiState.value.groups,
eligibleGroupIds = installedAddonIds,
)
val presentedGroups = DebridStreamPresentation.apply(
groups = availabilityGroups,
settings = debridSettings,
@ -435,7 +449,9 @@ object StreamsRepository {
_uiState.update { current -> current.copy(groups = presentedGroups) }
launch {
DirectDebridStreamPreparer.prepare(
streams = _uiState.value.groups.flatMap { it.streams },
streams = _uiState.value.groups
.filter { it.addonId in installedAddonIds }
.flatMap { it.streams },
season = season,
episode = episode,
playerSettings = playerSettings,
@ -447,6 +463,7 @@ object StreamsRepository {
groups = current.groups,
original = original,
prepared = prepared,
eligibleGroupIds = installedAddonIds,
),
)
}

View file

@ -3,9 +3,12 @@ package com.nuvio.app.features.streams
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@ -85,6 +88,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.features.debrid.DebridProviders
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository
@ -221,6 +225,7 @@ fun StreamsScreen(
episodeTitle = episodeTitle,
uiState = uiState,
debridEnabled = debridSettings.enabled,
appendInstantServiceToDefaultName = !debridSettings.hasCustomStreamFormatting,
resumePositionMs = effectiveResumePositionMs,
resumeProgressFraction = effectiveResumeProgressFraction,
onStreamSelected = { stream, positionMs, progressFraction ->
@ -239,6 +244,7 @@ fun StreamsScreen(
episodeTitle = episodeTitle,
uiState = uiState,
debridEnabled = debridSettings.enabled,
appendInstantServiceToDefaultName = !debridSettings.hasCustomStreamFormatting,
resumePositionMs = effectiveResumePositionMs,
resumeProgressFraction = effectiveResumeProgressFraction,
onStreamSelected = { stream, positionMs, progressFraction ->
@ -385,6 +391,7 @@ private fun MobileStreamsLayout(
episodeTitle: String?,
uiState: StreamsUiState,
debridEnabled: Boolean,
appendInstantServiceToDefaultName: Boolean,
resumePositionMs: Long?,
resumeProgressFraction: Float?,
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
@ -466,6 +473,7 @@ private fun MobileStreamsLayout(
StreamList(
uiState = uiState,
debridEnabled = debridEnabled,
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
onStreamSelected = onStreamSelected,
onStreamLongPress = onStreamLongPress,
resumePositionMs = resumePositionMs,
@ -760,6 +768,7 @@ private fun FilterChip(
internal fun StreamList(
uiState: StreamsUiState,
debridEnabled: Boolean,
appendInstantServiceToDefaultName: Boolean,
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
onStreamLongPress: (StreamItem) -> Unit,
resumePositionMs: Long?,
@ -799,6 +808,7 @@ internal fun StreamList(
group = group,
showHeader = uiState.selectedFilter == null,
debridEnabled = debridEnabled,
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
onStreamSelected = onStreamSelected,
onStreamLongPress = onStreamLongPress,
resumePositionMs = resumePositionMs,
@ -823,6 +833,7 @@ private fun LazyListScope.streamSection(
group: AddonStreamGroup,
showHeader: Boolean,
debridEnabled: Boolean,
appendInstantServiceToDefaultName: Boolean,
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
onStreamLongPress: (StreamItem) -> Unit,
resumePositionMs: Long?,
@ -867,6 +878,7 @@ private fun LazyListScope.streamSection(
StreamCard(
stream = stream,
enabled = stream.isSelectableForPlayback(debridEnabled),
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
onClick = {
if (stream.isSelectableForPlayback(debridEnabled)) {
onStreamSelected(stream, resumePositionMs, resumeProgressFraction)
@ -971,6 +983,7 @@ private fun StreamSourceHeader(
private fun StreamCard(
stream: StreamItem,
enabled: Boolean,
appendInstantServiceToDefaultName: Boolean,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
@ -997,15 +1010,9 @@ private fun StreamCard(
verticalAlignment = Alignment.Top,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stream.streamLabel,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
color = MaterialTheme.colorScheme.onSurface,
StreamNameWithInstantService(
stream = stream,
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
)
val subtitle = stream.streamSubtitle
@ -1022,14 +1029,68 @@ private fun StreamCard(
}
Spacer(modifier = Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
StreamAvailabilityBadge(stream = stream)
Row(verticalAlignment = Alignment.CenterVertically) {
StreamFileSizeBadge(stream = stream)
}
}
}
}
@Composable
private fun StreamNameWithInstantService(
stream: StreamItem,
appendInstantServiceToDefaultName: Boolean,
) {
val nameStyle = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
lineHeight = 20.sp,
letterSpacing = 0.sp,
)
val instantLabel = if (appendInstantServiceToDefaultName) {
stream.instantServiceLabel()
} else {
null
}
val showInstantLabel = instantLabel != null
val visibleState = remember(stream.streamLabel) {
MutableTransitionState(showInstantLabel)
}
visibleState.targetState = showInstantLabel
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stream.streamLabel,
modifier = Modifier.weight(1f, fill = false),
style = nameStyle,
color = MaterialTheme.colorScheme.onSurface,
)
AnimatedVisibility(
visibleState = visibleState,
enter = fadeIn(animationSpec = tween(durationMillis = 260)) +
expandHorizontally(
animationSpec = tween(durationMillis = 260),
expandFrom = Alignment.Start,
),
exit = fadeOut(animationSpec = tween(durationMillis = 120)) +
shrinkHorizontally(
animationSpec = tween(durationMillis = 120),
shrinkTowards = Alignment.Start,
),
label = "streamNameInstantService",
) {
Text(
text = " ${instantLabel.orEmpty()}",
style = nameStyle,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StreamActionsSheet(
@ -1128,48 +1189,13 @@ private fun StreamActionsSheet(
}
}
@Composable
private fun StreamAvailabilityBadge(stream: StreamItem) {
val status = stream.debridCacheStatus ?: return
val (label, background, foreground) = when (status.state) {
StreamDebridCacheState.CHECKING -> Triple(
"${status.providerName} checking",
MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
MaterialTheme.colorScheme.primary,
)
StreamDebridCacheState.CACHED -> Triple(
"${status.providerName} ready",
Color(0xFF123D2B),
Color(0xFFB8F6D3),
)
StreamDebridCacheState.NOT_CACHED -> Triple(
"${status.providerName} not cached",
Color(0xFF3D2424),
Color(0xFFFFC9C9),
)
StreamDebridCacheState.UNKNOWN -> Triple(
"${status.providerName} unknown",
Color(0xFF2C3033),
MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(background)
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.2.sp,
),
color = foreground,
)
}
private fun StreamItem.instantServiceLabel(): String? {
val status = debridCacheStatus ?: return null
if (status.state != StreamDebridCacheState.CACHED) return null
val providerLabel = DebridProviders.shortName(status.providerId)
.ifBlank { status.providerName.trim() }
.ifBlank { DebridProviders.displayName(status.providerId) }
return "- $providerLabel Instant"
}
@Composable

View file

@ -61,6 +61,7 @@ internal fun TabletStreamsLayout(
episodeTitle: String?,
uiState: StreamsUiState,
debridEnabled: Boolean,
appendInstantServiceToDefaultName: Boolean,
resumePositionMs: Long?,
resumeProgressFraction: Float?,
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit,
@ -201,6 +202,7 @@ internal fun TabletStreamsLayout(
StreamList(
uiState = uiState,
debridEnabled = debridEnabled,
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
onStreamSelected = onStreamSelected,
onStreamLongPress = onStreamLongPress,
resumePositionMs = resumePositionMs,

View file

@ -75,13 +75,45 @@ class DebridStreamPresentationTest {
),
).single().streams
assertEquals(listOf("4K TB Ready", "FHD TB Ready", "Resolved addon URL"), presented.map { it.name })
assertEquals(listOf("Large", "Mid", "Resolved addon URL"), presented.map { it.name })
}
@Test
fun `hides addon torrent streams that are not cached`() {
val cached = localTorboxStream(
name = "Cached",
filename = "Movie.1080p.WEB-DL.HEVC-GRP.mkv",
size = 10_000_000_000,
)
val uncached = localTorboxStream(
name = "Uncached",
filename = "Movie.2160p.WEB-DL.HEVC-GRP.mkv",
size = 20_000_000_000,
cacheState = StreamDebridCacheState.NOT_CACHED,
)
val presented = DebridStreamPresentation.apply(
groups = listOf(
AddonStreamGroup(
addonName = "Addon",
addonId = "addon:test",
streams = listOf(cached, uncached),
),
),
settings = DebridSettings(
enabled = true,
torboxApiKey = "key",
),
).single().streams
assertEquals(listOf("Cached"), presented.map { it.name })
}
private fun localTorboxStream(
name: String = "Torrent",
filename: String,
size: Long,
cacheState: StreamDebridCacheState = StreamDebridCacheState.CACHED,
): StreamItem =
StreamItem(
name = name,
@ -95,7 +127,7 @@ class DebridStreamPresentationTest {
debridCacheStatus = StreamDebridCacheStatus(
providerId = DebridProviders.TORBOX_ID,
providerName = DebridProviders.Torbox.displayName,
state = StreamDebridCacheState.CACHED,
state = cacheState,
cachedName = filename,
cachedSize = size,
),