Skip to content

Commit 914f414

Browse files
committed
feat(streams): adjust autoplay stream selection for cloud services
1 parent 800d716 commit 914f414

5 files changed

Lines changed: 246 additions & 37 deletions

File tree

composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1571,9 +1571,11 @@ private fun MainAppContent(
15711571
) {
15721572
is DirectDebridPlayableResult.Success -> resolved.stream
15731573
else -> {
1574-
resolved.toastMessage()?.let { NuvioToastController.show(it) }
1575-
StreamsRepository.consumeAutoPlay()
1576-
if (resolved == DirectDebridPlayableResult.Stale) {
1574+
val hasNextCandidate = StreamsRepository.skipAutoPlayStream(selectedStream)
1575+
if (!hasNextCandidate) {
1576+
resolved.toastMessage()?.let { NuvioToastController.show(it) }
1577+
}
1578+
if (!hasNextCandidate && resolved == DirectDebridPlayableResult.Stale) {
15771579
StreamsRepository.reload(
15781580
type = launch.type,
15791581
videoId = effectiveVideoId,
@@ -1588,7 +1590,11 @@ private fun MainAppContent(
15881590
} else {
15891591
selectedStream
15901592
}
1591-
val sourceUrl = stream.playableDirectUrl ?: return@LaunchedEffect
1593+
val sourceUrl = stream.playableDirectUrl
1594+
if (sourceUrl == null) {
1595+
StreamsRepository.skipAutoPlayStream(selectedStream)
1596+
return@LaunchedEffect
1597+
}
15921598
autoPlayHandled = true
15931599
if (playerSettings.streamReuseLastLinkEnabled) {
15941600
val cacheKey = StreamLinkCacheRepository.contentKey(

composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,36 @@ object StreamAutoPlaySelector {
4141
preferredBingeGroup: String? = null,
4242
preferBingeGroupInSelection: Boolean = false,
4343
debridEnabled: Boolean = true,
44-
): StreamItem? {
45-
if (streams.isEmpty()) return null
44+
activeResolverProviderId: String? = null,
45+
): StreamItem? =
46+
evaluateAutoPlayStream(
47+
streams = streams,
48+
mode = mode,
49+
regexPattern = regexPattern,
50+
source = source,
51+
installedAddonNames = installedAddonNames,
52+
selectedAddons = selectedAddons,
53+
selectedPlugins = selectedPlugins,
54+
preferredBingeGroup = preferredBingeGroup,
55+
preferBingeGroupInSelection = preferBingeGroupInSelection,
56+
debridEnabled = debridEnabled,
57+
activeResolverProviderId = activeResolverProviderId,
58+
).stream
59+
60+
fun evaluateAutoPlayStream(
61+
streams: List<StreamItem>,
62+
mode: StreamAutoPlayMode,
63+
regexPattern: String,
64+
source: StreamAutoPlaySource,
65+
installedAddonNames: Set<String>,
66+
selectedAddons: Set<String>,
67+
selectedPlugins: Set<String>,
68+
preferredBingeGroup: String? = null,
69+
preferBingeGroupInSelection: Boolean = false,
70+
debridEnabled: Boolean = true,
71+
activeResolverProviderId: String? = null,
72+
): StreamAutoPlayEvaluation {
73+
if (streams.isEmpty()) return StreamAutoPlayEvaluation()
4674

4775
val sourceScopedStreams = when (source) {
4876
StreamAutoPlaySource.ALL_SOURCES -> streams
@@ -57,25 +85,26 @@ object StreamAutoPlaySelector {
5785
selectedPlugins.isEmpty() || stream.addonName in selectedPlugins
5886
}
5987
}
60-
if (candidateStreams.isEmpty()) return null
61-
if (mode == StreamAutoPlayMode.MANUAL) return null
88+
if (candidateStreams.isEmpty()) return StreamAutoPlayEvaluation()
89+
if (mode == StreamAutoPlayMode.MANUAL) return StreamAutoPlayEvaluation()
6290

6391
val targetBingeGroup = preferredBingeGroup?.trim().orEmpty()
64-
if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
65-
val bingeGroupMatch = candidateStreams.firstOrNull { stream ->
66-
stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable(debridEnabled)
92+
val preferredReadyStream = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) {
93+
candidateStreams.firstOrNull { stream ->
94+
stream.behaviorHints.bingeGroup == targetBingeGroup &&
95+
stream.isAutoPlayable(debridEnabled, activeResolverProviderId)
6796
}
68-
if (bingeGroupMatch != null) return bingeGroupMatch
97+
} else {
98+
null
6999
}
70-
71-
return when (mode) {
72-
StreamAutoPlayMode.MANUAL -> null
73-
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.isAutoPlayable(debridEnabled) }
100+
val matchingStreams = when (mode) {
101+
StreamAutoPlayMode.MANUAL -> emptyList()
102+
StreamAutoPlayMode.FIRST_STREAM -> candidateStreams
74103
StreamAutoPlayMode.REGEX_MATCH -> {
75104
val pattern = regexPattern.trim()
76105

77106
val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull()
78-
?: return null
107+
?: return StreamAutoPlayEvaluation()
79108

80109
val exclusionMatches = Regex("\\(\\?![^)]*?\\(([^)]+)\\)").findAll(pattern)
81110

@@ -89,8 +118,7 @@ object StreamAutoPlaySelector {
89118
Regex("\\b(${exclusionWords.joinToString("|")})\\b", RegexOption.IGNORE_CASE)
90119
} else null
91120

92-
val matchingStreams = candidateStreams.filter { stream ->
93-
if (!stream.isAutoPlayable(debridEnabled)) return@filter false
121+
candidateStreams.filter { stream ->
94122
val url = stream.playableDirectUrl.orEmpty()
95123

96124
val searchableText = buildString {
@@ -109,14 +137,65 @@ object StreamAutoPlaySelector {
109137

110138
true
111139
}
112-
113-
if (matchingStreams.isEmpty()) return null
114-
matchingStreams.firstOrNull { it.isAutoPlayable(debridEnabled) }
115140
}
116141
}
142+
if (matchingStreams.isEmpty() && preferredReadyStream == null) return StreamAutoPlayEvaluation()
143+
144+
val readyStreams = buildList {
145+
preferredReadyStream?.let(::add)
146+
matchingStreams
147+
.filter { it.isAutoPlayable(debridEnabled, activeResolverProviderId) }
148+
.filterNot { it == preferredReadyStream }
149+
.forEach(::add)
150+
}
151+
val selected = readyStreams.firstOrNull()
152+
if (selected != null) {
153+
return StreamAutoPlayEvaluation(
154+
stream = selected,
155+
readyStreams = readyStreams,
156+
)
157+
}
158+
159+
return StreamAutoPlayEvaluation(
160+
readyStreams = readyStreams,
161+
hasPendingDebridCandidate = matchingStreams.any {
162+
it.isPendingDebridAutoPlay(debridEnabled, activeResolverProviderId)
163+
},
164+
)
117165
}
118166

119-
private fun StreamItem.isAutoPlayable(debridEnabled: Boolean): Boolean =
167+
private fun StreamItem.isAutoPlayable(
168+
debridEnabled: Boolean,
169+
activeResolverProviderId: String?,
170+
): Boolean =
120171
playableDirectUrl != null ||
121-
(debridEnabled && isAddonDebridCandidate && (isDirectDebridStream || isCachedDebridTorrentStream))
172+
(debridEnabled && isAddonDebridCandidate && isReadyDebridAutoPlay(activeResolverProviderId))
173+
174+
private fun StreamItem.isReadyDebridAutoPlay(activeResolverProviderId: String?): Boolean =
175+
when {
176+
isDirectDebridStream -> clientResolve?.service.matchesResolver(activeResolverProviderId)
177+
isCachedDebridTorrentStream -> debridCacheStatus?.providerId.matchesResolver(activeResolverProviderId)
178+
else -> false
179+
}
180+
181+
private fun StreamItem.isPendingDebridAutoPlay(
182+
debridEnabled: Boolean,
183+
activeResolverProviderId: String?,
184+
): Boolean {
185+
if (!debridEnabled || !isInstalledAddonStream || !needsLocalDebridResolve) return false
186+
if (!debridCacheStatus?.providerId.matchesResolver(activeResolverProviderId)) return false
187+
val state = debridCacheStatus?.state
188+
return state == null || state == StreamDebridCacheState.CHECKING
189+
}
190+
191+
private fun String?.matchesResolver(activeResolverProviderId: String?): Boolean {
192+
val active = activeResolverProviderId?.trim().orEmpty()
193+
return active.isBlank() || this == null || equals(active, ignoreCase = true)
194+
}
122195
}
196+
197+
data class StreamAutoPlayEvaluation(
198+
val stream: StreamItem? = null,
199+
val readyStreams: List<StreamItem> = emptyList(),
200+
val hasPendingDebridCandidate: Boolean = false,
201+
)

composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ data class StreamsUiState(
186186
val isAnyLoading: Boolean = false,
187187
val emptyStateReason: StreamsEmptyStateReason? = null,
188188
val autoPlayStream: StreamItem? = null,
189+
val autoPlayCandidates: List<StreamItem> = emptyList(),
189190
val isDirectAutoPlayFlow: Boolean = false,
190191
val showDirectAutoPlayOverlay: Boolean = false,
191192
) {

composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,6 @@ object StreamsRepository {
232232
val installedAddonIds = streamAddons.map { it.addonId }.toSet()
233233
val debridAvailabilityJobs = mutableListOf<Job>()
234234
var autoSelectTriggered = false
235-
var timeoutElapsed = false
236235
fun publishCompletion(completion: StreamLoadCompletion) {
237236
if (completions.trySend(completion).isFailure) {
238237
log.d { "Ignoring late stream load completion after channel close" }
@@ -286,12 +285,10 @@ object StreamsRepository {
286285
if (timeoutMs > 0L && playerSettings.streamAutoPlayTimeoutSeconds < 11) {
287286
launch {
288287
delay(timeoutMs)
289-
timeoutElapsed = true
290288
if (!autoSelectTriggered) {
291289
val allStreams = _uiState.value.groups.flatMap { it.streams }
292290
if (allStreams.isNotEmpty()) {
293-
autoSelectTriggered = true
294-
val selected = StreamAutoPlaySelector.selectAutoPlayStream(
291+
val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream(
295292
streams = allStreams,
296293
mode = autoPlayMode,
297294
regexPattern = playerSettings.streamAutoPlayRegex,
@@ -300,9 +297,18 @@ object StreamsRepository {
300297
selectedAddons = playerSettings.streamAutoPlaySelectedAddons,
301298
selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins,
302299
debridEnabled = debridSettings.canResolvePlayableLinks,
300+
activeResolverProviderId = debridSettings.activeResolverProviderId,
303301
)
304-
_uiState.update { it.copy(autoPlayStream = selected) }
305-
if (selected == null) {
302+
if (evaluation.stream != null || !evaluation.hasPendingDebridCandidate) {
303+
autoSelectTriggered = true
304+
_uiState.update {
305+
it.copy(
306+
autoPlayStream = evaluation.stream,
307+
autoPlayCandidates = evaluation.readyStreams,
308+
)
309+
}
310+
}
311+
if (evaluation.stream == null && !evaluation.hasPendingDebridCandidate) {
306312
_uiState.update {
307313
it.copy(
308314
isDirectAutoPlayFlow = false,
@@ -313,9 +319,6 @@ object StreamsRepository {
313319
}
314320
}
315321
}
316-
} else if (timeoutMs <= 0L) {
317-
timeoutElapsed = true
318-
null
319322
} else {
320323
null
321324
}
@@ -490,7 +493,7 @@ object StreamsRepository {
490493
if (isAutoPlayEnabled && !autoSelectTriggered) {
491494
autoSelectTriggered = true
492495
val allStreams = _uiState.value.groups.flatMap { it.streams }
493-
val selected = StreamAutoPlaySelector.selectAutoPlayStream(
496+
val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream(
494497
streams = allStreams,
495498
mode = autoPlayMode,
496499
regexPattern = playerSettings.streamAutoPlayRegex,
@@ -499,8 +502,14 @@ object StreamsRepository {
499502
selectedAddons = playerSettings.streamAutoPlaySelectedAddons,
500503
selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins,
501504
debridEnabled = debridSettings.canResolvePlayableLinks,
505+
activeResolverProviderId = debridSettings.activeResolverProviderId,
502506
)
503-
_uiState.update { it.copy(autoPlayStream = selected) }
507+
_uiState.update {
508+
it.copy(
509+
autoPlayStream = evaluation.stream,
510+
autoPlayCandidates = evaluation.readyStreams,
511+
)
512+
}
504513
}
505514
if (isDirectAutoPlayFlow && _uiState.value.autoPlayStream == null) {
506515
_uiState.update {
@@ -522,12 +531,33 @@ object StreamsRepository {
522531
_uiState.update {
523532
it.copy(
524533
autoPlayStream = null,
534+
autoPlayCandidates = emptyList(),
525535
isDirectAutoPlayFlow = false,
526536
showDirectAutoPlayOverlay = false,
527537
)
528538
}
529539
}
530540

541+
fun skipAutoPlayStream(stream: StreamItem): Boolean {
542+
var hasNext = false
543+
_uiState.update { current ->
544+
val failedIndex = current.autoPlayCandidates.indexOf(stream)
545+
val remaining = if (failedIndex >= 0) {
546+
current.autoPlayCandidates.drop(failedIndex + 1)
547+
} else {
548+
current.autoPlayCandidates.drop(1)
549+
}
550+
hasNext = remaining.isNotEmpty()
551+
current.copy(
552+
autoPlayStream = remaining.firstOrNull(),
553+
autoPlayCandidates = remaining,
554+
isDirectAutoPlayFlow = remaining.isNotEmpty(),
555+
showDirectAutoPlayOverlay = remaining.isNotEmpty(),
556+
)
557+
}
558+
return hasNext
559+
}
560+
531561
fun cancelLoading() {
532562
activeJob?.cancel()
533563
activeJob = null

0 commit comments

Comments
 (0)