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 <noreply@anthropic.com>
This commit is contained in:
Aniket Tuli 2026-06-11 17:02:22 -07:00
parent 3d52716770
commit fe747ffc3e
7 changed files with 47 additions and 7 deletions

View file

@ -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,
)

View file

@ -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,

View file

@ -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 {

View file

@ -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

View file

@ -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"),

View file

@ -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 {

View file

@ -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(