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>, videoCodecs: List<String>,
maxAudioChannels: Int maxAudioChannels: Int
): DeviceProfile { ): DeviceProfile {
val hlsOptions = HlsPlaybackStability.conservativeHlsOptions
return DeviceProfile( return DeviceProfile(
name = "Android Media3", name = "Android Media3",
maxStaticBitrate = 100_000_000, maxStaticBitrate = 100_000_000,
@@ -94,8 +96,10 @@ object AndroidDeviceProfile {
protocol = MediaStreamProtocol.HLS, protocol = MediaStreamProtocol.HLS,
context = EncodingContext.STREAMING, context = EncodingContext.STREAMING,
maxAudioChannels = maxAudioChannels.toString(), maxAudioChannels = maxAudioChannels.toString(),
minSegments = 2, minSegments = hlsOptions.minSegments,
breakOnNonKeyFrames = true, segmentLength = hlsOptions.segmentLengthSeconds,
breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames,
copyTimestamps = hlsOptions.copyTimestamps,
conditions = emptyList() 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 { companion object {
private const val TAG = "JellyfinApiClient" private const val TAG = "JellyfinApiClient"
private const val MAX_STREAMING_BITRATE = 100_000_000 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 { private val jellyfin = createJellyfin {
@@ -405,6 +413,11 @@ class JellyfinApiClient @Inject constructor(
} }
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(applicationContext) 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( val response = api.mediaInfoApi.getPostedPlaybackInfo(
mediaId, mediaId,
PlaybackInfoDto( PlaybackInfoDto(
@@ -467,6 +480,14 @@ class JellyfinApiClient @Inject constructor(
playSessionId: String?, playSessionId: String?,
maxAudioChannels: Int maxAudioChannels: Int
): String { ): 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( return api.videosApi.getVideoStreamUrl(
itemId = mediaId, itemId = mediaId,
static = false, static = false,
@@ -474,9 +495,13 @@ class JellyfinApiClient @Inject constructor(
playSessionId = playSessionId, playSessionId = playSessionId,
liveStreamId = mediaSource.liveStreamId, liveStreamId = mediaSource.liveStreamId,
container = mediaSource.transcodingContainer ?: mediaSource.container, container = mediaSource.transcodingContainer ?: mediaSource.container,
segmentLength = hlsOptions.segmentLengthSeconds,
minSegments = hlsOptions.minSegments,
enableAutoStreamCopy = true, enableAutoStreamCopy = true,
allowVideoStreamCopy = true, allowVideoStreamCopy = true,
allowAudioStreamCopy = true, allowAudioStreamCopy = true,
breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames,
copyTimestamps = hlsOptions.copyTimestamps,
maxAudioChannels = maxAudioChannels maxAudioChannels = maxAudioChannels
) )
} }
@@ -487,7 +512,15 @@ class JellyfinApiClient @Inject constructor(
playSessionId: String?, playSessionId: String?,
maxAudioChannels: Int maxAudioChannels: Int
): String? { ): String? {
val hlsOptions = streamingHlsOptionsFor(PlaybackSourcePlanner.UrlStrategy.TRANSCODE)
?: error("Transcode requests must use HLS stability options.")
mediaSource.transcodingUrl?.takeIf { it.isNotBlank() }?.let { transcodingUrl -> 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)) { if (transcodingUrl.startsWith("http", ignoreCase = true)) {
return transcodingUrl return transcodingUrl
} }
@@ -495,6 +528,11 @@ class JellyfinApiClient @Inject constructor(
return "$baseUrl$transcodingUrl" 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( return api.videosApi.getVideoStreamUrl(
itemId = mediaId, itemId = mediaId,
static = false, static = false,
@@ -502,9 +540,13 @@ class JellyfinApiClient @Inject constructor(
playSessionId = playSessionId, playSessionId = playSessionId,
liveStreamId = mediaSource.liveStreamId, liveStreamId = mediaSource.liveStreamId,
container = mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4", container = mediaSource.transcodingContainer ?: mediaSource.container ?: "mp4",
segmentLength = hlsOptions.segmentLengthSeconds,
minSegments = hlsOptions.minSegments,
enableAutoStreamCopy = false, enableAutoStreamCopy = false,
allowVideoStreamCopy = false, allowVideoStreamCopy = false,
allowAudioStreamCopy = false, allowAudioStreamCopy = false,
breakOnNonKeyFrames = hlsOptions.breakOnNonKeyFrames,
copyTimestamps = hlsOptions.copyTimestamps,
maxAudioChannels = maxAudioChannels 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.EncodingContext
import org.jellyfin.sdk.model.api.MediaStreamProtocol import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -25,7 +26,9 @@ class AndroidDeviceProfileTest {
assertEquals(MediaStreamProtocol.HLS, transcodingProfile.protocol) assertEquals(MediaStreamProtocol.HLS, transcodingProfile.protocol)
assertEquals(EncodingContext.STREAMING, transcodingProfile.context) assertEquals(EncodingContext.STREAMING, transcodingProfile.context)
assertEquals("6", transcodingProfile.maxAudioChannels) assertEquals("6", transcodingProfile.maxAudioChannels)
assertEquals(2, transcodingProfile.minSegments) assertEquals(3, transcodingProfile.minSegments)
assertTrue(transcodingProfile.breakOnNonKeyFrames) 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)
}
}