diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 6cd778a3..f60bdd76 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index a88faa7d..db1552c8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -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, + mode: StreamAutoPlayMode, + regexPattern: String, + source: StreamAutoPlaySource, + installedAddonNames: Set, + selectedAddons: Set, + selectedPlugins: Set, + 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 = emptyList(), + val hasPendingDebridCandidate: Boolean = false, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 55deebce..b88c8251 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -186,6 +186,7 @@ data class StreamsUiState( val isAnyLoading: Boolean = false, val emptyStateReason: StreamsEmptyStateReason? = null, val autoPlayStream: StreamItem? = null, + val autoPlayCandidates: List = emptyList(), val isDirectAutoPlayFlow: Boolean = false, val showDirectAutoPlayOverlay: Boolean = false, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 575a78e4..fffbb8be 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -232,7 +232,6 @@ object StreamsRepository { val installedAddonIds = streamAddons.map { it.addonId }.toSet() val debridAvailabilityJobs = mutableListOf() 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 diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt index 1ebf6b84..000d00da 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt @@ -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, ),