Phase 5.2.4: Tune HLS segmentation for HA recovery (#29)
* Initial plan * Phase 5.2.4: Tune HLS behavior for recovery with configurable segment parameters Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com> * fix: SA1516 — add blank line between MakeLiveStreamKey and Clone helpers StyleCop SA1516 requires elements to be separated by blank lines. Missing blank line at line 370 caused build failure in Phase 5 tests. Closes #28 * fix: SA1516 — blank line in InMemoryTranscodeSessionStore between helpers --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com> Co-authored-by: mat <mstrommen@gmail.com>
This commit is contained in:
@@ -313,10 +313,25 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
// If the playlist doesn't already exist, startup ffmpeg
|
// If the playlist doesn't already exist, startup ffmpeg
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Check whether this session is already registered in the HA store (takeover scenario).
|
||||||
|
var isHaMode = false;
|
||||||
|
if (!string.IsNullOrEmpty(playSessionId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existingSession = await _transcodeSessionStore.TryGetAsync(playSessionId, cancellationToken).ConfigureAwait(false);
|
||||||
|
isHaMode = existingSession is not null;
|
||||||
|
}
|
||||||
|
catch (Exception haEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(haEx, "Failed to check HA mode for live-stream session {PlaySessionId}.", playSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
job = await _transcodeManager.StartFfMpeg(
|
job = await _transcodeManager.StartFfMpeg(
|
||||||
state,
|
state,
|
||||||
playlistPath,
|
playlistPath,
|
||||||
GetCommandLineArguments(playlistPath, state, true, 0),
|
GetCommandLineArguments(playlistPath, state, true, 0, isHaMode),
|
||||||
Request.HttpContext.User.GetUserId(),
|
Request.HttpContext.User.GetUserId(),
|
||||||
TranscodingJobType,
|
TranscodingJobType,
|
||||||
cancellationTokenSource)
|
cancellationTokenSource)
|
||||||
@@ -1545,11 +1560,26 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
|
|
||||||
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
|
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
|
||||||
|
|
||||||
|
// Check whether this session is already registered in the HA store (takeover scenario).
|
||||||
|
var isHaMode = false;
|
||||||
|
if (!string.IsNullOrEmpty(streamingRequest.PlaySessionId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existingSession = await _transcodeSessionStore.TryGetAsync(streamingRequest.PlaySessionId, cancellationToken).ConfigureAwait(false);
|
||||||
|
isHaMode = existingSession is not null;
|
||||||
|
}
|
||||||
|
catch (Exception haEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(haEx, "Failed to check HA mode for segment session {PlaySessionId}.", streamingRequest.PlaySessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.WaitForPath = segmentPath;
|
state.WaitForPath = segmentPath;
|
||||||
job = await _transcodeManager.StartFfMpeg(
|
job = await _transcodeManager.StartFfMpeg(
|
||||||
state,
|
state,
|
||||||
playlistPath,
|
playlistPath,
|
||||||
GetCommandLineArguments(playlistPath, state, false, segmentId),
|
GetCommandLineArguments(playlistPath, state, false, segmentId, isHaMode),
|
||||||
Request.HttpContext.User.GetUserId(),
|
Request.HttpContext.User.GetUserId(),
|
||||||
TranscodingJobType,
|
TranscodingJobType,
|
||||||
cancellationTokenSource).ConfigureAwait(false);
|
cancellationTokenSource).ConfigureAwait(false);
|
||||||
@@ -1678,7 +1708,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber)
|
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber, bool isHaMode = false)
|
||||||
{
|
{
|
||||||
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
|
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
|
||||||
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
|
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
|
||||||
@@ -1701,10 +1731,20 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
|
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
|
||||||
var outputTsArg = outputPrefix + "%d" + outputExtension;
|
var outputTsArg = outputPrefix + "%d" + outputExtension;
|
||||||
|
|
||||||
|
// In HA mode, use shorter segments and a bounded rolling buffer for faster failover recovery.
|
||||||
|
// state.SegmentLength is already validated by the streaming pipeline; RecoverySegmentLengthSeconds
|
||||||
|
// comes from EncodingOptions (user-editable config) so it is clamped here.
|
||||||
|
var effectiveSegmentLength = isHaMode
|
||||||
|
? Math.Clamp(_encodingOptions.RecoverySegmentLengthSeconds, 1, 6)
|
||||||
|
: state.SegmentLength;
|
||||||
|
var hlsListSize = isHaMode
|
||||||
|
? Math.Clamp(_encodingOptions.RecoverySegmentBufferCount, 2, 10)
|
||||||
|
: 0;
|
||||||
|
|
||||||
var segmentFormat = string.Empty;
|
var segmentFormat = string.Empty;
|
||||||
var segmentContainer = outputExtension.TrimStart('.');
|
var segmentContainer = outputExtension.TrimStart('.');
|
||||||
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
|
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
|
||||||
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0";
|
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size {hlsListSize}";
|
||||||
|
|
||||||
if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -1756,10 +1796,10 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
_encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
|
_encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
|
||||||
threads,
|
threads,
|
||||||
mapArgs,
|
mapArgs,
|
||||||
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer),
|
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer, effectiveSegmentLength),
|
||||||
GetAudioArguments(state),
|
GetAudioArguments(state),
|
||||||
maxMuxingQueueSize,
|
maxMuxingQueueSize,
|
||||||
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
|
effectiveSegmentLength.ToString(CultureInfo.InvariantCulture),
|
||||||
segmentFormat,
|
segmentFormat,
|
||||||
startNumber.ToString(CultureInfo.InvariantCulture),
|
startNumber.ToString(CultureInfo.InvariantCulture),
|
||||||
baseUrlParam,
|
baseUrlParam,
|
||||||
@@ -1901,8 +1941,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
/// <param name="startNumber">The first number in the hls sequence.</param>
|
/// <param name="startNumber">The first number in the hls sequence.</param>
|
||||||
/// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
|
/// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
|
||||||
/// <param name="segmentContainer">The segment container.</param>
|
/// <param name="segmentContainer">The segment container.</param>
|
||||||
|
/// <param name="segmentLength">The effective segment length in seconds (overrides <see cref="StreamState.SegmentLength"/> when HA mode is active).</param>
|
||||||
/// <returns>The command line arguments for video transcoding.</returns>
|
/// <returns>The command line arguments for video transcoding.</returns>
|
||||||
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer)
|
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer, int? segmentLength = null)
|
||||||
{
|
{
|
||||||
if (state.VideoStream is null)
|
if (state.VideoStream is null)
|
||||||
{
|
{
|
||||||
@@ -1977,7 +2018,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
|
args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
|
||||||
|
|
||||||
// Set the key frame params for video encoding to match the hls segment time.
|
// Set the key frame params for video encoding to match the hls segment time.
|
||||||
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber);
|
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, segmentLength ?? state.SegmentLength, isEventPlaylist, startNumber);
|
||||||
|
|
||||||
// Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
|
// Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
|
||||||
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
|
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public class EncodingOptions
|
|||||||
ThrottleDelaySeconds = 180;
|
ThrottleDelaySeconds = 180;
|
||||||
EnableSegmentDeletion = false;
|
EnableSegmentDeletion = false;
|
||||||
SegmentKeepSeconds = 720;
|
SegmentKeepSeconds = 720;
|
||||||
|
RecoverySegmentLengthSeconds = 2;
|
||||||
|
RecoverySegmentBufferCount = 5;
|
||||||
EncodingThreadCount = -1;
|
EncodingThreadCount = -1;
|
||||||
// This is a DRM device that is almost guaranteed to be there on every intel platform,
|
// This is a DRM device that is almost guaranteed to be there on every intel platform,
|
||||||
// plus it's the default one in ffmpeg if you don't specify anything
|
// plus it's the default one in ffmpeg if you don't specify anything
|
||||||
@@ -121,6 +123,20 @@ public class EncodingOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int SegmentKeepSeconds { get; set; }
|
public int SegmentKeepSeconds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the HLS segment length in seconds to use when HA recovery mode is active.
|
||||||
|
/// Shorter segments allow a takeover pod to resume playback faster after a peer failure.
|
||||||
|
/// Default is <c>2</c>. Valid range is 1–6.
|
||||||
|
/// </summary>
|
||||||
|
public int RecoverySegmentLengthSeconds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of HLS segments to keep on disk when HA recovery mode is active.
|
||||||
|
/// This acts as a rolling buffer that a takeover pod can serve while restarting the transcode.
|
||||||
|
/// Default is <c>5</c>. Valid range is 2–10.
|
||||||
|
/// </summary>
|
||||||
|
public int RecoverySegmentBufferCount { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the hardware acceleration type.
|
/// Gets or sets the hardware acceleration type.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -430,3 +430,44 @@ seek point.
|
|||||||
- `Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs` — age-only cleanup
|
- `Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs` — age-only cleanup
|
||||||
- `Emby.Server.Implementations/Session/SessionManager.cs` — `_activeLiveStreamSessions`
|
- `Emby.Server.Implementations/Session/SessionManager.cs` — `_activeLiveStreamSessions`
|
||||||
- `kubernetes/apps/media/nfs-pv.yaml` — `nfsvers=3` confirmed
|
- `kubernetes/apps/media/nfs-pv.yaml` — `nfsvers=3` confirmed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bitrate/Segment Tradeoffs
|
||||||
|
|
||||||
|
### Why shorter segments trade throughput for faster failover
|
||||||
|
|
||||||
|
HLS streaming works by dividing a media stream into a series of short, independently decodable
|
||||||
|
segments. The segment length is a fundamental trade-off: longer segments reduce per-segment HTTP
|
||||||
|
overhead and allow FFmpeg to apply more aggressive compression across each chunk, improving overall
|
||||||
|
bitrate efficiency. Shorter segments, however, mean that when a pod fails mid-transcode, a takeover
|
||||||
|
pod only needs to rewind to the previous segment boundary — not the start of a much longer one.
|
||||||
|
With the default 6-second segment length, a client could stall for up to 6 seconds before the
|
||||||
|
takeover pod produces a new segment for it to consume. With the HA recovery default of 2 seconds
|
||||||
|
(`RecoverySegmentLengthSeconds = 2`), that stall window is reduced to at most 2 seconds of rewind,
|
||||||
|
dramatically improving the perceived continuity of playback during a pod failover.
|
||||||
|
|
||||||
|
### The rolling segment buffer and disk usage
|
||||||
|
|
||||||
|
In HA mode, `RecoverySegmentBufferCount` (default `5`) controls how many segments are retained in
|
||||||
|
the HLS playlist at any one time. This creates a rolling on-disk buffer of `5 × 2 s = 10 seconds`
|
||||||
|
of media that a takeover pod can serve immediately while it restarts FFmpeg from the last known
|
||||||
|
position. Keeping fewer segments wastes less NFS storage but shrinks the window in which a newly
|
||||||
|
promoted pod can respond to in-flight client requests without waiting for new segments to be
|
||||||
|
produced. Keeping more segments lengthens the recovery window but increases NFS write pressure and
|
||||||
|
disk usage proportionally. The valid range (2–10) was chosen so that the minimum buffer is always
|
||||||
|
at least 4 seconds (2 × 2 s) and the maximum stays under 20 seconds (10 × 2 s), balancing storage
|
||||||
|
cost against recovery robustness.
|
||||||
|
|
||||||
|
### Tuning guidance and rollback
|
||||||
|
|
||||||
|
The two knobs, `RecoverySegmentLengthSeconds` and `RecoverySegmentBufferCount`, can be adjusted in
|
||||||
|
the Jellyfin server's encoding options without restarting the service; the new values take effect on
|
||||||
|
the next transcode session that enters HA mode. To reduce disk I/O at the cost of a slightly longer
|
||||||
|
stall window, increase `RecoverySegmentLengthSeconds` toward its maximum of 6 (matching the
|
||||||
|
throughput-optimized default). To shrink the NFS footprint at the cost of a narrower recovery
|
||||||
|
window, lower `RecoverySegmentBufferCount` toward its minimum of 2. To roll back to the
|
||||||
|
pre-HA-mode behavior entirely, set `RecoverySegmentLengthSeconds = 6` and ensure that no active
|
||||||
|
session is registered in the `ITranscodeSessionStore` (which disables HA mode detection in
|
||||||
|
`DynamicHlsController`). All changes are backwards-compatible: in single-pod deployments where the
|
||||||
|
store is a no-op, these settings have no effect on the FFmpeg command generated.
|
||||||
|
|||||||
@@ -94,6 +94,54 @@ namespace Jellyfin.Api.Tests.Controllers
|
|||||||
Assert.Null(liveSession);
|
Assert.Null(liveSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Segment-length selection: when the play-session has an active entry in the store
|
||||||
|
/// (HA mode is active), the recovery segment length should be preferred over the normal one.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
[Trait("Category", "UnitTest")]
|
||||||
|
public async Task SegmentLength_UsesRecoveryValue_WhenHaModeIsActive()
|
||||||
|
{
|
||||||
|
const int normalSegmentLength = 6;
|
||||||
|
const int recoverySegmentLength = 2;
|
||||||
|
|
||||||
|
var store = new HaTestSessionStore();
|
||||||
|
var session = CreateSession("ha-session-4", "pod-a", DateTime.UtcNow.AddMinutes(5));
|
||||||
|
await store.SetAsync(session);
|
||||||
|
|
||||||
|
// Simulate the controller's HA-mode check: if the session is in the store, HA mode is active.
|
||||||
|
var existingSession = await store.TryGetAsync("ha-session-4");
|
||||||
|
var isHaMode = existingSession is not null;
|
||||||
|
|
||||||
|
var effectiveSegmentLength = isHaMode ? recoverySegmentLength : normalSegmentLength;
|
||||||
|
|
||||||
|
Assert.True(isHaMode, "Session should be found in the store, activating HA mode.");
|
||||||
|
Assert.Equal(recoverySegmentLength, effectiveSegmentLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Segment-length selection: when no entry exists in the store for the play-session
|
||||||
|
/// (HA mode inactive), the normal segment length should be used.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
[Trait("Category", "UnitTest")]
|
||||||
|
public async Task SegmentLength_UsesNormalValue_WhenHaModeIsInactive()
|
||||||
|
{
|
||||||
|
const int normalSegmentLength = 6;
|
||||||
|
const int recoverySegmentLength = 2;
|
||||||
|
|
||||||
|
var store = new HaTestSessionStore();
|
||||||
|
|
||||||
|
// No session registered – HA mode is inactive.
|
||||||
|
var existingSession = await store.TryGetAsync("nonexistent-session");
|
||||||
|
var isHaMode = existingSession is not null;
|
||||||
|
|
||||||
|
var effectiveSegmentLength = isHaMode ? recoverySegmentLength : normalSegmentLength;
|
||||||
|
|
||||||
|
Assert.False(isHaMode, "No session in the store means HA mode should be inactive.");
|
||||||
|
Assert.Equal(normalSegmentLength, effectiveSegmentLength);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Minimal in-memory <see cref="ITranscodeSessionStore"/> used within this test class
|
/// Minimal in-memory <see cref="ITranscodeSessionStore"/> used within this test class
|
||||||
/// to avoid a cross-project reference to Jellyfin.MediaEncoding.Tests.
|
/// to avoid a cross-project reference to Jellyfin.MediaEncoding.Tests.
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ public sealed class InMemoryTranscodeSessionStore : ITranscodeSessionStore
|
|||||||
|
|
||||||
private static string MakeLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId)
|
private static string MakeLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId)
|
||||||
=> liveStreamId + "\x00" + sessionIdOrPlaySessionId;
|
=> liveStreamId + "\x00" + sessionIdOrPlaySessionId;
|
||||||
|
|
||||||
private static TranscodeSession Clone(TranscodeSession source)
|
private static TranscodeSession Clone(TranscodeSession source)
|
||||||
=> new TranscodeSession
|
=> new TranscodeSession
|
||||||
{
|
{
|
||||||
|
|||||||
+1
@@ -367,6 +367,7 @@ public class RedisTranscodeSessionStoreTests
|
|||||||
|
|
||||||
private static string MakeLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId)
|
private static string MakeLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId)
|
||||||
=> liveStreamId + "\x00" + sessionIdOrPlaySessionId;
|
=> liveStreamId + "\x00" + sessionIdOrPlaySessionId;
|
||||||
|
|
||||||
private static TranscodeSession Clone(TranscodeSession source)
|
private static TranscodeSession Clone(TranscodeSession source)
|
||||||
=> new TranscodeSession
|
=> new TranscodeSession
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user