Stabilize HLS playback timestamps

This commit is contained in:
2026-04-07 17:18:42 +02:00
parent 985fdec3a5
commit 69ae10c761
5 changed files with 99 additions and 4 deletions

View File

@@ -68,6 +68,8 @@ object AndroidDeviceProfile {
videoCodecs: List<String>,
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()
)
),

View File

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

View File

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

View File

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

View File

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