using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.Enigma2; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; /// Native source dimensions through slate/live switches, remux and fMP4 packaging. public static class ResolutionE2E { private static string encoder, output; private static int active, maximum; private static readonly Dictionary Sizes = new() { ["sd"] = "720x576", ["hd"] = "1280x720", ["fhd"] = "1920x1080", ["qhd"] = "2560x1440", ["uhd"] = "3840x2160" }; public static async Task Run(string executable, string folder) { encoder = executable; output = Path.GetFullPath(folder); if (Directory.Exists(output)) throw new Exception("Choose a new output directory"); Directory.CreateDirectory(output); using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); builder.WebHost.UseUrls($"http://127.0.0.1:{port}"); using var logging = LoggerFactory.Create(b => b.AddSimpleConsole().SetMinimumLevel(LogLevel.Warning)); using var manager = new ContinuityStreams(encoder, Path.Combine(output, "cache"), port, new StartupMessages(), logging.CreateLogger(), preserveResolution: true); using var relay = new LiveStreamRelay(new System.Net.Http.HttpClient(), port, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(5)); builder.Services.AddSingleton(manager); builder.Services.AddSingleton(relay); builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly); await using var app = builder.Build(); app.MapControllers(); app.MapGet("/fixture/{size}.ts", Fixture); await app.StartAsync(); var reports = new List(); try { foreach (var (name, size) in Sizes) { var dimensions = size.Split('x'); var tracks = LosslessSlateTests.Tracks(int.Parse(dimensions[0]), int.Parse(dimensions[1]), 25); ((BroadcastMediaStream)tracks.Input[0]).SampleAspectRatio = name == "sd" ? "16:15" : "1:1"; tracks = new BroadcastTracks(tracks.Input); var media = await manager.Open(relay.Register($"http://127.0.0.1:{port}/fixture/{name}.ts"), "en", CancellationToken.None, tracks); Require(media.MediaStreams[0].Width == tracks.Input[0].Width && media.MediaStreams[0].Height == tracks.Input[0].Height, "source metadata does not falsely cap the channel at Full HD: " + name); var target = Path.Combine(output, name); Directory.CreateDirectory(target); var common = new[] { "-xerror", "-i", media.Path, "-t", "32", "-map", "0:v:0", "-map", "0:a:0", "-c", "copy" }; using var recording = Start(common.Concat(new[] { Path.Combine(target, "recording.ts") }), name + "-recording"); using var packaging = Start(common.Concat(new[] { "-bsf:a", "aac_adtstoasc", "-f", "hls", "-hls_segment_type", "fmp4", "-hls_time", "2", "-hls_list_size", "0", Path.Combine(target, "index.m3u8") }), name + "-fmp4"); try { await Task.WhenAll(recording.WaitForExitAsync(), packaging.WaitForExitAsync()).WaitAsync(TimeSpan.FromSeconds(65)); Console.WriteLine("Source status: " + JsonSerializer.Serialize(manager.Status())); Require(recording.ExitCode == 0 && packaging.ExitCode == 0, "strict slate/live read and fMP4 packaging: " + size); } finally { if (!recording.HasExited) recording.Kill(true); if (!packaging.HasExited) packaging.Kill(true); manager.Close(media.Id); } foreach (var file in new[] { "recording.ts", "index.m3u8" }) { using var decode = Start(new[] { "-xerror", "-i", Path.Combine(target, file), "-map", "0:v:0", "-map", "0:a:0", "-f", "null", "-" }, name + "-decode-" + Path.GetExtension(file).TrimStart('.')); await decode.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60)); Require(decode.ExitCode == 0, "decode mixed-resolution " + file + ": " + size); var probePath = Path.Combine(Path.GetDirectoryName(encoder), "ffprobe"); var info = new ProcessStartInfo(probePath) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true }; foreach (var arg in new[] { "-v", "error", "-threads", "1", "-select_streams", "v:0", "-show_entries", "frame=width,height,sample_aspect_ratio", "-of", "csv=p=0", Path.Combine(target, file) }) info.ArgumentList.Add(arg); using var probe = Process.Start(info); var text = await probe.StandardOutput.ReadToEndAsync(); var error = await probe.StandardError.ReadToEndAsync(); await probe.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60)); Require(probe.ExitCode == 0 && string.IsNullOrWhiteSpace(error), "probe actual decoded dimensions: " + name + " " + file); var decodedDimensions = text.Split('\n').Where(line => line.Length > 0).Select(line => line.Trim()).Distinct().ToArray(); var expected = size.Replace('x', ',') + ","; Require(decodedDimensions.Any(line => line.StartsWith(expected, StringComparison.Ordinal)), "native dimensions reach the decoded client frames: " + size); if (name == "sd") Require(decodedDimensions.Any(line => line.StartsWith(expected + "16:15", StringComparison.Ordinal)), "SD sample aspect ratio survives slate/live switching"); reports.Add(new { Profile = name, File = file, Dimensions = decodedDimensions }); } await Task.Delay(1500); } Require(active == 0 && maximum == 1, "resolution trials keep one upstream and release the last source"); var report = new { Passed = true, SyntheticOnly = true, LowMotionFixture = true, ReceiverConnectionsOpened = 0, PhysicalAndroidTvTested = false, ActualJellyfinPlayerTested = false, MaximumConcurrentSourceConnections = maximum, Reports = reports }; await File.WriteAllTextAsync(Path.Combine(output, "result.json"), JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); Console.WriteLine(JsonSerializer.Serialize(report)); } finally { manager.Dispose(); await app.StopAsync(); } } private static Process Start(IEnumerable arguments, string name) { var info = new ProcessStartInfo(encoder) { UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true }; foreach (var arg in new[] { "-hide_banner", "-loglevel", "warning", "-nostdin", "-threads", "1", "-filter_threads", "1" }.Concat(arguments)) info.ArgumentList.Add(arg); var process = Process.Start(info); process.ErrorDataReceived += (_, e) => { if (e.Data != null) lock (typeof(ResolutionE2E)) File.AppendAllText(Path.Combine(output, name + ".log"), e.Data + "\n"); }; process.BeginErrorReadLine(); return process; } private static void Require(bool condition, string text) { if (!condition) throw new Exception(text); Console.WriteLine("PASS " + text); } private static async Task Fixture(HttpContext context) { var name = context.Request.RouteValues["size"]?.ToString(); if (name == null || !Sizes.TryGetValue(name, out var size)) { context.Response.StatusCode = 404; return; } maximum = Math.Max(maximum, Interlocked.Increment(ref active)); var filter = "color=c=0x085f21:s=" + size + ":r=25" + (name == "sd" ? ",setsar=16/15" : ""); var info = new ProcessStartInfo(encoder) { UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true }; foreach (var arg in new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-threads", "1", "-filter_threads", "1", "-re", "-f", "lavfi", "-i", filter, "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-c:v", "libx264", "-threads", "1", "-preset", "ultrafast", "-g", "25", "-c:a", "aac", "-ac", "2", "-f", "mpegts", "pipe:1" }) info.ArgumentList.Add(arg); using var process = Process.Start(info); process.ErrorDataReceived += (_, _) => { }; process.BeginErrorReadLine(); context.Response.ContentType = "video/mp2t"; try { await process.StandardOutput.BaseStream.CopyToAsync(context.Response.Body, context.RequestAborted); } catch (OperationCanceledException) { } catch (IOException) { } finally { if (!process.HasExited) process.Kill(true); await process.WaitForExitAsync(); Interlocked.Decrement(ref active); } } }