Preserve native broadcast video and add patient shared stream startup

This commit is contained in:
2026-10-25 16:59:56 +01:00
parent bd7395f045
commit 11a34a5195
27 changed files with 708 additions and 139 deletions
+66 -19
View File
@@ -17,17 +17,28 @@ namespace Jellyfin.Plugin.Enigma2;
public sealed class LiveStreamProbe
{
private readonly Func<string, CancellationToken, Task<string>> runner;
private readonly Dictionary<string, (Task<List<MediaStream>> Task, DateTime Until)> cache = new();
private sealed class ProbeEntry
{
public readonly TaskCompletionSource<List<MediaStream>> Completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
public readonly CancellationTokenSource Stop = new();
public DateTime Until = DateTime.MaxValue;
public int Waiters;
public bool Abandoned;
public Task<List<MediaStream>> Task => Completion.Task;
}
private readonly Dictionary<string, ProbeEntry> cache = new();
private readonly object gate = new();
private readonly Dictionary<string, (List<MediaStream> Streams, DateTime At)> lastKnown = new();
private readonly SemaphoreSlim slots = new(8);
private readonly TimeSpan timeout;
private readonly Func<TimeSpan> timeout;
public string LastError { get; private set; }
public LiveStreamProbe(IMediaEncoder encoder) : this((url, token) => Run(encoder.ProbePath, url, token)) { }
public LiveStreamProbe(Func<string, CancellationToken, Task<string>> runner, TimeSpan? timeout = null) { this.runner = runner; this.timeout = timeout ?? TimeSpan.FromSeconds(25); }
public LiveStreamProbe(IMediaEncoder encoder) : this((url, token) => Run(encoder.ProbePath, url, token))
{ timeout = () => TimeSpan.FromSeconds(StartupBudget.Seconds); }
public LiveStreamProbe(Func<string, CancellationToken, Task<string>> runner, TimeSpan? timeout = null) { this.runner = runner; this.timeout = () => timeout ?? TimeSpan.FromSeconds(25); }
// The HTTP and file-analysis seams let tests exercise the actual capture, retry and cleanup.
public LiveStreamProbe(HttpClient client, Func<string, int?, bool, CancellationToken, Task<string>> fileProbe, SamplingLimits limits = null, TimeSpan? timeout = null)
: this((url, token) => CaptureAndProbe(client, fileProbe, limits ?? SamplingLimits.Default, url, token), timeout) { }
public LiveStreamProbe(HttpClient client, Func<string, int?, bool, CancellationToken, Task<string>> fileProbe, SamplingLimits limits = null, TimeSpan? timeout = null, bool patient = false, TimeSpan? retryDelay = null)
: this((url, token) => patient ? Patient(() => CaptureAndProbe(client, fileProbe, limits ?? SamplingLimits.Default, url, token), token, retryDelay)
: CaptureAndProbe(client, fileProbe, limits ?? SamplingLimits.Default, url, token), timeout) { }
public sealed record SamplingLimits(TimeSpan InitialDuration, TimeSpan MaximumDuration, int InitialBytes, int MaximumBytes)
{
@@ -49,20 +60,31 @@ public sealed class LiveStreamProbe
public Task<List<MediaStream>> Get(string url, CancellationToken caller, int? programme = null)
{
if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture);
Task<List<MediaStream>> task;
caller.ThrowIfCancellationRequested();
ProbeEntry entry;
lock (gate)
{
if (cache.TryGetValue(url, out var existing) && existing.Until > DateTime.UtcNow) task = existing.Task;
if (cache.TryGetValue(url, out var existing) && existing.Until > DateTime.UtcNow && !existing.Abandoned && !existing.Stop.IsCancellationRequested) entry = existing;
else
{
foreach (var expired in cache.Where(x => x.Value.Until <= DateTime.UtcNow).Select(x => x.Key).ToArray()) cache.Remove(expired);
var completion = new TaskCompletionSource<List<MediaStream>>(TaskCreationOptions.RunContinuationsAsynchronously);
task = completion.Task;
cache[url] = (task, DateTime.MaxValue);
_ = Produce(url, completion);
foreach (var expired in cache.Where(x => x.Value.Until <= DateTime.UtcNow).Select(x => x.Key).ToArray())
{ cache[expired].Stop.Dispose(); cache.Remove(expired); }
entry = new(); cache[url] = entry;
_ = Produce(url, entry);
}
entry.Waiters++;
}
return AwaitShared(entry, caller);
}
private async Task<List<MediaStream>> AwaitShared(ProbeEntry entry, CancellationToken caller)
{
try { return await entry.Task.WaitAsync(caller); }
finally
{
bool stop;
lock (gate) { entry.Waiters--; stop = entry.Waiters == 0 && !entry.Task.IsCompleted; if (stop) entry.Abandoned = true; }
if (stop) entry.Stop.Cancel(); // Last viewer left; do not occupy a tuner for the remaining budget.
}
return task.WaitAsync(caller);
}
public List<MediaStream> LastKnown(string url, int? programme = null)
@@ -71,10 +93,12 @@ public sealed class LiveStreamProbe
lock (gate) return lastKnown.TryGetValue(url, out var value) && DateTime.UtcNow - value.At <= TimeSpan.FromHours(6) ? value.Streams.ToList() : null;
}
private async Task Produce(string url, TaskCompletionSource<List<MediaStream>> completion)
private async Task Produce(string url, ProbeEntry entry)
{
var success = false;
using var deadline = new CancellationTokenSource(timeout);
var completion = entry.Completion;
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(entry.Stop.Token);
deadline.CancelAfter(timeout());
try
{
await slots.WaitAsync(deadline.Token);
@@ -91,14 +115,37 @@ public sealed class LiveStreamProbe
}
finally { slots.Release(); }
}
catch (Exception) when (entry.Stop.IsCancellationRequested) { completion.TrySetCanceled(entry.Stop.Token); }
catch (Exception error) { LastError = error.Message; completion.TrySetException(new InvalidOperationException("Decoder returned no usable live stream within the probe deadline.", error)); }
finally { lock (gate) cache[url] = (completion.Task, DateTime.UtcNow.AddSeconds(success ? 30 : 3)); }
finally { lock (gate) { entry.Until = DateTime.UtcNow.AddSeconds(success ? 30 : 3); if (entry.Stop.IsCancellationRequested && cache.TryGetValue(url, out var current) && ReferenceEquals(current, entry)) cache.Remove(url); } }
}
private static async Task<string> Run(string executable, string url, CancellationToken token)
{
using var client = new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3) }) { Timeout = Timeout.InfiniteTimeSpan };
return await CaptureAndProbe(client, (sample, programme, extended, cancellation) => RunFile(executable, sample, programme, extended, cancellation), SamplingLimits.Default, url, token);
Task<string> Sample() => CaptureAndProbe(client, (sample, programme, extended, cancellation) => RunFile(executable, sample, programme, extended, cancellation), SamplingLimits.Default, url, token);
// Production always uses the local shared relay. Reopening its HTTP subscription
// keeps the same receiver connection and a fresh bounded sample window.
return Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.IsLoopback && uri.AbsolutePath.StartsWith("/DecoderIntegration/Live/", StringComparison.Ordinal)
? await Patient(Sample, token) : await Sample();
}
private static async Task<string> Patient(Func<Task<string>> sample, CancellationToken token, TimeSpan? retryDelay = null)
{
Exception last = null; var windows = 0;
try
{
while (true)
{
token.ThrowIfCancellationRequested(); windows++;
try { return await sample(); }
catch (InvalidOperationException error) when (new[] { "video_parameters_unavailable", "missing_expected_programme", "missing_video", "capture_http_failure", "capture_empty", "probe_process_failed" }.Any(c => error.Message.Contains("cause=" + c, StringComparison.Ordinal)))
{ last = error; }
await Task.Delay(retryDelay ?? TimeSpan.FromSeconds(1), token);
}
}
catch (OperationCanceledException) when (last != null)
{ throw new InvalidOperationException($"Decoder startup budget exhausted after {windows} sample windows; last: {last.Message}", last); }
}
private static int? Programme(string url)
@@ -329,7 +376,7 @@ public sealed class LiveStreamProbe
if (values.Length == 1) return numerator;
return float.TryParse(values[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var denominator) && denominator != 0 ? numerator / denominator : null;
}
var stream = new BroadcastMediaStream { TransportId = BroadcastTracks.TransportId(Text("id")), SubtitleExtraData = BroadcastTracks.ExtraData(Text("extradata")), Type = type == "video" ? MediaStreamType.Video : type == "audio" ? MediaStreamType.Audio : MediaStreamType.Subtitle, Index = index.Value, Codec = BroadcastTracks.JellyfinCodec(codec), Width = Number("width"), Height = Number("height"), Channels = Number("channels"), SampleRate = Number("sample_rate"), BitRate = Number("bit_rate"), Profile = Text("profile"), Level = Number("level"), PixelFormat = Text("pix_fmt"), ColorRange = Text("color_range"), ColorSpace = Text("color_space"), ColorTransfer = Text("color_transfer"), ColorPrimaries = Text("color_primaries"), AverageFrameRate = Rate("avg_frame_rate"), RealFrameRate = Rate("r_frame_rate"), IsInterlaced = Text("field_order") is "tt" or "bb" or "tb" or "bt", ChannelLayout = Text("channel_layout") };
var stream = new BroadcastMediaStream { SampleAspectRatio = Text("sample_aspect_ratio"), FrameRateRational = Text("avg_frame_rate"), TransportId = BroadcastTracks.TransportId(Text("id")), SubtitleExtraData = BroadcastTracks.ExtraData(Text("extradata")), Type = type == "video" ? MediaStreamType.Video : type == "audio" ? MediaStreamType.Audio : MediaStreamType.Subtitle, Index = index.Value, Codec = BroadcastTracks.JellyfinCodec(codec), Width = Number("width"), Height = Number("height"), Channels = Number("channels"), SampleRate = Number("sample_rate"), BitRate = Number("bit_rate"), Profile = Text("profile"), Level = Number("level"), PixelFormat = Text("pix_fmt"), ColorRange = Text("color_range"), ColorSpace = Text("color_space"), ColorTransfer = Text("color_transfer"), ColorPrimaries = Text("color_primaries"), AverageFrameRate = Rate("avg_frame_rate"), RealFrameRate = Rate("r_frame_rate"), IsInterlaced = Text("field_order") is "tt" or "bb" or "tb" or "bt", ChannelLayout = Text("channel_layout") };
if (item.TryGetProperty("tags", out var tags) && tags.TryGetProperty("language", out var language)) stream.Language = language.GetString();
if (item.TryGetProperty("tags", out var labels))
{