From 42a4ee2d66e0ede4d86e392d04d892fa544e94e7 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:58:35 +0530 Subject: [PATCH] feat: add support for response headers in playback data source handling --- ...atformPlaybackDataSourceFactory.android.kt | 14 +++++- .../features/player/PlayerEngine.android.kt | 7 ++- .../ResponseHeaderOverridingDataSource.kt | 50 +++++++++++++++++++ ...atformPlaybackDataSourceFactory.android.kt | 15 +++++- .../commonMain/kotlin/com/nuvio/app/App.kt | 10 ++++ .../nuvio/app/features/player/PlayerEngine.kt | 15 ++++++ .../nuvio/app/features/player/PlayerModels.kt | 1 + .../nuvio/app/features/player/PlayerScreen.kt | 13 ++++- .../streams/StreamLinkCacheRepository.kt | 6 +++ .../app/features/streams/StreamParserTest.kt | 31 ++++++++++++ .../app/features/player/PlayerEngine.ios.kt | 2 + 11 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ResponseHeaderOverridingDataSource.kt diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt index c5201af6..2a30c309 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt @@ -7,11 +7,21 @@ import com.nuvio.app.features.trailer.YoutubeChunkedDataSourceFactory internal object PlatformPlaybackDataSourceFactory { fun create( defaultRequestHeaders: Map, + defaultResponseHeaders: Map, useYoutubeChunkedPlayback: Boolean, - ): DataSource.Factory = - if (useYoutubeChunkedPlayback) { + ): DataSource.Factory { + val baseFactory: DataSource.Factory = if (useYoutubeChunkedPlayback) { YoutubeChunkedDataSourceFactory(defaultRequestHeaders = defaultRequestHeaders) } else { DefaultHttpDataSource.Factory().setDefaultRequestProperties(defaultRequestHeaders) } + return if (defaultResponseHeaders.isEmpty()) { + baseFactory + } else { + ResponseHeaderOverridingDataSourceFactory( + upstreamFactory = baseFactory, + defaultResponseHeaders = defaultResponseHeaders, + ) + } + } } \ No newline at end of file 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 3d907a9f..ba5fea4d 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 @@ -63,6 +63,7 @@ actual fun PlatformPlayerSurface( sourceUrl: String, sourceAudioUrl: String?, sourceHeaders: Map, + sourceResponseHeaders: Map, useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, @@ -86,8 +87,11 @@ actual fun PlatformPlayerSurface( val sanitizedSourceHeaders = remember(sourceHeaders) { sanitizePlaybackHeaders(sourceHeaders) } + val sanitizedSourceResponseHeaders = remember(sourceResponseHeaders) { + sanitizePlaybackResponseHeaders(sourceResponseHeaders) + } - val exoPlayer = remember(sourceUrl, sourceAudioUrl, sanitizedSourceHeaders) { + val exoPlayer = remember(sourceUrl, sourceAudioUrl, sanitizedSourceHeaders, sanitizedSourceResponseHeaders) { val renderersFactory = DefaultRenderersFactory(context) .setExtensionRendererMode(playerSettings.decoderPriority) .setMapDV7ToHevc(playerSettings.mapDV7ToHevc) @@ -119,6 +123,7 @@ actual fun PlatformPlayerSurface( val mediaSourceFactory = DefaultMediaSourceFactory( PlatformPlaybackDataSourceFactory.create( defaultRequestHeaders = sanitizedSourceHeaders, + defaultResponseHeaders = sanitizedSourceResponseHeaders, useYoutubeChunkedPlayback = useYoutubeChunkedPlayback, ), extractorsFactory, diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ResponseHeaderOverridingDataSource.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ResponseHeaderOverridingDataSource.kt new file mode 100644 index 00000000..a132fcdc --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ResponseHeaderOverridingDataSource.kt @@ -0,0 +1,50 @@ +package com.nuvio.app.features.player + +import android.net.Uri +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener + +internal class ResponseHeaderOverridingDataSourceFactory( + private val upstreamFactory: DataSource.Factory, + private val defaultResponseHeaders: Map, +) : DataSource.Factory { + override fun createDataSource(): DataSource = + ResponseHeaderOverridingDataSource( + upstream = upstreamFactory.createDataSource(), + defaultResponseHeaders = defaultResponseHeaders, + ) +} + +private class ResponseHeaderOverridingDataSource( + private val upstream: DataSource, + private val defaultResponseHeaders: Map, +) : DataSource { + + override fun addTransferListener(transferListener: TransferListener) { + upstream.addTransferListener(transferListener) + } + + override fun open(dataSpec: DataSpec): Long = upstream.open(dataSpec) + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = + upstream.read(buffer, offset, length) + + override fun getUri(): Uri? = upstream.uri + + override fun getResponseHeaders(): Map> { + val upstreamHeaders = upstream.responseHeaders + if (defaultResponseHeaders.isEmpty()) return upstreamHeaders + + val merged = LinkedHashMap>(upstreamHeaders.size + defaultResponseHeaders.size) + merged.putAll(upstreamHeaders) + defaultResponseHeaders.forEach { (key, value) -> + merged[key] = listOf(value) + } + return merged + } + + override fun close() { + upstream.close() + } +} diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt index 946488b7..0935a904 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt @@ -6,7 +6,18 @@ import androidx.media3.datasource.DefaultHttpDataSource internal object PlatformPlaybackDataSourceFactory { fun create( defaultRequestHeaders: Map, + defaultResponseHeaders: Map, useYoutubeChunkedPlayback: Boolean, - ): DataSource.Factory = - DefaultHttpDataSource.Factory().setDefaultRequestProperties(defaultRequestHeaders) + ): DataSource.Factory { + val baseFactory = DefaultHttpDataSource.Factory() + .setDefaultRequestProperties(defaultRequestHeaders) + return if (defaultResponseHeaders.isEmpty()) { + baseFactory + } else { + ResponseHeaderOverridingDataSourceFactory( + upstreamFactory = baseFactory, + defaultResponseHeaders = defaultResponseHeaders, + ) + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index fd77bd3d..5ecf9ec9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -104,6 +104,7 @@ import com.nuvio.app.features.player.PlayerLaunchStore import com.nuvio.app.features.player.PlayerRoute import com.nuvio.app.features.player.PlayerScreen import com.nuvio.app.features.player.sanitizePlaybackHeaders +import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders import com.nuvio.app.features.profiles.NuvioProfile import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileRepository @@ -901,6 +902,8 @@ private fun MainAppContent( PlayerLaunch( title = route.title, sourceUrl = cached.url, + sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders), + sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders), logo = route.logo, poster = route.poster, background = route.background, @@ -947,6 +950,8 @@ private fun MainAppContent( streamName = stream.streamLabel, addonName = stream.addonName, addonId = stream.addonId, + requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), + responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, @@ -957,6 +962,7 @@ private fun MainAppContent( title = route.title, sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), + sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), logo = route.logo, poster = route.poster, background = route.background, @@ -1024,6 +1030,8 @@ private fun MainAppContent( streamName = stream.streamLabel, addonName = stream.addonName, addonId = stream.addonId, + requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), + responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, @@ -1034,6 +1042,7 @@ private fun MainAppContent( title = route.title, sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), + sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), logo = route.logo, poster = route.poster, background = route.background, @@ -1097,6 +1106,7 @@ private fun MainAppContent( sourceUrl = launch.sourceUrl, sourceAudioUrl = launch.sourceAudioUrl, sourceHeaders = launch.sourceHeaders, + sourceResponseHeaders = launch.sourceResponseHeaders, logo = launch.logo, poster = launch.poster, background = launch.background, 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 e7128c85..8a5b6730 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 @@ -35,11 +35,26 @@ internal fun sanitizePlaybackHeaders(headers: Map?): Map?): Map { + val rawHeaders = headers ?: return emptyMap() + if (rawHeaders.isEmpty()) return emptyMap() + + val sanitized = LinkedHashMap(rawHeaders.size) + rawHeaders.forEach { (rawKey, rawValue) -> + val key = rawKey.trim() + val value = rawValue.trim() + if (key.isEmpty() || value.isEmpty()) return@forEach + sanitized[key] = value + } + return sanitized +} + @Composable expect fun PlatformPlayerSurface( sourceUrl: String, sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), + sourceResponseHeaders: Map = emptyMap(), 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 b958ae4e..f659084a 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 @@ -12,6 +12,7 @@ data class PlayerLaunch( val sourceUrl: String, val sourceAudioUrl: String? = null, val sourceHeaders: Map = emptyMap(), + val sourceResponseHeaders: Map = emptyMap(), 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 ef443a3c..7e0bee9b 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 @@ -85,6 +85,7 @@ fun PlayerScreen( sourceUrl: String, sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), + sourceResponseHeaders: Map = emptyMap(), providerName: String, streamTitle: String, streamSubtitle: String?, @@ -132,6 +133,9 @@ fun PlayerScreen( var activeSourceHeaders by remember(sourceUrl, sourceHeaders) { mutableStateOf(sanitizePlaybackHeaders(sourceHeaders)) } + var activeSourceResponseHeaders by remember(sourceUrl, sourceResponseHeaders) { + mutableStateOf(sanitizePlaybackResponseHeaders(sourceResponseHeaders)) + } var activeStreamTitle by rememberSaveable { mutableStateOf(streamTitle) } var activeStreamSubtitle by rememberSaveable { mutableStateOf(streamSubtitle) } var activeProviderName by rememberSaveable { mutableStateOf(providerName) } @@ -522,6 +526,8 @@ fun PlayerScreen( streamName = stream.streamLabel, addonName = stream.addonName, addonId = stream.addonId, + requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), + responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, @@ -530,6 +536,7 @@ fun PlayerScreen( activeSourceUrl = url activeSourceAudioUrl = null activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) + activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) activeStreamTitle = stream.streamLabel activeStreamSubtitle = stream.streamSubtitle activeProviderName = stream.addonName @@ -577,6 +584,8 @@ fun PlayerScreen( streamName = stream.streamLabel, addonName = stream.addonName, addonId = stream.addonId, + requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), + responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, @@ -585,6 +594,7 @@ fun PlayerScreen( activeSourceUrl = url activeSourceAudioUrl = null activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) + activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) activeStreamTitle = stream.streamLabel activeStreamSubtitle = stream.streamSubtitle activeProviderName = stream.addonName @@ -740,7 +750,7 @@ fun PlayerScreen( controlsVisible = false } - LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders) { + LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders, activeSourceResponseHeaders) { errorMessage = null playerController = null playerControllerSourceUrl = null @@ -1103,6 +1113,7 @@ fun PlayerScreen( sourceUrl = activeSourceUrl, sourceAudioUrl = activeSourceAudioUrl, sourceHeaders = activeSourceHeaders, + sourceResponseHeaders = activeSourceResponseHeaders, modifier = Modifier.fillMaxSize(), playWhenReady = shouldPlay, resizeMode = resizeMode, 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 5490df9f..07519fd7 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 @@ -10,6 +10,8 @@ data class CachedStreamLink( val addonName: String, val addonId: String, val cachedAtMs: Long, + val requestHeaders: Map = emptyMap(), + val responseHeaders: Map = emptyMap(), val filename: String? = null, val videoSize: Long? = null, val bingeGroup: String? = null, @@ -29,6 +31,8 @@ object StreamLinkCacheRepository { streamName: String, addonName: String, addonId: String, + requestHeaders: Map = emptyMap(), + responseHeaders: Map = emptyMap(), filename: String? = null, videoSize: Long? = null, bingeGroup: String? = null, @@ -39,6 +43,8 @@ object StreamLinkCacheRepository { addonName = addonName, addonId = addonId, cachedAtMs = epochMs(), + requestHeaders = requestHeaders, + responseHeaders = responseHeaders, filename = filename, videoSize = videoSize, bingeGroup = bingeGroup, 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 d1a554b7..3f297d04 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 @@ -88,4 +88,35 @@ class StreamParserTest { val stream = streams.single() assertFalse(stream.behaviorHints.notWebReady) } + + @Test + fun `parse keeps proxy response headers`() { + val streams = StreamParser.parse( + payload = + """ + { + "streams": [ + { + "url": "https://example.com/video.mp4", + "behaviorHints": { + "proxyHeaders": { + "response": { + "content-type": "video/mp4", + "x-test": "ok" + } + } + } + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + val responseHeaders = streams.single().behaviorHints.proxyHeaders?.response + assertNotNull(responseHeaders) + assertEquals("video/mp4", responseHeaders["content-type"]) + assertEquals("ok", responseHeaders["x-test"]) + } } 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 a1cc303f..a0286b9f 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 @@ -23,6 +23,7 @@ actual fun PlatformPlayerSurface( sourceUrl: String, sourceAudioUrl: String?, sourceHeaders: Map, + sourceResponseHeaders: Map, useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, @@ -32,6 +33,7 @@ actual fun PlatformPlayerSurface( onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, ) { + sanitizePlaybackResponseHeaders(sourceResponseHeaders) val latestOnSnapshot = rememberUpdatedState(onSnapshot) val latestOnError = rememberUpdatedState(onError)