From 3d5271677058b82e2d13330e0dd9d1347e84b8fd Mon Sep 17 00:00:00 2001 From: Aniket Tuli Date: Thu, 11 Jun 2026 14:44:14 -0700 Subject: [PATCH 1/2] fix(player): honor declared stream type for HLS without .m3u8 URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin scrapers (and some non-standard addons) declare the manifest format of their links via a "type" field ("hls", "dash") on the stream object. PluginRuntime already parses it into PluginRuntimeResult.type, but both toStreamItem conversions dropped it, and PlayerEngine.android.kt built MediaItems with no MIME type, leaving ExoPlayer to infer the format from the URL extension. For HLS links whose URL hides the .m3u8 behind tokens or rewriters (https://cdn.example.com/playlist?token=…), inference fails and playback errors or stalls. This change: - Adds a streamType field to StreamItem, populated from stream.type in StreamParser, MetaDetailsParser embedded streams, and both PluginRuntimeResult.toStreamItem conversions (StreamsRepository and PlayerStreamsRepository). - Plumbs the value through PlayerLaunch / PlayerScreen / PlatformPlayerSurface and the in-player source-switch paths. Switches to P2P sentinels and downloaded local files reset the value to null so a stale "hls" can never mislabel the next source. streamType participates in the player rebuild keys so a source change always rebuilds with the right MIME. - Persists streamType in the Reuse Last Link cache (defaulted serializable field, old payloads stay decodable) so a typed HLS stream that played once keeps working through link reuse. - In playbackMediaItemFromUrl, the declared type takes precedence over response-header and URL-path inference, mapping "hls"/"m3u8" → APPLICATION_M3U8, "dash"/"mpd" → APPLICATION_MPD, "ss"/"smoothstreaming" → APPLICATION_SS. Unrecognized values (including Stremio's content-type semantics like "movie"/"series") fall through to the existing inference chain unchanged. The official Stremio stream spec defines no top-level type field, so the Stremio-addon parsing is best-effort; the reliable emitters are plugin scrapers, whose results previously lost the field entirely. Scopes down to #1244 only. #1246 (User-Agent override) is already fixed in the current PlayerPlaybackNetworking.createHttpDataSourceFactory, which skips setUserAgent when caller-supplied headers declare one. Fixes #1244 Co-Authored-By: Claude Fable 5 --- .../player/PlaybackMediaItems.android.kt | 20 ++++++++- .../features/player/PlayerEngine.android.kt | 4 ++ .../commonMain/kotlin/com/nuvio/app/App.kt | 7 +++ .../app/features/details/MetaDetailsParser.kt | 1 + .../nuvio/app/features/player/PlayerEngine.kt | 1 + .../nuvio/app/features/player/PlayerModels.kt | 1 + .../nuvio/app/features/player/PlayerScreen.kt | 2 + .../app/features/player/PlayerScreenArgs.kt | 1 + .../player/PlayerScreenRuntimeEffects.kt | 1 + .../PlayerScreenRuntimeSourceActions.kt | 6 +++ .../player/PlayerScreenRuntimeState.kt | 2 + .../features/player/PlayerScreenRuntimeUi.kt | 1 + .../player/PlayerStreamsRepository.kt | 1 + .../streams/StreamLinkCacheRepository.kt | 3 ++ .../app/features/streams/StreamModels.kt | 1 + .../app/features/streams/StreamParser.kt | 1 + .../app/features/streams/StreamsRepository.kt | 1 + .../app/features/streams/StreamParserTest.kt | 43 +++++++++++++++++++ .../features/player/PlayerEngine.desktop.kt | 1 + .../app/features/player/PlayerEngine.ios.kt | 1 + 20 files changed, 98 insertions(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt index 038a472a..d4646342 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt @@ -7,11 +7,13 @@ import java.util.Locale internal fun playbackMediaItemFromUrl( url: String, responseHeaders: Map = emptyMap(), + streamType: String? = null, ): MediaItem { val builder = MediaItem.Builder().setUri(url) inferPlaybackMimeType( url = url, responseHeaders = responseHeaders, + streamType = streamType, )?.let(builder::setMimeType) return builder.build() } @@ -19,10 +21,26 @@ internal fun playbackMediaItemFromUrl( private fun inferPlaybackMimeType( url: String, responseHeaders: Map, + streamType: String?, ): String? = - inferMimeTypeFromResponseHeaders(responseHeaders) + inferMimeTypeFromStreamType(streamType) + ?: inferMimeTypeFromResponseHeaders(responseHeaders) ?: inferMimeTypeFromPath(url) +private fun inferMimeTypeFromStreamType(streamType: String?): String? { + val normalized = streamType + ?.trim() + ?.lowercase(Locale.US) + ?.takeIf { it.isNotBlank() } + ?: return null + return when (normalized) { + "hls", "m3u8" -> MimeTypes.APPLICATION_M3U8 + "dash", "mpd" -> MimeTypes.APPLICATION_MPD + "smoothstreaming", "ss" -> MimeTypes.APPLICATION_SS + else -> null + } +} + private fun inferMimeTypeFromResponseHeaders(headers: Map): String? { if (headers.isEmpty()) return null 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 e4e83323..17492a73 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 @@ -75,6 +75,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl: String?, sourceHeaders: Map, sourceResponseHeaders: Map, + streamType: String?, useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, @@ -116,6 +117,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl.orEmpty(), sanitizedSourceHeaders, sanitizedSourceResponseHeaders, + streamType.orEmpty(), useYoutubeChunkedPlayback, ) var subtitleDelayMs by remember(playerSourceKey) { mutableStateOf(0) } @@ -168,6 +170,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl, sanitizedSourceHeaders, sanitizedSourceResponseHeaders, + streamType, useYoutubeChunkedPlayback, effectiveDecoderPriority, ) { @@ -232,6 +235,7 @@ actual fun PlatformPlayerSurface( videoMediaItem = playbackMediaItemFromUrl( url = sourceUrl, responseHeaders = sanitizedSourceResponseHeaders, + streamType = streamType, ), startPositionMs = fallbackStartPositionMs, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 458c752c..ee06a40b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1845,6 +1845,7 @@ private fun MainAppContent( sourceUrl = sentinelUrl, sourceHeaders = emptyMap(), sourceResponseHeaders = emptyMap(), + streamType = stream.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1964,6 +1965,7 @@ private fun MainAppContent( sourceUrl = cached.url, sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders), sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders), + streamType = cached.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -2089,6 +2091,7 @@ private fun MainAppContent( filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, + streamType = stream.streamType, ) } val playerLaunch = PlayerLaunch( @@ -2096,6 +2099,7 @@ private fun MainAppContent( sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + streamType = stream.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -2213,6 +2217,7 @@ private fun MainAppContent( filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, + streamType = stream.streamType, ) } val playerLaunch = PlayerLaunch( @@ -2220,6 +2225,7 @@ private fun MainAppContent( sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + streamType = stream.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -2378,6 +2384,7 @@ private fun MainAppContent( sourceAudioUrl = launch.sourceAudioUrl, sourceHeaders = launch.sourceHeaders, sourceResponseHeaders = launch.sourceResponseHeaders, + streamType = launch.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, 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 adcf6811..031ae310 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 @@ -288,6 +288,7 @@ internal object MetaDetailsParser { externalUrl = externalUrl, addonName = addonName, addonId = "embedded", + streamType = 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/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index cdd15b8b..99e33b6a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -285,6 +285,7 @@ expect fun PlatformPlayerSurface( sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = emptyMap(), + streamType: String? = null, useYoutubeChunkedPlayback: Boolean = false, modifier: Modifier = Modifier, playWhenReady: Boolean = true, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 8441b89d..1989a25f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -25,6 +25,7 @@ data class PlayerLaunch( val sourceAudioUrl: String? = null, val sourceHeaders: Map = emptyMap(), val sourceResponseHeaders: Map = emptyMap(), + val streamType: String? = null, val logo: String? = null, val poster: String? = null, val background: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 725ecf22..431e4978 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -10,6 +10,7 @@ fun PlayerScreen( sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = emptyMap(), + streamType: String? = null, providerName: String, streamTitle: String, streamSubtitle: String?, @@ -44,6 +45,7 @@ fun PlayerScreen( sourceAudioUrl = sourceAudioUrl, sourceHeaders = sourceHeaders, sourceResponseHeaders = sourceResponseHeaders, + streamType = streamType, providerName = providerName, streamTitle = streamTitle, streamSubtitle = streamSubtitle, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt index 71705dc8..2aa779d2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt @@ -8,6 +8,7 @@ internal data class PlayerScreenArgs( val sourceAudioUrl: String?, val sourceHeaders: Map, val sourceResponseHeaders: Map, + val streamType: String?, val providerName: String, val streamTitle: String, val streamSubtitle: String?, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt index e1e5f81f..340db03a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -551,6 +551,7 @@ internal fun PlayerScreenRuntime.tryRefreshCredentialedSourceAfterError(message: activeSourceAudioUrl = null activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) + activeStreamType = stream.streamType activeStreamTitle = stream.streamLabel activeStreamSubtitle = stream.streamSubtitle activeProviderName = stream.addonName 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 12b82dc3..fa5507e7 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 @@ -164,6 +164,7 @@ internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) { activeSourceAudioUrl = null activeSourceHeaders = emptyMap() activeSourceResponseHeaders = emptyMap() + activeStreamType = null activeTorrentInfoHash = infoHash activeTorrentFileIdx = stream.fileIdx activeTorrentFilename = stream.behaviorHints.filename @@ -206,6 +207,7 @@ internal fun PlayerScreenRuntime.switchToP2pEpisodeStream( activeSourceAudioUrl = null activeSourceHeaders = emptyMap() activeSourceResponseHeaders = emptyMap() + activeStreamType = null activeTorrentInfoHash = infoHash activeTorrentFileIdx = stream.fileIdx activeTorrentFilename = stream.behaviorHints.filename @@ -255,6 +257,7 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) { activeSourceAudioUrl = null activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) + activeStreamType = stream.streamType activeSourceIdentityKey = sourceIdentityKey activeStreamTitle = stream.streamLabel activeStreamSubtitle = stream.streamSubtitle @@ -302,6 +305,7 @@ internal fun PlayerScreenRuntime.switchToEpisodeStream(stream: StreamItem, episo activeSourceAudioUrl = null activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) + activeStreamType = stream.streamType applyEpisodeStreamMetadata(stream, episode, resume) } @@ -329,6 +333,7 @@ internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: Downloa activeSourceAudioUrl = null activeSourceHeaders = emptyMap() activeSourceResponseHeaders = emptyMap() + activeStreamType = null activeSourceIdentityKey = null activeStreamTitle = downloadItem.streamTitle.ifBlank { episode.title.ifBlank { title } @@ -476,5 +481,6 @@ private fun PlayerScreenRuntime.saveDirectStreamForReuse( filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, + streamType = stream.streamType, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index ed4f44ea..15aaac6b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -32,6 +32,7 @@ internal class PlayerScreenRuntime( val sourceAudioUrl: String? get() = args.sourceAudioUrl val sourceHeaders: Map get() = args.sourceHeaders val sourceResponseHeaders: Map get() = args.sourceResponseHeaders + val streamType: String? get() = args.streamType val providerName: String get() = args.providerName val streamTitle: String get() = args.streamTitle val streamSubtitle: String? get() = args.streamSubtitle @@ -95,6 +96,7 @@ internal class PlayerScreenRuntime( var activeSourceAudioUrl by mutableStateOf(sourceAudioUrl) var activeSourceHeaders by mutableStateOf(sanitizePlaybackHeaders(sourceHeaders)) var activeSourceResponseHeaders by mutableStateOf(sanitizePlaybackResponseHeaders(sourceResponseHeaders)) + var activeStreamType by mutableStateOf(streamType) var activeTorrentInfoHash by mutableStateOf(torrentInfoHash) var activeTorrentFileIdx by mutableStateOf(torrentFileIdx) var activeTorrentFilename by mutableStateOf(torrentFilename) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 0d28e6df..c5bd64cf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -372,6 +372,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { sourceAudioUrl = activeSourceAudioUrl, sourceHeaders = activeSourceHeaders, sourceResponseHeaders = activeSourceResponseHeaders, + streamType = activeStreamType, modifier = Modifier.fillMaxSize(), playWhenReady = shouldPlay, resizeMode = resizeMode, 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 c78e27da..8031b405 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 @@ -503,6 +503,7 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem infoHash = infoHash, addonName = scraper.name, addonId = "plugin:${scraper.id}", + streamType = type, behaviorHints = if (requestHeaders.isEmpty()) { com.nuvio.app.features.streams.StreamBehaviorHints() } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt index f218ae74..e3a7e8ef 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt @@ -18,6 +18,7 @@ data class CachedStreamLink( val fileIdx: Int? = null, val sources: List = emptyList(), val bingeGroup: String? = null, + val streamType: String? = null, ) internal expect fun epochMs(): Long @@ -54,6 +55,7 @@ object StreamLinkCacheRepository { fileIdx: Int? = null, sources: List = emptyList(), bingeGroup: String? = null, + streamType: String? = null, ) { if (url.isNotBlank() && url.hasLikelyExpiringPlaybackCredentials()) { remove(contentKey) @@ -74,6 +76,7 @@ object StreamLinkCacheRepository { fileIdx = fileIdx, sources = sources, bingeGroup = bingeGroup, + streamType = streamType, ) val payload = json.encodeToString(CachedStreamLink.serializer(), entry) StreamLinkCacheStorage.saveEntry(hashedKey(contentKey), payload) 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 4719a89d..74dd6780 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 @@ -18,6 +18,7 @@ data class StreamItem( val addonName: String, val addonId: String, val addonLogo: String? = null, + val streamType: String? = null, val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), val clientResolve: StreamClientResolve? = null, val debridCacheStatus: StreamDebridCacheStatus? = null, 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 a27033ed..28ca1adf 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,6 +48,7 @@ object StreamParser { addonName = addonName, addonId = addonId, addonLogo = addonLogo, + streamType = 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 49fc361a..21eb0047 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,6 +915,7 @@ private fun PluginRuntimeResult.toStreamItem( sourceName = scraper.name, addonName = addonName, addonId = addonId, + streamType = 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 41519dac..bc9f4302 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 @@ -171,4 +171,47 @@ class StreamParserTest { assertEquals("2160p", stream.clientResolve?.stream?.raw?.parsed?.resolution) assertEquals(listOf(1, 2), stream.clientResolve?.stream?.raw?.parsed?.episodes) } + + @Test + fun `parse keeps addon-declared streamType`() { + 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 `parse leaves streamType null when addon omits it`() { + val streams = StreamParser.parse( + payload = + """ + { + "streams": [ + { + "url": "https://example.com/video.mp4", + "name": "1080p" + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + assertEquals(null, streams.single().streamType) + } } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt index b1c20ca2..d135fb3c 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt @@ -29,6 +29,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl: String?, sourceHeaders: Map, sourceResponseHeaders: Map, + streamType: String?, useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index a32f0d36..5ec4ca70 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -29,6 +29,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl: String?, sourceHeaders: Map, sourceResponseHeaders: Map, + streamType: String?, useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, From fe747ffc3eff5779ebd6561fc7666b9aea2825da Mon Sep 17 00:00:00 2001 From: Aniket Tuli Date: Thu, 11 Jun 2026 17:02:22 -0700 Subject: [PATCH 2/2] 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 17492a73..d6eb5d94 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 031ae310..073ae7f7 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 8031b405..eed8ac51 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 74dd6780..c87143fb 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 28ca1adf..ab1df959 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 21eb0047..b9f34927 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 bc9f4302..d6ddf28d 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(