mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-23 02:02:04 +00:00
feat(streams): adjust autoplay stream selection for cloud services
This commit is contained in:
parent
800d7160b1
commit
914f4147e9
5 changed files with 246 additions and 37 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue