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:
Copilot
2026-03-10 02:36:54 -04:00
committed by mat
parent e71efc3f97
commit fe9ea18918
6 changed files with 156 additions and 8 deletions
@@ -313,10 +313,25 @@ public class DynamicHlsController : BaseJellyfinApiController
// If the playlist doesn't already exist, startup ffmpeg
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(
state,
playlistPath,
GetCommandLineArguments(playlistPath, state, true, 0),
GetCommandLineArguments(playlistPath, state, true, 0, isHaMode),
Request.HttpContext.User.GetUserId(),
TranscodingJobType,
cancellationTokenSource)
@@ -1545,11 +1560,26 @@ public class DynamicHlsController : BaseJellyfinApiController
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;
job = await _transcodeManager.StartFfMpeg(
state,
playlistPath,
GetCommandLineArguments(playlistPath, state, false, segmentId),
GetCommandLineArguments(playlistPath, state, false, segmentId, isHaMode),
Request.HttpContext.User.GetUserId(),
TranscodingJobType,
cancellationTokenSource).ConfigureAwait(false);
@@ -1678,7 +1708,7 @@ public class DynamicHlsController : BaseJellyfinApiController
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 threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
@@ -1701,10 +1731,20 @@ public class DynamicHlsController : BaseJellyfinApiController
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
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 segmentContainer = outputExtension.TrimStart('.');
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))
{
@@ -1756,10 +1796,10 @@ public class DynamicHlsController : BaseJellyfinApiController
_encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
threads,
mapArgs,
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer),
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer, effectiveSegmentLength),
GetAudioArguments(state),
maxMuxingQueueSize,
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
effectiveSegmentLength.ToString(CultureInfo.InvariantCulture),
segmentFormat,
startNumber.ToString(CultureInfo.InvariantCulture),
baseUrlParam,
@@ -1901,8 +1941,9 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="startNumber">The first number in the hls sequence.</param>
/// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</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>
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)
{
@@ -1977,7 +2018,7 @@ public class DynamicHlsController : BaseJellyfinApiController
args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
// 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.
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
@@ -24,6 +24,8 @@ public class EncodingOptions
ThrottleDelaySeconds = 180;
EnableSegmentDeletion = false;
SegmentKeepSeconds = 720;
RecoverySegmentLengthSeconds = 2;
RecoverySegmentBufferCount = 5;
EncodingThreadCount = -1;
// 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
@@ -121,6 +123,20 @@ public class EncodingOptions
/// </summary>
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 16.
/// </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 210.
/// </summary>
public int RecoverySegmentBufferCount { get; set; }
/// <summary>
/// Gets or sets the hardware acceleration type.
/// </summary>
+41
View File
@@ -430,3 +430,44 @@ seek point.
- `Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs` — age-only cleanup
- `Emby.Server.Implementations/Session/SessionManager.cs``_activeLiveStreamSessions`
- `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 (210) 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);
}
/// <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>
/// Minimal in-memory <see cref="ITranscodeSessionStore"/> used within this test class
/// 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)
=> liveStreamId + "\x00" + sessionIdOrPlaySessionId;
private static TranscodeSession Clone(TranscodeSession source)
=> new TranscodeSession
{
@@ -367,6 +367,7 @@ public class RedisTranscodeSessionStoreTests
private static string MakeLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId)
=> liveStreamId + "\x00" + sessionIdOrPlaySessionId;
private static TranscodeSession Clone(TranscodeSession source)
=> new TranscodeSession
{