feat: add a durable transcode session store

Transcode state lives only in the process that started ffmpeg, so a pod restart
drops every in-flight HLS stream with no way for a peer to pick it up.

- add ITranscodeSessionStore plus the TranscodeSession and LiveStreamSession records
- add RedisTranscodeSessionStore with TTL leases and an atomic Lua takeover script
- add NullTranscodeSessionStore for single-instance deployments
- pick the store from Jellyfin:TranscodeStore:RedisConnectionString at startup
This commit is contained in:
2026-09-11 23:56:19 +10:00
parent c8a693ae17
commit e23d1a23be
13 changed files with 1281 additions and 0 deletions
@@ -0,0 +1,104 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.MediaEncoding;
/// <summary>
/// Provides a durable store for HLS transcoding session state, enabling
/// HA recovery and lease-based ownership between pods.
/// </summary>
public interface ITranscodeSessionStore
{
/// <summary>
/// Attempts to retrieve a transcoding session by its play session identifier.
/// </summary>
/// <param name="playSessionId">The play session identifier.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>
/// The <see cref="TranscodeSession"/> if it exists and its lease has not expired;
/// otherwise <c>null</c>.
/// </returns>
Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default);
/// <summary>
/// Attempts to take over ownership of an existing session by claiming the lease for
/// <paramref name="claimingPod"/>. Takeover succeeds only when the session exists and
/// its current lease has already expired.
/// </summary>
/// <param name="playSessionId">The play session identifier.</param>
/// <param name="claimingPod">The name of the pod attempting to claim ownership.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>
/// <c>true</c> if the takeover succeeded (the claiming pod now holds the lease);
/// <c>false</c> if the session does not exist, its lease is still valid, or another
/// concurrent caller already claimed it.
/// </returns>
Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default);
/// <summary>
/// Persists a new or updated transcoding session.
/// </summary>
/// <param name="session">The session to store.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default);
/// <summary>
/// Renews the lease for an existing session, extending its
/// <see cref="TranscodeSession.LeaseExpiresUtc"/> by the store's configured lease duration.
/// </summary>
/// <param name="playSessionId">The play session identifier.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default);
/// <summary>
/// Removes a transcoding session from the store.
/// </summary>
/// <param name="playSessionId">The play session identifier.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default);
/// <summary>
/// Returns all currently active transcoding sessions from the store.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>
/// An enumerable of <see cref="TranscodeSession"/> objects representing all active sessions.
/// Returns an empty enumerable if no sessions are active or if the store cannot be reached.
/// </returns>
Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Persists a live stream session record so that takeover pods can identify and close
/// streams that were opened on a pod that has since crashed or been evicted.
/// </summary>
/// <param name="session">The live stream session to store.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default);
/// <summary>
/// Attempts to retrieve a live stream session by its live stream identifier and the
/// session or play-session identifier that owns it.
/// </summary>
/// <param name="liveStreamId">The live stream identifier.</param>
/// <param name="sessionIdOrPlaySessionId">The session identifier or play-session identifier.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>
/// The <see cref="LiveStreamSession"/> if it exists; otherwise <c>null</c>.
/// </returns>
Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default);
/// <summary>
/// Removes the live stream session record for the given live stream and session identifier.
/// This is called when the stream is closed, either by the owning pod or a takeover pod.
/// </summary>
/// <param name="liveStreamId">The live stream identifier.</param>
/// <param name="sessionIdOrPlaySessionId">The session identifier or play-session identifier.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,36 @@
using System;
namespace MediaBrowser.Controller.MediaEncoding;
/// <summary>
/// Represents a durable record of an open live stream session, enabling HA pod recovery
/// when the owning pod crashes or is evicted.
/// </summary>
public sealed class LiveStreamSession
{
/// <summary>
/// Gets or sets the live stream identifier (e.g. a TV tuner channel token).
/// </summary>
public string LiveStreamId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the session identifier of the client that opened this live stream.
/// </summary>
public string SessionId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the play session identifier associated with this live stream,
/// or an empty string when the client did not supply one.
/// </summary>
public string PlaySessionId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the name of the pod that currently holds this live stream open.
/// </summary>
public string OwnerPod { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the UTC time at which this record was created.
/// </summary>
public DateTime OpenedAtUtc { get; set; }
}
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.MediaEncoding;
/// <summary>
/// A no-op implementation of <see cref="ITranscodeSessionStore"/> used in single-instance deployments
/// where durable session tracking across pods is not required.
/// </summary>
public sealed class NullTranscodeSessionStore : ITranscodeSessionStore
{
/// <inheritdoc />
public Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
=> Task.FromResult<TranscodeSession?>(null);
/// <inheritdoc />
public Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
/// <inheritdoc />
public Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IEnumerable<TranscodeSession>>(Array.Empty<TranscodeSession>());
/// <inheritdoc />
public Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
=> Task.FromResult<LiveStreamSession?>(null);
/// <inheritdoc />
public Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
@@ -0,0 +1,49 @@
using System;
namespace MediaBrowser.Controller.MediaEncoding;
/// <summary>
/// Represents a durable record of an HLS transcoding session for HA pod recovery.
/// </summary>
public sealed class TranscodeSession
{
/// <summary>
/// Gets or sets the unique play session identifier.
/// </summary>
public string PlaySessionId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the name of the pod that currently owns this session's lease.
/// </summary>
public string OwnerPod { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the UTC time at which the owning pod's lease expires.
/// </summary>
public DateTime LeaseExpiresUtc { get; set; }
/// <summary>
/// Gets or sets the absolute path to the HLS manifest (.m3u8) file on shared storage.
/// </summary>
public string ManifestPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the path prefix for transcoded segment files on shared storage.
/// </summary>
public string SegmentPathPrefix { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the media source identifier associated with this session.
/// </summary>
public string MediaSourceId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the zero-based index of the last segment that was fully written to durable storage.
/// </summary>
public int LastCompletedSegmentIndex { get; set; }
/// <summary>
/// Gets or sets the last durable playback offset in ticks, used to resume playback after failover.
/// </summary>
public long LastDurablePlaybackOffset { get; set; }
}
@@ -0,0 +1,19 @@
namespace MediaBrowser.Controller.MediaEncoding;
/// <summary>
/// Configuration options for the transcode session store.
/// </summary>
public sealed class TranscodeStoreOptions
{
/// <summary>
/// Gets or sets the Redis connection string.
/// A <c>null</c> or empty value indicates single-instance mode, where
/// <see cref="NullTranscodeSessionStore"/> is used instead of a Redis-backed store.
/// </summary>
public string? RedisConnectionString { get; set; }
/// <summary>
/// Gets or sets the duration in seconds for which a transcoding session lease is valid.
/// </summary>
public int LeaseDurationSeconds { get; set; } = 30;
}