Preserve native broadcast video and add patient shared stream startup
This commit is contained in:
@@ -19,7 +19,7 @@ public static class BroadcastTrackTests
|
||||
var layout=new BroadcastTracks(parsed);
|
||||
check(layout.Output[0].Width==1920&&layout.Output[0].Height==1080&&BroadcastTracks.Fallback().Output[0].Width==null,"native negotiation uses probed channel dimensions, never the status picture dimensions");
|
||||
check(layout.Output.Select(s=>s.Index).SequenceEqual(Enumerable.Range(0,5))&&layout.Output[2].Language=="eng","explicitly ordered program exposes distinct stable selectable indices");
|
||||
check(layout.Output.Where(s=>s.Type==MediaStreamType.Audio).All(s=>s.Codec=="aac"&&s.Channels==2),"each normalized audio language has its own AAC track");
|
||||
check(layout.Output[1].Codec=="ac3"&&layout.Output[1].Channels==6&&layout.Output[2].Codec=="aac", "every audio language retains its original codec and surround channels");
|
||||
check(EncodingHelper.FindIndex(layout.Output,layout.Output[2])==2&&EncodingHelper.GetSubtitleStreamIndexForFfmpeg(new(){MediaStreams=layout.Output},layout.Output[3])==3,"installed Jellyfin resolves alternate audio and subtitle maps without unknown-index auto-selection");
|
||||
check(layout.MapArguments().Contains("0:i:261")&&!layout.MapArguments().Contains("-sn"),"producer maps broadcast PIDs instead of unstable probe positions and retains subtitles");
|
||||
var untouched=new BroadcastTracks(parsed,false);
|
||||
|
||||
@@ -35,8 +35,9 @@ public static class ContinuityE2E
|
||||
try
|
||||
{
|
||||
var source = relay.Register($"http://127.0.0.1:{port}/fixture.ts");
|
||||
var pl = await streams.Open(source, "pl", CancellationToken.None);
|
||||
var en = await streams.Open(source, "en", CancellationToken.None);
|
||||
var tracks = LosslessSlateTests.Tracks(1280, 720, 25);
|
||||
var pl = await streams.Open(source, "pl", CancellationToken.None, tracks);
|
||||
var en = await streams.Open(source, "en", CancellationToken.None, tracks);
|
||||
Require(JsonSerializer.Serialize(streams.Status()).Contains("\"Sources\":1"), "two languages share one source worker");
|
||||
Require(!streams.Segment(pl.Id, 0).SequenceEqual(streams.Segment(en.Id, 0)), "same timeline uses different language slates");
|
||||
await File.WriteAllBytesAsync(Path.Combine(output, "pl-slate.ts"), streams.Segment(pl.Id, 0));
|
||||
@@ -81,7 +82,7 @@ public static class ContinuityE2E
|
||||
}
|
||||
var report = new { Passed = true, SyntheticOnly = true, ReceiverConnectionsOpened = 0, ViewerLanguages = new[] { "pl", "en" }, SourceConnections = connections, FailureSeconds = longTest ? 75 : 15,
|
||||
MaximumConcurrentSourceConnections = maximum, RemainingConnections = active, WaitingLossRecoverySamePlayer = true,
|
||||
StrictVideoAudioDecode = true, PreserveSourceResolution = preserveResolution, Width = preserveResolution ? 1280 : ContinuityStreams.Width, Height = preserveResolution ? 720 : ContinuityStreams.Height, FrameRate = ContinuityStreams.FrameRate,
|
||||
StrictVideoAudioDecode = true, PreserveSourceResolution = preserveResolution, Width = 1280, Height = 720, FrameRate = 25, OriginalAvCopied = true,
|
||||
PhysicalAndroidTvTested = false, ActualJellyfinPlayerTested = false };
|
||||
await File.WriteAllTextAsync(Path.Combine(output, "result.json"), JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); Console.WriteLine(JsonSerializer.Serialize(report));
|
||||
}
|
||||
@@ -105,7 +106,7 @@ public static class ContinuityE2E
|
||||
if (!enabled) { context.Response.StatusCode = 503; return; }
|
||||
var current = Interlocked.Increment(ref active); maximum = Math.Max(maximum, current);
|
||||
var info = new ProcessStartInfo(ffmpeg) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true };
|
||||
foreach (var arg in new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-threads", "1", "-filter_threads", "1", "-re", "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=25", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-c:v", "libx264", "-threads", "1", "-preset", "ultrafast", "-g", "25", "-c:a", "aac", "-f", "mpegts", "pipe:1" }) info.ArgumentList.Add(arg);
|
||||
foreach (var arg in new[] { "-hide_banner", "-loglevel", "error", "-nostdin", "-threads", "1", "-filter_threads", "1", "-re", "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=25", "-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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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);}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using Jellyfin.Plugin.Enigma2;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
public static class LosslessSlateTests
|
||||
{
|
||||
public static BroadcastTracks Tracks(int width, int height, float rate) => new(new MediaStream[] {
|
||||
new BroadcastMediaStream { Type=MediaStreamType.Video, Index=0, Codec="h264", Width=width, Height=height,
|
||||
AverageFrameRate=rate, RealFrameRate=rate, Profile="Constrained Baseline", PixelFormat="yuv420p", SampleAspectRatio="1:1" },
|
||||
new MediaStream { Type=MediaStreamType.Audio, Index=1, Codec="aac", Profile="LC", Channels=2, SampleRate=48000 }
|
||||
});
|
||||
public static void Run(Action<bool,string> check)
|
||||
{
|
||||
foreach (var rate in new[] { 24f, 25f, 30f, 50f, 60f, 120f })
|
||||
{
|
||||
var tracks=Tracks(3840,2160,rate);
|
||||
var profile=LosslessSlateProfile.Create(tracks);
|
||||
check(profile?.Rate==rate && profile.Width==3840 && profile.Height==2160 && tracks.Output[0].AverageFrameRate==rate,
|
||||
$"status picture matches source rate {rate} and 4K size without changing channel metadata");
|
||||
}
|
||||
var original=Tracks(3840,2160,60000f/1001);
|
||||
var video=(BroadcastMediaStream)original.Input[0];video.FrameRateRational="60000/1001";
|
||||
check(LosslessSlateProfile.Create(original)==null && original.Output[0].AverageFrameRate==60000f/1001, "fractional cadence is preserved by native fallback instead of rounded for a status image");
|
||||
var key=new BroadcastTracks(original.Input).Key;video.AverageFrameRate=24;
|
||||
check(new BroadcastTracks(original.Input).Key!=key,"source rate changes invalidate cached status images");
|
||||
video.Codec="hevc";video.PixelFormat="yuv420p10le";video.ColorTransfer="smpte2084";video.ColorPrimaries="bt2020";video.Level=153;
|
||||
original.Input[1].Codec="ac3";original.Input[1].Channels=6;
|
||||
var hdr=new BroadcastTracks(original.Input,true,false);
|
||||
check(!hdr.Normalize && hdr.Output[0].Codec=="hevc" && hdr.Output[0].PixelFormat=="yuv420p10le" && hdr.Output[0].ColorTransfer=="smpte2084"
|
||||
&& hdr.Output[0].Level==153 && hdr.Output[0].Width==3840 && hdr.Output[1].Codec=="ac3" && hdr.Output[1].Channels==6,
|
||||
"legacy slate/Full HD options cannot normalize 4K HDR10 or surround audio");
|
||||
check(LosslessSlateProfile.Create(hdr)==null,"HDR/HEVC keeps original transport instead of converting the channel to fit a status picture");
|
||||
var interlaced=Tracks(1920,1080,25);interlaced.Input[0].IsInterlaced=true;
|
||||
check(LosslessSlateProfile.Create(interlaced)==null && new BroadcastTracks(interlaced.Input).Output[0].IsInterlaced,
|
||||
"interlaced broadcast is preserved without plugin deinterlacing");
|
||||
check(LosslessSlateProfile.Create(BroadcastTracks.Fallback())==null,"unknown cold-start metadata cannot force a guessed status format");
|
||||
var unknown=Tracks(1280,720,60);((BroadcastMediaStream)unknown.Input[0]).SampleAspectRatio=null;
|
||||
check(LosslessSlateProfile.Create(unknown)==null,"unknown sample aspect ratio uses unmodified broadcast fallback");
|
||||
var now=DateTime.UtcNow;var timeline=new ContinuityTimeline(now,false);
|
||||
var first=Packet(12000,9000);var second=Packet(195000,189000);
|
||||
timeline.Live(first,"producer",2,now);timeline.Live(second,"producer",2,now.AddSeconds(2));
|
||||
long Decode(long sequence,byte[] packet)
|
||||
{
|
||||
var segment=timeline.Find(sequence);
|
||||
return TransportStreamClock.ClockBounds(TransportStreamClock.Rebase(packet,segment.Start90k,false,segment.ClockOffset90k)).Value.Decode;
|
||||
}
|
||||
check(Decode(1,second)-Decode(0,first)==180000,"copied B-frames retain their decode-clock interval despite different first presentation timestamps");
|
||||
timeline.Tick(now.AddSeconds(9),true);var retry=timeline.Find(2);
|
||||
var recovered=Packet(372000,369000);timeline.Live(recovered,"producer",2,now.AddSeconds(10));
|
||||
check(Decode(3,recovered)>=retry.Start90k+(long)(retry.Duration*90000),"short source stall followed by the same producer cannot rewind behind the inserted status picture");
|
||||
timeline=new ContinuityTimeline(now,false);first=Packet((1L<<33)-6000,(1L<<33)-9000);second=Packet(93000,90000);
|
||||
timeline.Live(first,"wrapped",1.1,now);timeline.Live(second,"wrapped",1.1,now.AddSeconds(1.1));
|
||||
check(Decode(1,second)-Decode(0,first)==99000,"copied transport keeps a monotonic decode clock across the 33-bit timestamp wrap");
|
||||
}
|
||||
private static byte[] Packet(long pts,long dts)
|
||||
{
|
||||
var packet=new byte[188];packet[0]=71;packet[1]=65;packet[2]=0;packet[3]=16;
|
||||
packet[6]=1;packet[7]=224;packet[10]=128;packet[11]=192;packet[12]=10;
|
||||
void Stamp(int at,long time,int prefix)
|
||||
{time&=(1L<<33)-1;packet[at]=(byte)((long)prefix|((time>>29)&14)|1);packet[at+1]=(byte)(time>>22);packet[at+2]=(byte)(1|((time>>14)&254));packet[at+3]=(byte)(time>>7);packet[at+4]=(byte)(1|(time<<1));}
|
||||
Stamp(13,pts,48);Stamp(18,dts,16);return packet;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Enigma2;
|
||||
|
||||
public static class PatientProbeTests
|
||||
{
|
||||
public static async Task Run(Action<bool,string> check)
|
||||
{
|
||||
using var upstream = new RelayHandler();
|
||||
using var relay = new LiveStreamRelay(new HttpClient(upstream),8096,TimeSpan.FromMilliseconds(200));
|
||||
var url=relay.Register("http://receiver/patient");
|
||||
using var local = new HttpClient(new LocalRelayHandler(relay));
|
||||
var attempts=0;var snapshots=new System.Collections.Generic.List<string>();
|
||||
var limits=new LiveStreamProbe.SamplingLimits(TimeSpan.FromMilliseconds(20),TimeSpan.FromMilliseconds(80),188*8,188*128);
|
||||
const string good="""{"streams":[{"index":0,"codec_type":"video","codec_name":"h264","width":1920,"height":1080}]}""";
|
||||
const string bad="""{"streams":[{"index":0,"codec_type":"video","codec_name":"h264","width":0,"height":0}]}""";
|
||||
var probe=new LiveStreamProbe(local,(path,sid,extended,token)=>
|
||||
{snapshots.Add(path);return Task.FromResult(++attempts>=3?good:bad);},limits,TimeSpan.FromSeconds(3),true,TimeSpan.FromMilliseconds(40));
|
||||
var result=await Task.WhenAll(Enumerable.Range(0,4).Select(_=>probe.Get(url,CancellationToken.None)));
|
||||
check(attempts==3 && result.All(x=>x[0].Width==1920),"patient startup retries fresh bounded sample windows shared by all four viewers");
|
||||
check(upstream.Calls==1,"multiple metadata windows keep exactly one physical receiver connection");
|
||||
check(snapshots.All(path=>!File.Exists(path)&&!File.Exists(path.Replace(".sample.ts",""))),"patient success cleans every sample and capture window");
|
||||
await Task.Delay(350);
|
||||
check(System.Text.Json.JsonSerializer.Serialize(relay.Status()).Contains("\"ActiveSources\":0"),"patient startup releases receiver after final probe consumer");
|
||||
|
||||
var started=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var stopped=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var cancellable=new LiveStreamProbe(async (source,token)=>
|
||||
{started.SetResult();try{await Task.Delay(10000,token);return good;}finally{stopped.SetResult();}},TimeSpan.FromSeconds(3));
|
||||
using var first=new CancellationTokenSource();using var second=new CancellationTokenSource();
|
||||
var a=cancellable.Get("cancel",first.Token);var b=cancellable.Get("cancel",second.Token);await started.Task;
|
||||
first.Cancel();try{await a;}catch(OperationCanceledException){}
|
||||
check(!stopped.Task.IsCompleted,"one viewer cancelling does not stop another viewer's patient startup");
|
||||
second.Cancel();try{await b;}catch(OperationCanceledException){}
|
||||
await stopped.Task.WaitAsync(TimeSpan.FromSeconds(1));
|
||||
check(stopped.Task.IsCompleted,"last viewer cancellation stops producer immediately instead of waiting 90 seconds");
|
||||
var bounded=new LiveStreamProbe(local,(path,sid,extended,token)=>Task.FromResult(bad),limits,TimeSpan.FromMilliseconds(250),true,TimeSpan.FromMilliseconds(20));
|
||||
try { await bounded.Get(url,CancellationToken.None); throw new Exception("Expected bounded startup failure"); }
|
||||
catch(InvalidOperationException error)
|
||||
{ check(error.ToString().Contains("video_parameters_unavailable"),"patient deadline preserves the last input diagnosis instead of waiting forever"); }
|
||||
}
|
||||
private sealed class LocalRelayHandler(LiveStreamRelay relay) : HttpMessageHandler
|
||||
{
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token)
|
||||
{
|
||||
var sub=await relay.Subscribe(request.RequestUri.Segments.Last(),token);
|
||||
return new(HttpStatusCode.OK){Content=new StreamContent(new SubscriptionStream(sub))};
|
||||
}
|
||||
}
|
||||
private sealed class SubscriptionStream(LiveStreamRelay.Subscription subscription) : Stream
|
||||
{
|
||||
private byte[] chunk;private int at;private bool disposed;
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer,CancellationToken token=default)
|
||||
{
|
||||
if(chunk==null||at==chunk.Length){chunk=await subscription.Reader.ReadAsync(token);at=0;}
|
||||
var n=Math.Min(buffer.Length,chunk.Length-at);chunk.AsMemory(at,n).CopyTo(buffer);at+=n;return n;
|
||||
}
|
||||
protected override void Dispose(bool disposing){if(disposing&&!disposed){disposed=true;subscription.DisposeAsync().AsTask().GetAwaiter().GetResult();}base.Dispose(disposing);}
|
||||
public override bool CanRead=>true;public override bool CanSeek=>false;public override bool CanWrite=>false;
|
||||
public override long Length=>throw new NotSupportedException();public override long Position{get=>0;set=>throw new NotSupportedException();}
|
||||
public override int Read(byte[] b,int o,int n)=>ReadAsync(b.AsMemory(o,n)).AsTask().GetAwaiter().GetResult();
|
||||
public override void Flush(){}public override long Seek(long o,SeekOrigin s)=>throw new NotSupportedException();
|
||||
public override void SetLength(long l)=>throw new NotSupportedException();public override void Write(byte[] b,int o,int n)=>throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Enigma2;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
public static class PatientStartupE2E
|
||||
{
|
||||
private static int connections, active, maximum;
|
||||
public static async Task Run(string encoder, string folder)
|
||||
{
|
||||
folder=Path.GetFullPath(folder);if(Directory.Exists(folder))throw new Exception("Choose a new output directory");Directory.CreateDirectory(folder);
|
||||
var file=Path.Combine(folder,"fixture.ts");
|
||||
var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardError=true};
|
||||
foreach(var a in new[]{"-v","error","-nostdin","-filter_threads","1","-f","lavfi","-i","testsrc2=size=320x180:rate=25","-f","lavfi","-i","sine=sample_rate=48000","-t","4","-c:v","libx264","-threads","1","-preset","ultrafast","-g","25","-c:a","aac","-ac","2","-f","mpegts",file})info.ArgumentList.Add(a);
|
||||
using(var make=Process.Start(info)){var error=make.StandardError.ReadToEndAsync();await make.WaitForExitAsync();if(make.ExitCode!=0)throw new Exception(await error);}
|
||||
var bytes=await File.ReadAllBytesAsync(file);
|
||||
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 relay=new LiveStreamRelay(new System.Net.Http.HttpClient(),port,TimeSpan.FromMilliseconds(200),TimeSpan.FromSeconds(StartupBudget.Seconds),TimeSpan.FromSeconds(StartupBudget.StallSeconds));
|
||||
builder.Services.AddSingleton(relay);builder.Services.AddControllers().AddApplicationPart(typeof(LiveRelayController).Assembly);
|
||||
await using var app=builder.Build();app.MapControllers();
|
||||
app.MapGet("/late.ts",async context=>
|
||||
{
|
||||
Interlocked.Increment(ref connections);maximum=Math.Max(maximum,Interlocked.Increment(ref active));
|
||||
context.Response.ContentType="video/mp2t";await context.Response.StartAsync(context.RequestAborted);
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(35),context.RequestAborted);
|
||||
while(!context.RequestAborted.IsCancellationRequested)
|
||||
for(var at=0;at<bytes.Length;at+=188*32)
|
||||
{
|
||||
var n=Math.Min(188*32,bytes.Length-at);
|
||||
await context.Response.Body.WriteAsync(bytes.AsMemory(at,n),context.RequestAborted);await context.Response.Body.FlushAsync(context.RequestAborted);
|
||||
await Task.Delay(TimeSpan.FromSeconds(4d*n/bytes.Length),context.RequestAborted);
|
||||
}
|
||||
}
|
||||
catch(OperationCanceledException){}catch(IOException){}
|
||||
finally{Interlocked.Decrement(ref active);}
|
||||
});
|
||||
await app.StartAsync();
|
||||
try
|
||||
{
|
||||
var proxy=DispatchProxy.Create<IMediaEncoder,PatientEncoderProxy>();((PatientEncoderProxy)(object)proxy).ProbePath=Path.Combine(Path.GetDirectoryName(encoder),"ffprobe");
|
||||
var probe=new LiveStreamProbe(proxy);var url=relay.Register($"http://127.0.0.1:{port}/late.ts");var watch=Stopwatch.StartNew();
|
||||
var result=await Task.WhenAll(Enumerable.Range(0,4).Select(_=>probe.Get(url,CancellationToken.None,1))).WaitAsync(TimeSpan.FromSeconds(95));
|
||||
if(watch.Elapsed.TotalSeconds<35||watch.Elapsed.TotalSeconds>=90||result.Any(x=>x[0].Width!=320||x[0].Height!=180))throw new Exception("Delayed source failed the configured budget");
|
||||
await Task.Delay(800);
|
||||
if(maximum!=1||connections!=1||active!=0)throw new Exception("Receiver sharing or cleanup failed");
|
||||
var report=new{Passed=true,SyntheticOnly=true,ReceiverConnectionsOpened=0,ConfiguredStartupSeconds=StartupBudget.Seconds,DelayedFirstDataSeconds=35,
|
||||
ElapsedSeconds=watch.Elapsed.TotalSeconds,ConcurrentRequesters=4,PhysicalFixtureConnections=connections,MaximumConcurrentFixtureConnections=maximum,RemainingConnections=active,
|
||||
ActualFfprobeUsed=true,PhysicalAndroidTvTested=false};
|
||||
await File.WriteAllTextAsync(Path.Combine(folder,"result.json"),JsonSerializer.Serialize(report,new JsonSerializerOptions{WriteIndented=true}));Console.WriteLine(JsonSerializer.Serialize(report));
|
||||
}
|
||||
finally{relay.Dispose();await app.StopAsync();}
|
||||
}
|
||||
}
|
||||
public class PatientEncoderProxy : DispatchProxy
|
||||
{
|
||||
public string ProbePath;
|
||||
protected override object Invoke(MethodInfo method,object[] args)=>method.Name=="get_ProbePath"?ProbePath:null;
|
||||
}
|
||||
@@ -18,6 +18,8 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
if (args.Length == 3 && args[0] == "--patient-startup-e2e") { await PatientStartupE2E.Run(args[1], args[2]); return; }
|
||||
if (args.Length == 3 && args[0] == "--lossless-e2e") { await LosslessE2E.Run(args[1], args[2]); return; }
|
||||
if (args.Length == 3 && args[0] == "--broadcast-tracks-e2e") { await BroadcastTracksE2E.Run(args[1], args[2]); return; }
|
||||
if (args.Length == 3 && args[0] == "--native-continuity-e2e") { await ContinuityE2E.Run(args[1], args[2], true, true); return; }
|
||||
if (args.Length == 3 && args[0] == "--resolution-e2e") { await ResolutionE2E.Run(args[1], args[2]); return; }
|
||||
@@ -113,6 +115,7 @@ Check(true,"empty source rejected before playback");
|
||||
var liveVideo=LiveStreamProbe.ParseLiveMetadata(probes[0][0]);
|
||||
Check(liveVideo.Index==-1&&liveVideo.IsInterlaced&&liveVideo.AverageFrameRate==25&&probes[0][0].Index==0,"live automatic mapping preserves metadata without mutating cached probe");
|
||||
await AdaptiveProbeTests.Run(Check);
|
||||
await PatientProbeTests.Run(Check);
|
||||
await RelayStartupTests.Run(Check);
|
||||
var relayHandler=new RelayHandler();
|
||||
using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler),8096,TimeSpan.FromMilliseconds(100)))
|
||||
@@ -133,6 +136,7 @@ await SavedChannelPlaybackTests.Run(Check);
|
||||
await ChannelSyncTests.Run(Check);
|
||||
await PlaybackFeedbackTests.Run(Check);
|
||||
ContinuityTests.Run(Check);
|
||||
LosslessSlateTests.Run(Check);
|
||||
NativeResolutionPolicyTests.Run(Check);
|
||||
public class PathsProxy : DispatchProxy
|
||||
{
|
||||
|
||||
+11
-7
@@ -40,8 +40,12 @@ public static class ResolutionE2E
|
||||
{
|
||||
foreach (var (name, size) in Sizes)
|
||||
{
|
||||
var media = await manager.Open(relay.Register($"http://127.0.0.1:{port}/fixture/{name}.ts"), "en", CancellationToken.None);
|
||||
Require(media.MediaStreams[0].Width == null && media.MediaStreams[0].Height == null && media.MediaStreams[0].Level == null,
|
||||
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" };
|
||||
@@ -66,11 +70,11 @@ public static class ResolutionE2E
|
||||
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 dimensions = text.Split('\n').Where(line => line.Length > 0).Select(line => line.Trim()).Distinct().ToArray();
|
||||
var decodedDimensions = text.Split('\n').Where(line => line.Length > 0).Select(line => line.Trim()).Distinct().ToArray();
|
||||
var expected = size.Replace('x', ',') + ",";
|
||||
Require(dimensions.Any(line => line.StartsWith(expected, StringComparison.Ordinal)), "native dimensions reach the decoded client frames: " + size);
|
||||
if (name == "sd") Require(dimensions.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 = dimensions });
|
||||
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);
|
||||
}
|
||||
@@ -95,7 +99,7 @@ public static class ResolutionE2E
|
||||
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", "-f", "mpegts", "pipe:1" }) info.ArgumentList.Add(arg);
|
||||
"-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) { }
|
||||
|
||||
Reference in New Issue
Block a user