From 69ae10c761bfb7c283b40e4ea8068950ae72e688 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Tue, 7 Apr 2026 17:18:42 +0200 Subject: [PATCH] Stabilize HLS playback timestamps --- .../core/data/client/AndroidDeviceProfile.kt | 8 +++- .../core/data/client/HlsPlaybackStability.kt | 17 ++++++++ .../core/data/client/JellyfinApiClient.kt | 42 +++++++++++++++++++ .../data/client/AndroidDeviceProfileTest.kt | 7 +++- .../core/data/client/JellyfinApiClientTest.kt | 29 +++++++++++++ 5 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 core/data/src/main/java/hu/bbara/purefin/core/data/client/HlsPlaybackStability.kt create mode 100644 core/data/src/test/java/hu/bbara/purefin/core/data/client/JellyfinApiClientTest.kt diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt index c07ee215..1c43e3e6 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt @@ -68,6 +68,8 @@ object AndroidDeviceProfile { videoCodecs: List, maxAudioChannels: Int ): DeviceProfile { + val hlsOptions = HlsPlaybackStability.conservativeHlsOptions + return DeviceProfile( name = "Android Media3", maxStaticBitrate = 100_000_000, @@ -94,8 +96,10 @@ object AndroidDeviceProfile { protocol = MediaStreamProtocol.HLS, context = EncodingContext.STREAMING, maxAudioChannels = maxAudioChannels.toString(), - minSegments = 2, - breakOnNonKeyFrames = true, + minSegments = hlsOptions.minSegments, + segmentLength = hlsOptions.segmentLengthSeconds, + breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames, + copyTimestamps = hlsOptions.copyTimestamps, conditions = emptyList() ) ), diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/HlsPlaybackStability.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/HlsPlaybackStability.kt new file mode 100644 index 00000000..2557ef8e --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/HlsPlaybackStability.kt @@ -0,0 +1,17 @@ +package hu.bbara.purefin.core.data.client + +internal data class HlsStabilityOptions( + val segmentLengthSeconds: Int, + val minSegments: Int, + val breakOnNonKeyFrames: Boolean, + val copyTimestamps: Boolean +) + +internal object HlsPlaybackStability { + val conservativeHlsOptions = HlsStabilityOptions( + segmentLengthSeconds = 6, + minSegments = 3, + breakOnNonKeyFrames = false, + copyTimestamps = true + ) +} diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt index e8691a58..cdfdc8f1 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/JellyfinApiClient.kt @@ -50,6 +50,14 @@ class JellyfinApiClient @Inject constructor( companion object { private const val TAG = "JellyfinApiClient" private const val MAX_STREAMING_BITRATE = 100_000_000 + + internal fun streamingHlsOptionsFor( + urlStrategy: PlaybackSourcePlanner.UrlStrategy + ): HlsStabilityOptions? = when (urlStrategy) { + PlaybackSourcePlanner.UrlStrategy.DIRECT_PLAY -> null + PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM, + PlaybackSourcePlanner.UrlStrategy.TRANSCODE -> HlsPlaybackStability.conservativeHlsOptions + } } private val jellyfin = createJellyfin { @@ -405,6 +413,11 @@ class JellyfinApiClient @Inject constructor( } val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext) + Log.d( + TAG, + "Playback request for $mediaId (forceTranscode=$forceTranscode) maxAudioChannels=${capabilitySnapshot.maxAudioChannels}, " + + "hlsOptions=${HlsPlaybackStability.conservativeHlsOptions}" + ) val response = api.mediaInfoApi.getPostedPlaybackInfo( mediaId, PlaybackInfoDto( @@ -467,6 +480,14 @@ class JellyfinApiClient @Inject constructor( playSessionId: String?, maxAudioChannels: Int ): String { + val hlsOptions = streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM) + ?: error("Direct stream requests must use HLS stability options.") + + Log.d( + TAG, + "Stream URL request for $mediaId strategy=DIRECT_STREAM container=${mediaSource.transcodingContainer ?: mediaSource.container}, " + + "hlsOptions=$hlsOptions" + ) return api.videosApi.getVideoStreamUrl( itemId = mediaId, static = false, @@ -474,9 +495,13 @@ class JellyfinApiClient @Inject constructor( playSessionId = playSessionId, liveStreamId = mediaSource.liveStreamId, container = mediaSource.transcodingContainer ?: mediaSource.container, + segmentLength = hlsOptions.segmentLengthSeconds, + minSegments = hlsOptions.minSegments, enableAutoStreamCopy = true, allowVideoStreamCopy = true, allowAudioStreamCopy = true, + breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames, + copyTimestamps = hlsOptions.copyTimestamps, maxAudioChannels = maxAudioChannels ) } @@ -487,7 +512,15 @@ class JellyfinApiClient @Inject constructor( playSessionId: String?, maxAudioChannels: Int ): String? { + val hlsOptions = streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.TRANSCODE) + ?: error("Transcode requests must use HLS stability options.") + mediaSource.transcodingUrl?.takeIf { it.isNotBlank() }?.let { transcodingUrl -> + Log.d( + TAG, + "Using server transcodingUrl for $mediaId strategy=TRANSCODE container=${mediaSource.transcodingContainer ?: mediaSource.container}, " + + "hlsOptions=$hlsOptions" + ) if (transcodingUrl.startsWith("http", ignoreCase = true)) { return transcodingUrl } @@ -495,6 +528,11 @@ class JellyfinApiClient @Inject constructor( return "$baseUrl$transcodingUrl" } + Log.d( + TAG, + "Stream URL request for $mediaId strategy=TRANSCODE container=${mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4"}, " + + "hlsOptions=$hlsOptions" + ) return api.videosApi.getVideoStreamUrl( itemId = mediaId, static = false, @@ -502,9 +540,13 @@ class JellyfinApiClient @Inject constructor( playSessionId = playSessionId, liveStreamId = mediaSource.liveStreamId, container = mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4", + segmentLength = hlsOptions.segmentLengthSeconds, + minSegments = hlsOptions.minSegments, enableAutoStreamCopy = false, allowVideoStreamCopy = false, allowAudioStreamCopy = false, + breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames, + copyTimestamps = hlsOptions.copyTimestamps, maxAudioChannels = maxAudioChannels ) } diff --git a/core/data/src/test/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfileTest.kt b/core/data/src/test/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfileTest.kt index e02f328d..b304ab29 100644 --- a/core/data/src/test/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfileTest.kt +++ b/core/data/src/test/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfileTest.kt @@ -4,6 +4,7 @@ import org.jellyfin.sdk.model.api.DlnaProfileType import org.jellyfin.sdk.model.api.EncodingContext import org.jellyfin.sdk.model.api.MediaStreamProtocol import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -25,7 +26,9 @@ class AndroidDeviceProfileTest { assertEquals(MediaStreamProtocol.HLS, transcodingProfile.protocol) assertEquals(EncodingContext.STREAMING, transcodingProfile.context) assertEquals("6", transcodingProfile.maxAudioChannels) - assertEquals(2, transcodingProfile.minSegments) - assertTrue(transcodingProfile.breakOnNonKeyFrames) + assertEquals(3, transcodingProfile.minSegments) + assertEquals(6, transcodingProfile.segmentLength) + assertFalse(transcodingProfile.breakOnNonKeyFrames) + assertTrue(transcodingProfile.copyTimestamps) } } diff --git a/core/data/src/test/java/hu/bbara/purefin/core/data/client/JellyfinApiClientTest.kt b/core/data/src/test/java/hu/bbara/purefin/core/data/client/JellyfinApiClientTest.kt new file mode 100644 index 00000000..2a281cfa --- /dev/null +++ b/core/data/src/test/java/hu/bbara/purefin/core/data/client/JellyfinApiClientTest.kt @@ -0,0 +1,29 @@ +package hu.bbara.purefin.core.data.client + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class JellyfinApiClientTest { + @Test + fun `direct play requests do not apply HLS stability options`() { + val options = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_PLAY) + + assertNull(options) + } + + @Test + fun `direct stream requests use conservative HLS stability options`() { + val options = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM) + + assertEquals(HlsPlaybackStability.conservativeHlsOptions, options) + } + + @Test + fun `transcode requests use the same HLS stability options as direct stream`() { + val directStreamOptions = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.DIRECT_STREAM) + val transcodeOptions = JellyfinApiClient.streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.TRANSCODE) + + assertEquals(directStreamOptions, transcodeOptions) + } +}