Files
jellyfin-plugin-enigma2/Tests/LosslessE2E.cs
T

124 lines
11 KiB
C#

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;
/// <summary>Compare compressed video packet hashes through the real continuity transport.</summary>
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<object>();
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<string>{"-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<string> 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<string[]> 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<string> 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);}
}