diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerNextEpisodeAutoPlay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerNextEpisodeAutoPlay.kt index 637587493..808bd733a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerNextEpisodeAutoPlay.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerNextEpisodeAutoPlay.kt @@ -28,6 +28,10 @@ internal fun CoroutineScope.launchPlayerNextEpisodeAutoPlay( contentType: String?, settings: PlayerSettingsUiState, currentStreamBingeGroup: String?, + currentStreamTitle: String? = null, + currentStreamProviderName: String? = null, + currentStreamProviderAddonId: String? = null, + currentStreamSubtitle: String? = null, onDownloadedEpisodeSelected: (DownloadItem, MetaVideo) -> Unit, onEpisodeStreamSelected: (StreamItem, MetaVideo) -> Unit, onManualSelectionRequired: (MetaVideo) -> Unit, @@ -57,18 +61,22 @@ internal fun CoroutineScope.launchPlayerNextEpisodeAutoPlay( onCountdownChanged(null) val type = contentType ?: parentMetaType + val hasPreferredSameName = !currentStreamTitle.isNullOrBlank() + val hasPreferredBingeGroup = !currentStreamBingeGroup.isNullOrBlank() && settings.streamAutoPlayPreferBingeGroup + val hasPreferredMatch = hasPreferredSameName || hasPreferredBingeGroup + val shouldAutoSelectInManualMode = settings.streamAutoPlayMode == StreamAutoPlayMode.MANUAL && ( settings.streamAutoPlayNextEpisodeEnabled || - settings.streamAutoPlayPreferBingeGroup + hasPreferredMatch ) - val bingeGroupOnlyManualMode = + val preferredOnlyManualMode = shouldAutoSelectInManualMode && (!settings.streamAutoPlayNextEpisodeEnabled || !settings.streamAutoPlayNextEpisodeFallbackEnabled) && - settings.streamAutoPlayPreferBingeGroup + hasPreferredMatch val effectiveMode = if (shouldAutoSelectInManualMode) { StreamAutoPlayMode.FIRST_STREAM @@ -148,13 +156,18 @@ internal fun CoroutineScope.launchPlayerNextEpisodeAutoPlay( selectedPlugins = effectiveSelectedPlugins, preferredBingeGroup = preferredBingeGroup, preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, - bingeGroupOnly = bingeGroupOnlyManualMode, + preferredStreamTitle = currentStreamTitle, + preferredProviderName = currentStreamProviderName, + preferredProviderAddonId = currentStreamProviderAddonId, + preferredStreamSubtitle = currentStreamSubtitle, + preferSameNameInSelection = hasPreferredSameName, + bingeGroupOnly = preferredOnlyManualMode, debridEnabled = debridSettings.canResolvePlayableLinks, activeResolverProviderId = debridSettings.activeResolverProviderId, ) - fun tryBingeGroupOnly(streams: List): StreamItem? { - if (preferredBingeGroup == null || !settings.streamAutoPlayPreferBingeGroup) return null + fun tryPreferredOnly(streams: List): StreamItem? { + if (!hasPreferredMatch) return null return StreamAutoPlaySelector.selectAutoPlayStream( streams = streams, mode = effectiveMode, @@ -164,7 +177,12 @@ internal fun CoroutineScope.launchPlayerNextEpisodeAutoPlay( selectedAddons = effectiveSelectedAddons, selectedPlugins = effectiveSelectedPlugins, preferredBingeGroup = preferredBingeGroup, - preferBingeGroupInSelection = true, + preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, + preferredStreamTitle = currentStreamTitle, + preferredProviderName = currentStreamProviderName, + preferredProviderAddonId = currentStreamProviderAddonId, + preferredStreamSubtitle = currentStreamSubtitle, + preferSameNameInSelection = hasPreferredSameName, bingeGroupOnly = true, debridEnabled = debridSettings.canResolvePlayableLinks, activeResolverProviderId = debridSettings.activeResolverProviderId, @@ -173,7 +191,7 @@ internal fun CoroutineScope.launchPlayerNextEpisodeAutoPlay( val selectionCoordinator = NextEpisodeStreamSelectionCoordinator( selectAfterDelay = ::trySelectStream, - selectPreferred = ::tryBingeGroupOnly, + selectPreferred = ::tryPreferredOnly, ) fun applySelectionDecision(decision: NextEpisodeStreamSelectionDecision) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt index 4f3d7deaa..e71283997 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt @@ -381,6 +381,10 @@ internal fun PlayerScreenRuntime.playNextEpisode() { contentType = contentType, settings = playerSettingsUiState, currentStreamBingeGroup = currentStreamBingeGroup, + currentStreamTitle = activeStreamTitle, + currentStreamProviderName = activeProviderName, + currentStreamProviderAddonId = activeProviderAddonId, + currentStreamSubtitle = activeStreamSubtitle, onDownloadedEpisodeSelected = { item, episode -> switchToDownloadedEpisode(item, episode) }, onEpisodeStreamSelected = { stream, episode -> switchToEpisodeStream(stream, episode) }, onManualSelectionRequired = { nextVideo -> 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 2f225777c..59a8770d5 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 @@ -43,6 +43,11 @@ object StreamAutoPlaySelector { preferredBingeGroup: String? = null, preferBingeGroupInSelection: Boolean = false, bingeGroupOnly: Boolean = false, + preferredStreamTitle: String? = null, + preferredProviderName: String? = null, + preferredProviderAddonId: String? = null, + preferredStreamSubtitle: String? = null, + preferSameNameInSelection: Boolean = false, debridEnabled: Boolean = true, activeResolverProviderId: String? = null, ): StreamItem? = @@ -57,6 +62,11 @@ object StreamAutoPlaySelector { preferredBingeGroup = preferredBingeGroup, preferBingeGroupInSelection = preferBingeGroupInSelection, bingeGroupOnly = bingeGroupOnly, + preferredStreamTitle = preferredStreamTitle, + preferredProviderName = preferredProviderName, + preferredProviderAddonId = preferredProviderAddonId, + preferredStreamSubtitle = preferredStreamSubtitle, + preferSameNameInSelection = preferSameNameInSelection, debridEnabled = debridEnabled, activeResolverProviderId = activeResolverProviderId, ).stream @@ -72,6 +82,11 @@ object StreamAutoPlaySelector { preferredBingeGroup: String? = null, preferBingeGroupInSelection: Boolean = false, bingeGroupOnly: Boolean = false, + preferredStreamTitle: String? = null, + preferredProviderName: String? = null, + preferredProviderAddonId: String? = null, + preferredStreamSubtitle: String? = null, + preferSameNameInSelection: Boolean = false, debridEnabled: Boolean = true, activeResolverProviderId: String? = null, ): StreamAutoPlayEvaluation { @@ -95,37 +110,66 @@ object StreamAutoPlaySelector { return StreamAutoPlayEvaluation() } + val targetStreamTitle = preferredStreamTitle?.trim().orEmpty() + val sameNameCandidates = if (preferSameNameInSelection && targetStreamTitle.isNotEmpty()) { + candidateStreams.filter { stream -> + val providerMatches = when { + !preferredProviderAddonId.isNullOrBlank() && stream.addonId.equals(preferredProviderAddonId.trim(), ignoreCase = true) -> true + !preferredProviderName.isNullOrBlank() && stream.addonName.equals(preferredProviderName.trim(), ignoreCase = true) -> true + preferredProviderAddonId.isNullOrBlank() && preferredProviderName.isNullOrBlank() -> true + else -> false + } + if (!providerMatches) return@filter false + + stream.streamLabel.trim().equals(targetStreamTitle, ignoreCase = true) || + stream.name?.trim()?.equals(targetStreamTitle, ignoreCase = true) == true + } + } else { + emptyList() + } + val targetSubtitle = preferredStreamSubtitle?.trim().orEmpty() + val orderedSameNameCandidates = if (sameNameCandidates.size > 1 && targetSubtitle.isNotEmpty()) { + sameNameCandidates.sortedByDescending { stream -> + stream.streamSubtitle?.trim()?.equals(targetSubtitle, ignoreCase = true) == true + } + } else { + sameNameCandidates + } + val preferredSameNameReadyStream = orderedSameNameCandidates.firstOrNull { stream -> + stream.isAutoPlayable(debridEnabled, activeResolverProviderId) + } + val targetBingeGroup = preferredBingeGroup?.trim().orEmpty() val bingeGroupCandidates = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { candidateStreams.filter { stream -> stream.behaviorHints.bingeGroup == targetBingeGroup } } else { emptyList() } - val preferredReadyStream = bingeGroupCandidates.firstOrNull { stream -> + val preferredBingeGroupReadyStream = bingeGroupCandidates.firstOrNull { stream -> stream.isAutoPlayable(debridEnabled, activeResolverProviderId) } + + val preferredReadyStream = preferredSameNameReadyStream ?: preferredBingeGroupReadyStream if (bingeGroupOnly) { val readyStreams = preferredReadyStream?.let(::listOf).orEmpty() return StreamAutoPlayEvaluation( stream = preferredReadyStream, readyStreams = readyStreams, hasPendingDebridCandidate = preferredReadyStream == null && - bingeGroupCandidates.any { - it.isPendingDebridAutoPlay(debridEnabled, activeResolverProviderId) - }, + ( + orderedSameNameCandidates.any { + it.isPendingDebridAutoPlay(debridEnabled, activeResolverProviderId) + } || + bingeGroupCandidates.any { + it.isPendingDebridAutoPlay(debridEnabled, activeResolverProviderId) + } + ), ) } if (mode == StreamAutoPlayMode.MANUAL) { return StreamAutoPlayEvaluation() } - val preferredStream = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { - candidateStreams.firstOrNull { stream -> - stream.behaviorHints.bingeGroup == targetBingeGroup && - stream.isAutoPlayable(debridEnabled, activeResolverProviderId) - } - } else { - null - } + val preferredStream = preferredReadyStream val matchingStreams = when (mode) { StreamAutoPlayMode.MANUAL -> emptyList() StreamAutoPlayMode.FIRST_STREAM -> candidateStreams diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerExitOrderingTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerExitOrderingTest.kt index 77071ddef..836c9a8d9 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerExitOrderingTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerExitOrderingTest.kt @@ -92,6 +92,7 @@ class PlayerExitOrderingTest { override fun selectAudioTrack(index: Int) = Unit override fun selectSubtitleTrack(index: Int) = Unit override fun setSubtitleUri(url: String) = Unit + override fun applyAudioLanguagePreferences(languages: List) = Unit override fun clearExternalSubtitle() = Unit override fun clearExternalSubtitleAndSelect(trackIndex: Int) = Unit override fun releaseBeforeNavigation( 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 ef95abd33..d9a6f70f2 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 @@ -291,11 +291,127 @@ class StreamAutoPlaySelectorTest { assertEquals(stream, selected) } + @Test + fun `same-name stream from same addon is selected first before default first stream mode`() { + val topCartoons = stream( + addonName = "TopCartoons", + url = "https://example.com/topcartoons.m3u8", + name = "TopCartoons", + ) + val matchingStream = stream( + addonName = "4KHDHub", + url = "https://example.com/4khdhub-fsl.m3u8", + name = "4KHDHub - FSL 1080p", + ) + + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = listOf(topCartoons, matchingStream), + mode = StreamAutoPlayMode.FIRST_STREAM, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("TopCartoons", "4KHDHub"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + preferredStreamTitle = "4KHDHub - FSL 1080p", + preferredProviderName = "4KHDHub", + preferSameNameInSelection = true, + ) + + assertEquals(matchingStream, selected) + } + + @Test + fun `same-name stream matching prefers matching subtitle when multiple exist`() { + val stream720p = stream( + addonName = "4KHDHub", + url = "https://example.com/720p.m3u8", + name = "4KHDHub - FSL", + description = "720p", + ) + val stream1080p = stream( + addonName = "4KHDHub", + url = "https://example.com/1080p.m3u8", + name = "4KHDHub - FSL", + description = "1080p", + ) + + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = listOf(stream720p, stream1080p), + mode = StreamAutoPlayMode.FIRST_STREAM, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("4KHDHub"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + preferredStreamTitle = "4KHDHub - FSL", + preferredProviderName = "4KHDHub", + preferredStreamSubtitle = "1080p", + preferSameNameInSelection = true, + ) + + assertEquals(stream1080p, selected) + } + + @Test + fun `same-name stream matching falls back to default scanning algorithm if same-name not found`() { + val firstStream = stream( + addonName = "TopCartoons", + url = "https://example.com/first.m3u8", + name = "TopCartoons", + ) + val otherStream = stream( + addonName = "OtherAddon", + url = "https://example.com/other.m3u8", + name = "OtherAddon 1080p", + ) + + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = listOf(firstStream, otherStream), + mode = StreamAutoPlayMode.FIRST_STREAM, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("TopCartoons", "OtherAddon"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + preferredStreamTitle = "4KHDHub - FSL 1080p", + preferredProviderName = "4KHDHub", + preferSameNameInSelection = true, + ) + + assertEquals(firstStream, selected) + } + + @Test + fun `same-name stream matching works in manual mode when preferred stream is requested`() { + val matchingStream = stream( + addonName = "4KHDHub", + url = "https://example.com/4khdhub-fsl.m3u8", + name = "4KHDHub - FSL 1080p", + ) + + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = listOf(matchingStream), + mode = StreamAutoPlayMode.MANUAL, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("4KHDHub"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + preferredStreamTitle = "4KHDHub - FSL 1080p", + preferredProviderName = "4KHDHub", + preferSameNameInSelection = true, + bingeGroupOnly = true, + ) + + assertEquals(matchingStream, selected) + } + private fun stream( addonName: String, url: String? = null, externalUrl: String? = null, name: String? = null, + description: String? = null, bingeGroup: String? = null, directDebrid: Boolean = false, directDebridService: String = "torbox", @@ -303,6 +419,7 @@ class StreamAutoPlaySelectorTest { cacheState: StreamDebridCacheState? = null, ): StreamItem = StreamItem( name = name, + description = description, url = url, externalUrl = externalUrl, infoHash = infoHash,