Files
jellyfin-plugin-enigma2/Jellyfin.Plugin.Enigma2/LiveStreamProbe.cs
T

362 lines
21 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>One bounded metadata probe per source, shared by concurrent playback requests.</summary>
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 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;
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); }
// 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 sealed record SamplingLimits(TimeSpan InitialDuration, TimeSpan MaximumDuration, int InitialBytes, int MaximumBytes)
{
public static SamplingLimits Default { get; } = new(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(20), 4 * 1024 * 1024, 24 * 1024 * 1024);
internal void Validate()
{
if (InitialDuration <= TimeSpan.Zero || MaximumDuration < InitialDuration || MaximumDuration > TimeSpan.FromSeconds(20)
|| InitialBytes < 1 || MaximumBytes < InitialBytes || MaximumBytes > 24 * 1024 * 1024)
throw new ArgumentOutOfRangeException(nameof(SamplingLimits));
}
}
// Distinguish a rejected finite sample from process startup/I/O/cancellation failures.
public sealed class ProbeProcessException : InvalidOperationException
{
public ProbeProcessException(int exitCode) : base("Decoder stream probe process failed (exit code " + exitCode.ToString(CultureInfo.InvariantCulture) + ").") { }
}
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;
lock (gate)
{
if (cache.TryGetValue(url, out var existing) && existing.Until > DateTime.UtcNow) task = existing.Task;
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);
}
}
return task.WaitAsync(caller);
}
public List<MediaStream> LastKnown(string url, int? programme = null)
{
if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture);
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)
{
var success = false;
using var deadline = new CancellationTokenSource(timeout);
try
{
await slots.WaitAsync(deadline.Token);
try
{
var streams = Parse(await runner(url, deadline.Token), Programme(url));
lock (gate)
{
foreach (var stale in lastKnown.Where(x => DateTime.UtcNow - x.Value.At > TimeSpan.FromHours(6)).Select(x => x.Key).ToArray()) lastKnown.Remove(stale);
if (lastKnown.Count >= 256 && !lastKnown.ContainsKey(url)) lastKnown.Remove(lastKnown.OrderBy(x => x.Value.At).First().Key);
lastKnown[url] = (streams, DateTime.UtcNow);
}
completion.TrySetResult(streams); LastError = null; success = true;
}
finally { slots.Release(); }
}
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)); }
}
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);
}
private static int? Programme(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return null;
return uri.Query.StartsWith("?programme=", StringComparison.Ordinal) && int.TryParse(uri.Query.Substring(11), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null;
}
private static async Task<string> CaptureAndProbe(HttpClient client, Func<string, int?, bool, CancellationToken, Task<string>> fileProbe, SamplingLimits limits, string url, CancellationToken token)
{
limits.Validate();
var capturePath = Path.Combine(Path.GetTempPath(), "enigma-probe-" + Guid.NewGuid().ToString("N") + ".capture.ts");
var samplePath = capturePath + ".sample.ts";
var programme = Programme(url);
var watch = Stopwatch.StartNew();
var sampleBytes = 0L;
var attempts = 0;
var analysisReturned = false;
var json = "{}";
var cause = "capture_unavailable";
var captureStop = "running";
var closing = false;
var capturedBytes = 0L;
Task reader = null;
using var capture = CancellationTokenSource.CreateLinkedTokenSource(token);
using var fileGate = new SemaphoreSlim(1);
try
{
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token);
response.EnsureSuccessStatusCode();
using var input = await response.Content.ReadAsStreamAsync(token);
await using var file = new FileStream(capturePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read, 65536, useAsync: true);
var initialBytes = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
capture.CancelAfter(limits.MaximumDuration);
async Task Read()
{
var buffer = new byte[188 * 256];
try
{
while (capturedBytes < limits.MaximumBytes)
{
var count = await input.ReadAsync(buffer.AsMemory(0, Math.Min(buffer.Length, limits.MaximumBytes - (int)capturedBytes)), capture.Token);
if (count == 0) { captureStop = "end_of_stream"; return; }
await fileGate.WaitAsync(token);
try { await file.WriteAsync(buffer.AsMemory(0, count), token); capturedBytes += count; }
finally { fileGate.Release(); }
if (capturedBytes >= limits.InitialBytes) initialBytes.TrySetResult();
}
captureStop = "byte_limit";
}
catch (OperationCanceledException) when (!token.IsCancellationRequested) { captureStop = closing ? "probe_finished" : "capture_time_limit"; }
finally { await input.DisposeAsync(); }
}
async Task<bool> Snapshot(long limit)
{
await fileGate.WaitAsync(token);
try
{
await file.FlushAsync(token);
sampleBytes = Math.Min(file.Length, limit);
if (sampleBytes == 0) return false;
await using var source = new FileStream(capturePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 65536, useAsync: true);
await using var destination = new FileStream(samplePath, FileMode.Create, FileAccess.Write, FileShare.None, 65536, useAsync: true);
var remaining = sampleBytes;
var buffer = new byte[65536];
while (remaining > 0)
{
var count = await source.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, remaining)), token);
if (count == 0) throw new IOException("Probe snapshot was truncated.");
await destination.WriteAsync(buffer.AsMemory(0, count), token);
remaining -= count;
}
return true;
}
finally { fileGate.Release(); }
}
// Keep draining the same HTTP subscription while ffprobe reads a stable snapshot.
// Otherwise its bounded relay queue could overflow during the first analysis.
reader = Read();
try
{
await Task.WhenAny(initialBytes.Task, Task.Delay(limits.InitialDuration, token), reader);
token.ThrowIfCancellationRequested();
if (reader.IsFaulted) await reader;
if (await Snapshot(limits.InitialBytes))
{
attempts = 1;
try
{
json = await fileProbe(samplePath, programme, false, token);
analysisReturned = true;
}
catch (ProbeProcessException) { cause = "initial_probe_process_failed"; }
if (analysisReturned)
{
try { Parse(json, programme); return json; }
catch (Exception error) when (error is InvalidOperationException || error is JsonException)
{
cause = "initial_video_unavailable";
}
}
}
await reader;
if (!await Snapshot(limits.MaximumBytes)) throw new InvalidOperationException("Empty probe sample.");
attempts++;
analysisReturned = false;
json = await fileProbe(samplePath, programme, true, token);
analysisReturned = true;
Parse(json, programme);
return json;
}
finally
{
closing = true;
capture.Cancel();
// Do not close the file/HTTP stream until the sole writer has stopped.
try { await reader; }
catch (OperationCanceledException) { }
}
}
catch (Exception error)
{
cause = error is OperationCanceledException ? "deadline"
: error is IOException ? "capture_io_failure"
: error is HttpRequestException ? "capture_http_failure"
: error is ProbeProcessException ? "probe_process_failed"
: attempts > 0 && !analysisReturned ? "probe_analysis_failure"
: attempts > 0 ? ProbeCause(json, programme) : capturedBytes == 0 ? "capture_empty" : "capture_unavailable";
throw new InvalidOperationException($"Decoder probe failed: cause={cause}; expectedProgramme={programme?.ToString(CultureInfo.InvariantCulture) ?? "none"}; {ProbeSummary(json)}; sampleBytes={sampleBytes}; capturedBytes={capturedBytes}; elapsedMs={watch.ElapsedMilliseconds}; attempts={attempts}; captureStop={captureStop}.");
}
finally
{
if (File.Exists(samplePath)) File.Delete(samplePath);
if (File.Exists(capturePath)) File.Delete(capturePath);
}
}
private static string ProbeCause(string json, int? programme)
{
try
{
using var document = JsonDocument.Parse(json);
var streams = document.RootElement.GetProperty("streams");
if (programme != null)
{
if (!document.RootElement.TryGetProperty("programs", out var programmes)) return "missing_expected_programme";
var selected = programmes.EnumerateArray().FirstOrDefault(p => p.TryGetProperty("program_id", out var id) && id.TryGetInt32(out var value) && value == programme);
if (selected.ValueKind == JsonValueKind.Undefined) return "missing_expected_programme";
if (!selected.TryGetProperty("streams", out streams)) return "missing_video";
}
if (!streams.EnumerateArray().Any(s => s.TryGetProperty("codec_type", out var t) && t.GetString() == "video")) return "missing_video";
return "video_parameters_unavailable";
}
catch (Exception) { return "invalid_probe_output"; }
}
private static string ProbeSummary(string json)
{
try
{
using var document = JsonDocument.Parse(json);
var programmes = document.RootElement.TryGetProperty("programs", out var p) && p.ValueKind == JsonValueKind.Array
? p.EnumerateArray().Where(v => v.TryGetProperty("program_id", out var id) && id.TryGetInt32(out _)).Select(v => v.GetProperty("program_id").GetInt32().ToString(CultureInfo.InvariantCulture)).Take(16) : Array.Empty<string>();
var streams = document.RootElement.TryGetProperty("streams", out var s) && s.ValueKind == JsonValueKind.Array
? s.EnumerateArray().Where(v => v.TryGetProperty("codec_type", out var t) && (t.GetString() == "video" || t.GetString() == "audio")).Take(8).Select(v =>
{
int Number(string key) => v.TryGetProperty(key, out var n) && n.TryGetInt32(out var value) ? value : 0;
var codec = v.TryGetProperty("codec_name", out var c) ? c.GetString() : null;
if (codec == null || !System.Text.RegularExpressions.Regex.IsMatch(codec, "^[a-z0-9_]{1,32}$")) codec = "unknown";
return $"{v.GetProperty("codec_type").GetString()}:{codec}:{Number("width")}x{Number("height")}";
}) : Array.Empty<string>();
return $"programmes=[{string.Join(',', programmes)}]; AV=[{string.Join(',', streams)}]";
}
catch (Exception) { return "programmes=[]; AV=[]"; }
}
private static async Task<string> RunFile(string executable, string sample, int? programme, bool extended, CancellationToken token)
{
using var process = new Process { StartInfo = new ProcessStartInfo(executable) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true } };
foreach (var argument in new[] { "-v", "error", "-f", "mpegts", "-analyzeduration", extended ? "20000000" : "3000000", "-probesize", extended ? "25165824" : "4194304", "-i", sample, "-show_streams", "-show_programs", "-show_data", "-of", "json" }) process.StartInfo.ArgumentList.Add(argument);
if (programme != null) { process.StartInfo.ArgumentList.Add("-select_streams"); process.StartInfo.ArgumentList.Add("p:" + programme.Value.ToString(CultureInfo.InvariantCulture)); }
process.Start();
var output = process.StandardOutput.ReadToEndAsync();
var errors = process.StandardError.ReadToEndAsync();
try
{
await process.WaitForExitAsync(token);
await errors;
if (process.ExitCode != 0) throw new ProbeProcessException(process.ExitCode);
return await output;
}
finally
{
if (!process.HasExited) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(); }
await Task.WhenAll(output, errors);
}
}
public static List<MediaStream> Parse(string json, int? programme = null)
{
using var document = JsonDocument.Parse(json);
var result = new List<MediaStream>();
var items = document.RootElement.GetProperty("streams");
if (programme != null)
{
if (!document.RootElement.TryGetProperty("programs", out var programmes) || programmes.ValueKind != JsonValueKind.Array)
throw new InvalidOperationException("Decoder stream has no identified programme.");
var selected = programmes.EnumerateArray().FirstOrDefault(p => p.TryGetProperty("program_id", out var id) && id.TryGetInt32(out var value) && value == programme);
if (selected.ValueKind == JsonValueKind.Undefined || !selected.TryGetProperty("streams", out items))
throw new InvalidOperationException("Decoder stream does not contain the requested programme.");
}
foreach (var item in items.EnumerateArray())
{
string Text(string field) => item.TryGetProperty(field, out var value) ? value.ToString() : null;
int? Number(string field) => int.TryParse(Text(field), out var value) ? value : null;
var type = Text("codec_type");
if (type != "video" && type != "audio" && type != "subtitle") continue;
var codec = Text("codec_name"); var index = Number("index");
if (string.IsNullOrEmpty(codec) || index == null) continue;
float? Rate(string field)
{
var values = (Text(field) ?? "").Split('/');
if (!float.TryParse(values[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var numerator)) return null;
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") };
if (item.TryGetProperty("tags", out var tags) && tags.TryGetProperty("language", out var language)) stream.Language = language.GetString();
if (item.TryGetProperty("tags", out var labels))
{
if (labels.TryGetProperty("title", out var title)) stream.Title = title.GetString();
else if (labels.TryGetProperty("handler_name", out var handler)) stream.Title = handler.GetString();
}
if (item.TryGetProperty("disposition", out var disposition))
{
bool Flag(string key) => disposition.TryGetProperty(key, out var flag) && flag.TryGetInt32(out var value) && value != 0;
stream.IsDefault = Flag("default"); stream.IsForced = Flag("forced");
stream.IsHearingImpaired = Flag("hearing_impaired"); stream.IsVisualImpaired = Flag("visual_impaired"); stream.IsOriginal = Flag("original");
}
result.Add(stream);
}
if (!result.Any(s => s.Type == MediaStreamType.Video && s.Width > 0 && s.Height > 0)) throw new InvalidOperationException("Decoder stream has no identified video.");
return result;
}
public static MediaStream ParseLiveMetadata(MediaStream s) => new MediaStream
{
Index = -1, Type = s.Type, Codec = s.Codec, Width = s.Width, Height = s.Height,
Channels = s.Channels, SampleRate = s.SampleRate, BitRate = s.BitRate,
Profile = s.Profile, Level = s.Level, PixelFormat = s.PixelFormat,
ColorRange = s.ColorRange, ColorSpace = s.ColorSpace, ColorTransfer = s.ColorTransfer,
ColorPrimaries = s.ColorPrimaries, AverageFrameRate = s.AverageFrameRate,
RealFrameRate = s.RealFrameRate, IsInterlaced = s.IsInterlaced,
ChannelLayout = s.ChannelLayout, Language = s.Language, Title = s.Title, IsDefault = s.IsDefault, IsForced = s.IsForced, IsHearingImpaired = s.IsHearingImpaired, IsOriginal = s.IsOriginal
};
}