mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Stabilize HLS playback timestamps
This commit is contained in:
@@ -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()
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user