From fe747ffc3eff5779ebd6561fc7666b9aea2825da Mon Sep 17 00:00:00 2001 From: Aniket Tuli Date: Thu, 11 Jun 2026 17:02:22 -0700 Subject: [PATCH] fix(player): normalize declared stream type at ingestion streamType is now trimmed, lowercased, and blanked to null by a shared normalizeStreamType helper at all four ingestion sites, and the player derives a single normalized value for both its rebuild keys and MIME inference, so values like " HLS " cannot cause key churn or missed mappings. The helper lives in commonMain and is covered by commonTest together with a parser round-trip for a padded uppercase type. Co-Authored-By: Claude Fable 5 --- .../features/player/PlayerEngine.android.kt | 10 ++++-- .../app/features/details/MetaDetailsParser.kt | 3 +- .../player/PlayerStreamsRepository.kt | 3 +- .../app/features/streams/StreamModels.kt | 3 ++ .../app/features/streams/StreamParser.kt | 2 +- .../app/features/streams/StreamsRepository.kt | 2 +- .../app/features/streams/StreamParserTest.kt | 31 +++++++++++++++++++ 7 files changed, 47 insertions(+), 7 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 17492a73b..d6eb5d94b 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -56,6 +56,7 @@ import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView import androidx.media3.ui.CaptionStyleCompat import com.nuvio.app.R +import com.nuvio.app.features.streams.normalizeStreamType import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers @@ -108,6 +109,9 @@ actual fun PlatformPlayerSurface( val sanitizedSourceResponseHeaders = remember(sourceResponseHeaders) { sanitizePlaybackResponseHeaders(sourceResponseHeaders) } + val normalizedStreamType = remember(streamType) { + normalizeStreamType(streamType) + } val useLibass = playerSettings.useLibass val libassRenderType = runCatching { LibassRenderType.valueOf(playerSettings.libassRenderType) @@ -117,7 +121,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl.orEmpty(), sanitizedSourceHeaders, sanitizedSourceResponseHeaders, - streamType.orEmpty(), + normalizedStreamType.orEmpty(), useYoutubeChunkedPlayback, ) var subtitleDelayMs by remember(playerSourceKey) { mutableStateOf(0) } @@ -170,7 +174,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl, sanitizedSourceHeaders, sanitizedSourceResponseHeaders, - streamType, + normalizedStreamType, useYoutubeChunkedPlayback, effectiveDecoderPriority, ) { @@ -235,7 +239,7 @@ actual fun PlatformPlayerSurface( videoMediaItem = playbackMediaItemFromUrl( url = sourceUrl, responseHeaders = sanitizedSourceResponseHeaders, - streamType = streamType, + streamType = normalizedStreamType, ), startPositionMs = fallbackStartPositionMs, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index 031ae310f..073ae7f7c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.details import com.nuvio.app.features.streams.StreamBehaviorHints import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamProxyHeaders +import com.nuvio.app.features.streams.normalizeStreamType import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -288,7 +289,7 @@ internal object MetaDetailsParser { externalUrl = externalUrl, addonName = addonName, addonId = "embedded", - streamType = obj.string("type"), + streamType = normalizeStreamType(obj.string("type")), behaviorHints = StreamBehaviorHints( bingeGroup = hintsObj?.string("bingeGroup"), notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 8031b405b..eed8ac51a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -23,6 +23,7 @@ import com.nuvio.app.features.streams.StreamBadgeSettingsRepository import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamParser import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.streams.normalizeStreamType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -503,7 +504,7 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem infoHash = infoHash, addonName = scraper.name, addonId = "plugin:${scraper.id}", - streamType = type, + streamType = normalizeStreamType(type), behaviorHints = if (requestHeaders.isEmpty()) { com.nuvio.app.features.streams.StreamBehaviorHints() } else { 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 74dd67800..c87143fb3 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 @@ -90,6 +90,9 @@ data class StreamBadge( val borderColor: String = "", ) +fun normalizeStreamType(raw: String?): String? = + raw?.trim()?.lowercase()?.takeIf { it.isNotBlank() } + private fun String?.isMagnetLink(): Boolean = this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt index 28ca1adf1..ab1df9593 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt @@ -48,7 +48,7 @@ object StreamParser { addonName = addonName, addonId = addonId, addonLogo = addonLogo, - streamType = obj.string("type"), + streamType = normalizeStreamType(obj.string("type")), clientResolve = clientResolve, behaviorHints = StreamBehaviorHints( bingeGroup = hintsObj?.string("bingeGroup"), 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 21eb00474..b9f349273 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 @@ -915,7 +915,7 @@ private fun PluginRuntimeResult.toStreamItem( sourceName = scraper.name, addonName = addonName, addonId = addonId, - streamType = type, + streamType = normalizeStreamType(type), behaviorHints = if (requestHeaders.isEmpty()) { StreamBehaviorHints() } else { diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt index bc9f4302e..d6ddf28de 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt @@ -194,6 +194,37 @@ class StreamParserTest { assertEquals("hls", streams.single().streamType) } + @Test + fun `parse normalizes streamType casing and whitespace`() { + val streams = StreamParser.parse( + payload = + """ + { + "streams": [ + { + "url": "https://cdn.example.com/playlist?token=abc", + "name": "1080p", + "type": " HLS " + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + assertEquals("hls", streams.single().streamType) + } + + @Test + fun `normalizeStreamType trims lowercases and blanks to null`() { + assertEquals("hls", normalizeStreamType(" Hls ")) + assertEquals("dash", normalizeStreamType("DASH")) + assertEquals(null, normalizeStreamType(" ")) + assertEquals(null, normalizeStreamType("")) + assertEquals(null, normalizeStreamType(null)) + } + @Test fun `parse leaves streamType null when addon omits it`() { val streams = StreamParser.parse(