Allow bounded extended probing for slow live TV startup
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<AssemblyVersion>7.0.7.0</AssemblyVersion>
|
||||
<FileVersion>7.0.7.0</FileVersion>
|
||||
<AssemblyVersion>7.0.8.0</AssemblyVersion>
|
||||
<FileVersion>7.0.8.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
|
||||
|
||||
@@ -23,7 +23,21 @@ public sealed class LiveStreamProbe
|
||||
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(15); }
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<List<MediaStream>> Get(string url, CancellationToken caller, int? programme = null)
|
||||
{
|
||||
@@ -51,7 +65,7 @@ public sealed class LiveStreamProbe
|
||||
try
|
||||
{
|
||||
await slots.WaitAsync(deadline.Token);
|
||||
try { completion.TrySetResult(Parse(await runner(url, deadline.Token))); LastError = null; success = true; }
|
||||
try { completion.TrySetResult(Parse(await runner(url, deadline.Token), Programme(url))); 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)); }
|
||||
@@ -60,43 +74,185 @@ public sealed class LiveStreamProbe
|
||||
|
||||
private static async Task<string> Run(string executable, string url, CancellationToken token)
|
||||
{
|
||||
// ffprobe may wait for an EPG/data PID forever on live MPEG-TS. Give it a finite
|
||||
// sample from the shared relay, preserving the same PID/index ordering.
|
||||
var sample = Path.Combine(Path.GetTempPath(), "enigma-probe-" + Guid.NewGuid().ToString("N") + ".ts");
|
||||
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 client = new HttpClient(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(3) }) { Timeout = Timeout.InfiniteTimeSpan };
|
||||
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(sample, FileMode.CreateNew, FileAccess.Write, FileShare.None, 65536, useAsync: true))
|
||||
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()
|
||||
{
|
||||
using var capture = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
capture.CancelAfter(TimeSpan.FromSeconds(4));
|
||||
var buffer = new byte[188 * 256];
|
||||
try
|
||||
{
|
||||
while (file.Length < 4 * 1024 * 1024)
|
||||
while (capturedBytes < limits.MaximumBytes)
|
||||
{
|
||||
var count = await input.ReadAsync(buffer, capture.Token);
|
||||
if (count == 0) break;
|
||||
await file.WriteAsync(buffer.AsMemory(0, count), token);
|
||||
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;
|
||||
json = await fileProbe(samplePath, programme, false, token);
|
||||
analysisReturned = true;
|
||||
try { Parse(json, programme); return json; }
|
||||
catch (Exception error) when (error is InvalidOperationException || error is JsonException)
|
||||
{
|
||||
cause = "initial_video_unavailable";
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!token.IsCancellationRequested && file.Length > 0) { }
|
||||
if (file.Length == 0) throw new InvalidOperationException("Decoder returned an empty probe sample.");
|
||||
|
||||
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) { }
|
||||
}
|
||||
var query = new Uri(url).Query;
|
||||
var programme = query.StartsWith("?programme=", StringComparison.Ordinal) && int.TryParse(query.Substring(11), out var value) ? value : (int?)null;
|
||||
return await RunFile(executable, sample, programme, token);
|
||||
}
|
||||
finally { if (File.Exists(sample)) File.Delete(sample); }
|
||||
catch (Exception error)
|
||||
{
|
||||
cause = error is OperationCanceledException ? "deadline"
|
||||
: error is IOException ? "capture_io_failure"
|
||||
: error is HttpRequestException ? "capture_http_failure"
|
||||
: error.Message.StartsWith("Decoder stream probe process failed", StringComparison.Ordinal) ? "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 async Task<string> RunFile(string executable, string sample, int? programme, CancellationToken token)
|
||||
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", "3000000", "-probesize", "4194304", "-i", sample, "-show_streams", "-of", "json" }) process.StartInfo.ArgumentList.Add(argument);
|
||||
foreach (var argument in new[] { "-v", "error", "-f", "mpegts", "-analyzeduration", extended ? "20000000" : "3000000", "-probesize", extended ? "25165824" : "4194304", "-i", sample, "-show_streams", "-show_programs", "-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();
|
||||
@@ -104,8 +260,8 @@ public sealed class LiveStreamProbe
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(token);
|
||||
var diagnostics = await errors;
|
||||
if (process.ExitCode != 0) throw new InvalidOperationException("Decoder stream probe failed: " + System.Text.RegularExpressions.Regex.Replace(diagnostics, @"https?://[^\s]+", "<decoder-stream>"));
|
||||
await errors;
|
||||
if (process.ExitCode != 0) throw new InvalidOperationException("Decoder stream probe process failed (exit code " + process.ExitCode.ToString(CultureInfo.InvariantCulture) + ").");
|
||||
return await output;
|
||||
}
|
||||
finally
|
||||
@@ -115,11 +271,20 @@ public sealed class LiveStreamProbe
|
||||
}
|
||||
}
|
||||
|
||||
public static List<MediaStream> Parse(string json)
|
||||
public static List<MediaStream> Parse(string json, int? programme = null)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var result = new List<MediaStream>();
|
||||
foreach (var item in document.RootElement.GetProperty("streams").EnumerateArray())
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user