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
|
||||
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 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>
|
||||
/// Gets or sets the hardware acceleration type.
|
||||
/// </summary>
|
||||
|
||||
@@ -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 (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);
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
|
||||
+1
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user