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; /// One bounded metadata probe per source, shared by concurrent playback requests. public sealed class LiveStreamProbe { private readonly Func> runner; private readonly Dictionary> Task, DateTime Until)> cache = new(); private readonly object gate = new(); private readonly Dictionary 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> 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> 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> Get(string url, CancellationToken caller, int? programme = null) { if (programme != null) url += "?programme=" + programme.Value.ToString(CultureInfo.InvariantCulture); Task> 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>(TaskCreationOptions.RunContinuationsAsynchronously); task = completion.Task; cache[url] = (task, DateTime.MaxValue); _ = Produce(url, completion); } } return task.WaitAsync(caller); } public List 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> 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 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 CaptureAndProbe(HttpClient client, Func> 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 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(); 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(); return $"programmes=[{string.Join(',', programmes)}]; AV=[{string.Join(',', streams)}]"; } catch (Exception) { return "programmes=[]; AV=[]"; } } private static async Task 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 Parse(string json, int? programme = null) { using var document = JsonDocument.Parse(json); var result = new List(); 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 }; }