feat(streams): adjust autoplay stream selection for cloud services

This commit is contained in:
tapframe 2026-05-21 15:47:06 +05:30
parent 800d7160b1
commit 914f4147e9
5 changed files with 246 additions and 37 deletions

View file

@ -1571,9 +1571,11 @@ private fun MainAppContent(
) {
is DirectDebridPlayableResult.Success -> resolved.stream
else -> {
resolved.toastMessage()?.let { NuvioToastController.show(it) }
StreamsRepository.consumeAutoPlay()
if (resolved == DirectDebridPlayableResult.Stale) {
val hasNextCandidate = StreamsRepository.skipAutoPlayStream(selectedStream)
if (!hasNextCandidate) {
resolved.toastMessage()?.let { NuvioToastController.show(it) }
}
if (!hasNextCandidate && resolved == DirectDebridPlayableResult.Stale) {
StreamsRepository.reload(
type = launch.type,
videoId = effectiveVideoId,
@ -1588,7 +1590,11 @@ private fun MainAppContent(
} else {
selectedStream
}
val sourceUrl = stream.playableDirectUrl ?: return@LaunchedEffect
val sourceUrl = stream.playableDirectUrl
if (sourceUrl == null) {
StreamsRepository.skipAutoPlayStream(selectedStream)
return@LaunchedEffect
}
autoPlayHandled = true
if (playerSettings.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey(

View file

@ -41,8 +41,36 @@ object StreamAutoPlaySelector {
preferredBingeGroup: String? = null,
preferBingeGroupInSelection: Boolean = false,
debridEnabled: Boolean = true,
): StreamItem? {
if (streams.isEmpty()) return null
activeResolverProviderId: String? = null,
): StreamItem? =
evaluateAutoPlayStream(
streams = streams,
mode = mode,
regexPattern = regexPattern,
source = source,
installedAddonNames = installedAddonNames,
selectedAddons = selectedAddons,
selectedPlugins = selectedPlugins,
preferredBingeGroup = preferredBingeGroup,
preferBingeGroupInSelection = preferBingeGroupInSelection,
debridEnabled = debridEnabled,
activeResolverProviderId = activeResolverProviderId,
).stream
fun evaluateAutoPlayStream(
streams: List<StreamItem>,
mode: StreamAutoPlayMode,
regexPattern: String,
source: StreamAutoPlaySource,
installedAddonNames: Set<String>,
selectedAddons: Set<String>,
selectedPlugins: Set<String>,
preferredBingeGroup: String? = null,
preferBingeGroupInSelection: Boolean = false,
debridEnabled: Boolean = true,
activeResolverProviderId: String? = null,
): StreamAutoPlayEvaluation {
if (streams.isEmpty()) return StreamAutoPlayEvaluation()
val sourceScopedStreams = when (source) {
StreamAutoPlaySource.ALL_SOURCES -> streams
@ -57,25 +85,26 @@ object StreamAutoPlaySelector {
selectedPlugins.isEmpty() || stream.addonName in selectedPlugins
}
}
if (candidateStreams.isEmpty()) return null
if (mode == StreamAutoPlayMode.MANUAL) return null
if (candidateStreams.isEmpty()) return StreamAutoPlayEvaluation()
if (mode == StreamAutoPlayMode.MANUAL) return StreamAutoPlayEvaluation()
val targetBingeGroup = preferredBingeGroup?.trim().orEmpty()
if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
val bingeGroupMatch = candidateStreams.firstOrNull { stream ->
stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable(debridEnabled)
val preferredReadyStream = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
candidateStreams.firstOrNull { stream ->
stream.behaviorHints.bingeGroup == targetBingeGroup &&
stream.isAutoPlayable(debridEnabled, activeResolverProviderId)
}
if (bingeGroupMatch != null) return bingeGroupMatch
} else {
null
}
return when (mode) {
StreamAutoPlayMode.MANUAL -> null
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.isAutoPlayable(debridEnabled) }
val matchingStreams = when (mode) {
StreamAutoPlayMode.MANUAL -> emptyList()
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams
StreamAutoPlayMode.REGEX_MATCH -> {
val pattern = regexPattern.trim()
val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull()
?: return null
?: return StreamAutoPlayEvaluation()
val exclusionMatches = Regex("\\(\\?![^)]*?\\(([^)]+)\\)").findAll(pattern)
@ -89,8 +118,7 @@ object StreamAutoPlaySelector {
Regex("\\b(${exclusionWords.joinToString("|")})\\b", RegexOption.IGNORE_CASE)
} else null
val matchingStreams = candidateStreams.filter { stream ->
if (!stream.isAutoPlayable(debridEnabled)) return@filter false
candidateStreams.filter { stream ->
val url = stream.playableDirectUrl.orEmpty()
val searchableText = buildString {
@ -109,14 +137,65 @@ object StreamAutoPlaySelector {
true
}
if (matchingStreams.isEmpty()) return null
matchingStreams.firstOrNull { it.isAutoPlayable(debridEnabled) }
}
}
if (matchingStreams.isEmpty() && preferredReadyStream == null) return StreamAutoPlayEvaluation()
val readyStreams = buildList {
preferredReadyStream?.let(::add)
matchingStreams
.filter { it.isAutoPlayable(debridEnabled, activeResolverProviderId) }
.filterNot { it == preferredReadyStream }
.forEach(::add)
}
val selected = readyStreams.firstOrNull()
if (selected != null) {
return StreamAutoPlayEvaluation(
stream = selected,
readyStreams = readyStreams,
)
}
return StreamAutoPlayEvaluation(
readyStreams = readyStreams,
hasPendingDebridCandidate = matchingStreams.any {
it.isPendingDebridAutoPlay(debridEnabled, activeResolverProviderId)
},
)
}
private fun StreamItem.isAutoPlayable(debridEnabled: Boolean): Boolean =
private fun StreamItem.isAutoPlayable(
debridEnabled: Boolean,
activeResolverProviderId: String?,
): Boolean =
playableDirectUrl != null ||
(debridEnabled && isAddonDebridCandidate && (isDirectDebridStream || isCachedDebridTorrentStream))
(debridEnabled && isAddonDebridCandidate && isReadyDebridAutoPlay(activeResolverProviderId))
private fun StreamItem.isReadyDebridAutoPlay(activeResolverProviderId: String?): Boolean =
when {
isDirectDebridStream -> clientResolve?.service.matchesResolver(activeResolverProviderId)
isCachedDebridTorrentStream -> debridCacheStatus?.providerId.matchesResolver(activeResolverProviderId)
else -> false
}
private fun StreamItem.isPendingDebridAutoPlay(
debridEnabled: Boolean,
activeResolverProviderId: String?,
): Boolean {
if (!debridEnabled || !isInstalledAddonStream || !needsLocalDebridResolve) return false
if (!debridCacheStatus?.providerId.matchesResolver(activeResolverProviderId)) return false
val state = debridCacheStatus?.state
return state == null || state == StreamDebridCacheState.CHECKING
}
private fun String?.matchesResolver(activeResolverProviderId: String?): Boolean {
val active = activeResolverProviderId?.trim().orEmpty()
return active.isBlank() || this == null || equals(active, ignoreCase = true)
}
}
data class StreamAutoPlayEvaluation(
val stream: StreamItem? = null,
val readyStreams: List<StreamItem> = emptyList(),
val hasPendingDebridCandidate: Boolean = false,
)

View file

@ -186,6 +186,7 @@ data class StreamsUiState(
val isAnyLoading: Boolean = false,
val emptyStateReason: StreamsEmptyStateReason? = null,
val autoPlayStream: StreamItem? = null,
val autoPlayCandidates: List<StreamItem> = emptyList(),
val isDirectAutoPlayFlow: Boolean = false,
val showDirectAutoPlayOverlay: Boolean = false,
) {

View file

@ -232,7 +232,6 @@ object StreamsRepository {
val installedAddonIds = streamAddons.map { it.addonId }.toSet()
val debridAvailabilityJobs = mutableListOf<Job>()
var autoSelectTriggered = false
var timeoutElapsed = false
fun publishCompletion(completion: StreamLoadCompletion) {
if (completions.trySend(completion).isFailure) {
log.d { "Ignoring late stream load completion after channel close" }
@ -286,12 +285,10 @@ object StreamsRepository {
if (timeoutMs > 0L && playerSettings.streamAutoPlayTimeoutSeconds < 11) {
launch {
delay(timeoutMs)
timeoutElapsed = true
if (!autoSelectTriggered) {
val allStreams = _uiState.value.groups.flatMap { it.streams }
if (allStreams.isNotEmpty()) {
autoSelectTriggered = true
val selected = StreamAutoPlaySelector.selectAutoPlayStream(
val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream(
streams = allStreams,
mode = autoPlayMode,
regexPattern = playerSettings.streamAutoPlayRegex,
@ -300,9 +297,18 @@ object StreamsRepository {
selectedAddons = playerSettings.streamAutoPlaySelectedAddons,
selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins,
debridEnabled = debridSettings.canResolvePlayableLinks,
activeResolverProviderId = debridSettings.activeResolverProviderId,
)
_uiState.update { it.copy(autoPlayStream = selected) }
if (selected == null) {
if (evaluation.stream != null || !evaluation.hasPendingDebridCandidate) {
autoSelectTriggered = true
_uiState.update {
it.copy(
autoPlayStream = evaluation.stream,
autoPlayCandidates = evaluation.readyStreams,
)
}
}
if (evaluation.stream == null && !evaluation.hasPendingDebridCandidate) {
_uiState.update {
it.copy(
isDirectAutoPlayFlow = false,
@ -313,9 +319,6 @@ object StreamsRepository {
}
}
}
} else if (timeoutMs <= 0L) {
timeoutElapsed = true
null
} else {
null
}
@ -490,7 +493,7 @@ object StreamsRepository {
if (isAutoPlayEnabled && !autoSelectTriggered) {
autoSelectTriggered = true
val allStreams = _uiState.value.groups.flatMap { it.streams }
val selected = StreamAutoPlaySelector.selectAutoPlayStream(
val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream(
streams = allStreams,
mode = autoPlayMode,
regexPattern = playerSettings.streamAutoPlayRegex,
@ -499,8 +502,14 @@ object StreamsRepository {
selectedAddons = playerSettings.streamAutoPlaySelectedAddons,
selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins,
debridEnabled = debridSettings.canResolvePlayableLinks,
activeResolverProviderId = debridSettings.activeResolverProviderId,
)
_uiState.update { it.copy(autoPlayStream = selected) }
_uiState.update {
it.copy(
autoPlayStream = evaluation.stream,
autoPlayCandidates = evaluation.readyStreams,
)
}
}
if (isDirectAutoPlayFlow && _uiState.value.autoPlayStream == null) {
_uiState.update {
@ -522,12 +531,33 @@ object StreamsRepository {
_uiState.update {
it.copy(
autoPlayStream = null,
autoPlayCandidates = emptyList(),
isDirectAutoPlayFlow = false,
showDirectAutoPlayOverlay = false,
)
}
}
fun skipAutoPlayStream(stream: StreamItem): Boolean {
var hasNext = false
_uiState.update { current ->
val failedIndex = current.autoPlayCandidates.indexOf(stream)
val remaining = if (failedIndex >= 0) {
current.autoPlayCandidates.drop(failedIndex + 1)
} else {
current.autoPlayCandidates.drop(1)
}
hasNext = remaining.isNotEmpty()
current.copy(
autoPlayStream = remaining.firstOrNull(),
autoPlayCandidates = remaining,
isDirectAutoPlayFlow = remaining.isNotEmpty(),
showDirectAutoPlayOverlay = remaining.isNotEmpty(),
)
}
return hasNext
}
fun cancelLoading() {
activeJob?.cancel()
activeJob = null

View file

@ -2,7 +2,9 @@ package com.nuvio.app.features.streams
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class StreamAutoPlaySelectorTest {
@ -167,27 +169,118 @@ class StreamAutoPlaySelectorTest {
assertEquals(directDebrid, selected)
}
@Test
fun `timeout evaluation keeps pending regex debrid candidate open`() {
val pending = stream(
addonName = "Torrentio",
name = "The Show 1080p",
infoHash = "hash-pending",
cacheState = StreamDebridCacheState.CHECKING,
)
val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream(
streams = listOf(pending),
mode = StreamAutoPlayMode.REGEX_MATCH,
regexPattern = "1080p",
source = StreamAutoPlaySource.ALL_SOURCES,
installedAddonNames = setOf("Torrentio"),
selectedAddons = emptySet(),
selectedPlugins = emptySet(),
debridEnabled = true,
activeResolverProviderId = "premiumize",
)
assertNull(evaluation.stream)
assertTrue(evaluation.hasPendingDebridCandidate)
}
@Test
fun `timeout evaluation still selects direct link while debrid candidate is pending`() {
val pending = stream(
addonName = "Torrentio",
name = "The Show 1080p",
infoHash = "hash-pending",
cacheState = StreamDebridCacheState.CHECKING,
)
val direct = stream(
addonName = "Direct Addon",
url = "https://example.com/video.mp4",
name = "The Show 1080p",
)
val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream(
streams = listOf(pending, direct),
mode = StreamAutoPlayMode.REGEX_MATCH,
regexPattern = "1080p",
source = StreamAutoPlaySource.ALL_SOURCES,
installedAddonNames = setOf("Torrentio", "Direct Addon"),
selectedAddons = emptySet(),
selectedPlugins = emptySet(),
debridEnabled = true,
activeResolverProviderId = "premiumize",
)
assertEquals(direct, evaluation.stream)
assertFalse(evaluation.hasPendingDebridCandidate)
}
@Test
fun `direct debrid candidate must match active resolver`() {
val torbox = stream(
addonName = "Comet",
name = "TB Instant",
directDebrid = true,
directDebridService = "torbox",
)
val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream(
streams = listOf(torbox),
mode = StreamAutoPlayMode.FIRST_STREAM,
regexPattern = "",
source = StreamAutoPlaySource.ALL_SOURCES,
installedAddonNames = setOf("Comet"),
selectedAddons = emptySet(),
selectedPlugins = emptySet(),
debridEnabled = true,
activeResolverProviderId = "premiumize",
)
assertNull(evaluation.stream)
assertFalse(evaluation.hasPendingDebridCandidate)
}
private fun stream(
addonName: String,
url: String? = null,
name: String? = null,
bingeGroup: String? = null,
directDebrid: Boolean = false,
directDebridService: String = "torbox",
infoHash: String? = null,
cacheState: StreamDebridCacheState? = null,
): StreamItem = StreamItem(
name = name,
url = url,
infoHash = infoHash,
addonName = addonName,
addonId = addonName,
addonId = "addon:$addonName",
clientResolve = if (directDebrid) {
StreamClientResolve(
type = "debrid",
service = "torbox",
service = directDebridService,
isCached = true,
infoHash = "hash",
)
} else {
null
},
debridCacheStatus = cacheState?.let { state ->
StreamDebridCacheStatus(
providerId = "premiumize",
providerName = "Premiumize",
state = state,
)
},
behaviorHints = StreamBehaviorHints(
bingeGroup = bingeGroup,
),