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; /// Compare compressed video packet hashes through the real continuity transport. public static class LosslessE2E { private static string encoder, output, fixture; private static int active, maximum; 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 test 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 streams=new ContinuityStreams(encoder,Path.Combine(output,"cache"),port,new StartupMessages(),NullLogger.Instance); using var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(),port,TimeSpan.FromSeconds(1),TimeSpan.FromSeconds(10),TimeSpan.FromSeconds(5)); builder.Services.AddSingleton(streams);builder.Services.AddSingleton(relay);builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly); await using var app=builder.Build();app.MapControllers();app.MapGet("/fixture.ts",Fixture);await app.StartAsync(); var reports=new List(); try { foreach(var profile in new[]{"h264-60","h264-23976","hevc-hdr-5994","h264-interlaced"}) { fixture=Path.Combine(output,profile+"-input.ts"); var hdr=profile.StartsWith("hevc");var interlaced=profile.EndsWith("interlaced"); var rate=hdr?"60000/1001":profile=="h264-23976"?"24000/1001":interlaced?"25":"60"; var size=hdr?"3840x2160":interlaced?"1920x1080":"640x360"; var args=new List{"-f","lavfi","-i",$"{(hdr?"color=c=0x085f21:":"testsrc2=")}size={size}:rate={rate}","-f","lavfi","-i","sine=frequency=440:sample_rate=48000","-t","8","-vf",hdr?"format=yuv420p10le,setsar=1":interlaced?"setsar=1,setfield=tff":"setsar=1","-c:v",hdr?"libx265":"libx264","-threads","1","-preset","ultrafast","-g",hdr?"60":interlaced?"25":"24","-c:a",hdr?"ac3":"aac","-ac",hdr?"6":"2"}; if(hdr)args.AddRange(new[]{"-x265-params","pools=1:frame-threads=1:log-level=error:colorprim=9:transfer=16:colormatrix=9:hdr10=1:master-display=G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1):max-cll=1000,400","-color_trc","smpte2084","-color_primaries","bt2020","-colorspace","bt2020nc"}); if(interlaced)args.AddRange(new[]{"-flags","+ilme+ildct","-x264-params","tff=1"}); args.AddRange(new[]{"-f","mpegts",fixture}); using(var make=Start(args,profile+"-fixture")){await make.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(90));Require(make.ExitCode==0,"generate "+profile);} var metadata=LiveStreamProbe.Parse(await Probe(fixture));var tracks=new BroadcastTracks(metadata,true,false); if(hdr)Require(metadata[0].ColorTransfer=="smpte2084" && metadata[0].ColorPrimaries=="bt2020", "fixture carries real HDR10 PQ/BT2020 signaling"); var compatible=LosslessSlateProfile.Create(tracks)!=null; Require(compatible==(profile=="h264-60"),"safe slate compatibility: "+profile); var sourceHashes=(await Hashes(fixture)).ToHashSet(); var media=await streams.Open(relay.Register($"http://127.0.0.1:{port}/fixture.ts?profile={profile}"),"en",CancellationToken.None,tracks); Require(media.MediaStreams[0].Codec==metadata[0].Codec&&media.MediaStreams[0].AverageFrameRate==metadata[0].AverageFrameRate &&media.MediaStreams[0].IsInterlaced==metadata[0].IsInterlaced&&media.MediaStreams[0].PixelFormat==metadata[0].PixelFormat &&media.MediaStreams[0].ColorTransfer==metadata[0].ColorTransfer&&media.MediaStreams[0].Width==metadata[0].Width &&media.MediaStreams[1].Channels==metadata[1].Channels,"native source metadata unchanged: "+profile); if(compatible) { var waiting=Path.Combine(output,profile+"-waiting.ts");await File.WriteAllBytesAsync(waiting,streams.Segment(media.Id,0)); var waitingHashes=await Hashes(waiting); Require(waitingHashes.Length>10 && waitingHashes.All(h=>!sourceHashes.Contains(h)),"real generated waiting picture precedes unchanged source: "+profile); } var recording=Path.Combine(output,profile+"-output.ts"); using(var client=Start(new[]{"-xerror","-i",media.Path,"-t",compatible?"18":"6","-map","0:v:0","-map","0:a","-c","copy","-f","mpegts",recording},profile+"-client")) { try{await client.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(65));Require(client.ExitCode==0,"client copies "+profile);} finally{if(!client.HasExited)client.Kill(true);streams.Close(media.Id);} } await Task.Delay(1500); var inputHashes=await Hashes(fixture);var actual=await Hashes(recording); var expected=inputHashes.ToHashSet();var matching=actual.Count(expected.Contains); Require(matching>10,"original compressed video packets reach client unchanged: "+profile); if(compatible)Require(actual.Count(h=>!expected.Contains(h))>10,"recording contains both status picture and original video: "+profile); if(!compatible)Require(actual.All(expected.Contains),"every video packet is an original packet in native fallback: "+profile); using(var decode=Start(new[]{"-xerror","-i",recording,"-map","0:v:0","-map","0:a","-f","null","-"},profile+"-decode")) {await decode.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(60));Require(decode.ExitCode==0,"strict decode "+profile);} var decodedMetadata=LiveStreamProbe.Parse(await Probe(recording)); Require(decodedMetadata[0].Width==metadata[0].Width && decodedMetadata[0].Height==metadata[0].Height && decodedMetadata[0].PixelFormat==metadata[0].PixelFormat && decodedMetadata[0].IsInterlaced==metadata[0].IsInterlaced && decodedMetadata[0].ColorTransfer==metadata[0].ColorTransfer && decodedMetadata[0].ColorPrimaries==metadata[0].ColorPrimaries, "actual output retains dimensions, bit depth, interlace and HDR signaling: "+profile); reports.Add(new{Profile=profile,SlatesInserted=compatible,Width=metadata[0].Width,Height=metadata[0].Height,FrameRate=metadata[0].AverageFrameRate, Codec=metadata[0].Codec,PixelFormat=metadata[0].PixelFormat,Transfer=metadata[0].ColorTransfer,Primaries=metadata[0].ColorPrimaries,OutputTransfer=decodedMetadata[0].ColorTransfer,Interlaced=metadata[0].IsInterlaced,AudioChannels=metadata[1].Channels, OriginalCompressedVideoPackets=matching,OutputVideoPackets=actual.Length,AllOutputVideoPacketsOriginal=!compatible&&actual.All(expected.Contains),StrictDecode=true}); } Require(active==0&&maximum==1,"release source; at most one upstream"); var result=new{Passed=true,SyntheticOnly=true,ReceiverConnectionsOpened=0,PhysicalAndroidTvTested=false,ActualJellyfinPlayerTested=false,MaximumConcurrentSourceConnections=maximum,RemainingConnections=active,Reports=reports}; await File.WriteAllTextAsync(Path.Combine(output,"result.json"),JsonSerializer.Serialize(result,new JsonSerializerOptions{WriteIndented=true}));Console.WriteLine(JsonSerializer.Serialize(result)); } finally{streams.Dispose();await app.StopAsync();} } private static async Task Probe(string path) { var info=new ProcessStartInfo(Path.Combine(Path.GetDirectoryName(encoder),"ffprobe")){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true}; foreach(var a in new[]{"-v","error","-show_streams","-of","json",path})info.ArgumentList.Add(a); using var p=Process.Start(info);var text=p.StandardOutput.ReadToEndAsync();var error=p.StandardError.ReadToEndAsync();await p.WaitForExitAsync();Require(p.ExitCode==0,"probe fixture");await error;return await text; } private static async Task Hashes(string path) { var info=new ProcessStartInfo(Path.Combine(Path.GetDirectoryName(encoder),"ffprobe")){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true}; foreach(var a in new[]{"-v","error","-select_streams","v:0","-show_packets","-show_data_hash","sha256","-show_entries","packet=data_hash","-of","csv=p=0",path})info.ArgumentList.Add(a); using var p=Process.Start(info);var text=p.StandardOutput.ReadToEndAsync();var error=p.StandardError.ReadToEndAsync();await p.WaitForExitAsync();Require(p.ExitCode==0,"hash encoded packets");await error; return (await text).Split('\n').Select(l=>l.Split(',').FirstOrDefault(x=>x.StartsWith("SHA256:"))).Where(l=>l!=null).Select(l=>l.Trim()).ToArray(); } private static Process Start(IEnumerable args,string name) { var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardError=true,RedirectStandardOutput=true}; foreach(var a in new[]{"-hide_banner","-loglevel","warning","-nostdin","-filter_threads","1","-threads","1"}.Concat(args))info.ArgumentList.Add(a); var p=Process.Start(info);p.ErrorDataReceived+=(_,e)=>{if(e.Data!=null)lock(typeof(LosslessE2E))File.AppendAllText(Path.Combine(output,name+".log"),e.Data+"\n");};p.BeginErrorReadLine();return p; } private static async Task Fixture(HttpContext context) { maximum=Math.Max(maximum,Interlocked.Increment(ref active)); var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true}; foreach(var a in new[]{"-hide_banner","-loglevel","error","-nostdin","-re","-stream_loop","-1","-i",fixture,"-map","0","-c","copy","-f","mpegts","pipe:1"})info.ArgumentList.Add(a); using var p=Process.Start(info);p.ErrorDataReceived+=(_,_)=>{};p.BeginErrorReadLine();context.Response.ContentType="video/mp2t"; try{await p.StandardOutput.BaseStream.CopyToAsync(context.Response.Body,context.RequestAborted);} catch(OperationCanceledException){}catch(IOException){} finally{if(!p.HasExited)p.Kill(true);await p.WaitForExitAsync();Interlocked.Decrement(ref active);} } private static void Require(bool value,string message){if(!value)throw new Exception(message);Console.WriteLine("PASS "+message);} }