Add opt-in FFmpeg continuity slates with per-viewer localization

This commit is contained in:
2026-10-23 22:35:40 +02:00
parent d097aad032
commit 5f6b0b5ff6
22 changed files with 1407 additions and 30 deletions
+123
View File
@@ -0,0 +1,123 @@
using System;
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>Real FFmpeg HTTP/HLS failover; synthetic source only, no decoder or Jellyfin configuration.</summary>
public static class ContinuityE2E
{
private static string ffmpeg;
private static volatile bool enabled;
private static int connections, active, maximum;
public static async Task Run(string encoder, string output, bool longTest = false)
{
ffmpeg = encoder; output = Path.GetFullPath(output);
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 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(relay); builder.Services.AddSingleton(streams); builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly);
await using var app = builder.Build(); app.MapControllers(); app.MapGet("/fixture.ts", Fixture); await app.StartAsync();
Process first = null, second = null;
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);
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));
await File.WriteAllBytesAsync(Path.Combine(output, "en-slate.ts"), streams.Segment(en.Id, 0));
var denied = new ContinuityController(streams) { ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext { HttpContext = new DefaultHttpContext() } };
denied.HttpContext.Connection.RemoteIpAddress = IPAddress.Parse("192.0.2.1");
Require(denied.Playlist(pl.Id) is Microsoft.AspNetCore.Mvc.StatusCodeResult { StatusCode: 403 }, "private continuity endpoint denies remote access");
denied.HttpContext.Connection.RemoteIpAddress = IPAddress.Loopback; denied.Request.Headers["X-Forwarded-For"] = "192.0.2.1";
Require(denied.Playlist(pl.Id) is Microsoft.AspNetCore.Mvc.StatusCodeResult { StatusCode: 403 }, "private continuity endpoint denies forwarded requests");
first = Start(new[] { "-xerror", "-i", pl.Path, "-t", longTest ? "150" : "65", "-map", "0:v:0", "-map", "0:a:0", "-c", "copy", Path.Combine(output, "pl.mkv") }, output, "pl-client");
second = Start(new[] { "-xerror", "-i", en.Path, "-t", longTest ? "150" : "65", "-map", "0:v:0", "-map", "0:a:0", "-c", "copy", Path.Combine(output, "en.mkv") }, output, "en-client");
await Task.Delay(8000); enabled = true; Console.WriteLine("E2E source enabled after initial failure");
await Task.Delay(20000);
var beforeLoss = JsonSerializer.SerializeToElement(streams.Status()).GetProperty("LiveSegmentsProduced").GetInt64();
Console.WriteLine("E2E status before loss " + JsonSerializer.Serialize(streams.Status()));
Require(beforeLoss > 0, "real synthetic video reached clients before induced loss"); enabled = false; Console.WriteLine("E2E source disconnected");
await Task.Delay(longTest ? 75000 : 15000); enabled = true; Console.WriteLine("E2E source recovered");
await Task.WhenAll(first.WaitForExitAsync(), second.WaitForExitAsync()).WaitAsync(TimeSpan.FromSeconds(65));
Require(first.ExitCode == 0 && second.ExitCode == 0, "both HLS clients survive waiting, source loss and recovery without reopening");
foreach (var language in new[] { "pl", "en" })
{
using var decode = Start(new[] { "-xerror", "-i", Path.Combine(output, language + ".mkv"), "-map", "0:v:0", "-map", "0:a:0", "-f", "null", "-" }, output, language + "-decode");
await decode.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(40)); Require(decode.ExitCode == 0, "strict decode across all switches: " + language);
foreach (var (name, secondAt) in new[] { ("waiting", 1), ("live", longTest ? 39 : 22), ("retrying", longTest ? 70 : 39), ("recovered", longTest ? 140 : 60) })
{
using var image = Start(new[] { "-ss", secondAt.ToString(), "-i", Path.Combine(output, language + ".mkv"), "-frames:v", "1", "-update", "1", Path.Combine(output, language + "-" + name + ".png") }, output, language + "-" + name);
await image.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); Require(image.ExitCode == 0, "frame extraction: " + language + " " + name);
}
}
Console.WriteLine("E2E status after recovery "+JsonSerializer.Serialize(streams.Status()));
Require(JsonSerializer.SerializeToElement(streams.Status()).GetProperty("LiveSegmentsProduced").GetInt64() > beforeLoss, "new live segments arrive after recovery");
streams.Close(pl.Id); Require(JsonSerializer.Serialize(streams.Status()).Contains("\"Viewers\":1"), "one viewer stopping leaves the other active");
streams.Close(en.Id); await Task.Delay(2000);
Require(active == 0 && maximum <= 1 && connections >= 2, "one upstream at a time, retries and final source cleanup");
foreach (var language in new[] { "pl", "en", "de", "fr", "es", "uk" })
foreach (var phase in new[] { "waiting", "retrying" })
{
var path=Path.Combine(output,language+"-"+phase+"-slate.ts");
await File.WriteAllBytesAsync(path,await streams.SlatePreview(language,phase,CancellationToken.None));
using var image=Start(new[]{"-i",path,"-frames:v","1","-update","1",Path.Combine(output,language+"-"+phase+"-slate.png")},output,language+"-"+phase+"-slate");
await image.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));Require(image.ExitCode==0,"localized FFmpeg slate: "+language+" "+phase);
}
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, Width = ContinuityStreams.Width, Height = ContinuityStreams.Height, FrameRate = ContinuityStreams.FrameRate,
PhysicalAndroidTvTested = false, ActualJellyfinPlayerTested = false };
await File.WriteAllTextAsync(Path.Combine(output, "result.json"), JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); Console.WriteLine(JsonSerializer.Serialize(report));
}
finally
{
if (first is { HasExited: false }) first.Kill(true); if (second is { HasExited: false }) second.Kill(true);
first?.Dispose(); second?.Dispose(); streams.Dispose(); await app.StopAsync();
}
}
private static void Require(bool condition, string message) { if (!condition) throw new Exception(message); Console.WriteLine("PASS " + message); }
private static Process Start(string[] args, string output, string name)
{
var info = new ProcessStartInfo(ffmpeg) { UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true };
foreach (var arg in new[] { "-hide_banner", "-loglevel", "warning", "-nostdin", "-threads", "1", "-filter_threads", "1" }.Concat(args)) info.ArgumentList.Add(arg);
var process = Process.Start(info); process.ErrorDataReceived += (_, e) => { if (e.Data != null) lock (typeof(ContinuityE2E)) File.AppendAllText(Path.Combine(output, name + ".log"), e.Data + "\n"); };
process.BeginErrorReadLine(); return process;
}
private static async Task Fixture(HttpContext context)
{
Interlocked.Increment(ref connections);
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);
using var process = Process.Start(info); process.ErrorDataReceived += (_, _) => { }; process.BeginErrorReadLine(); context.Response.ContentType = "video/mp2t";
try
{
var buffer = new byte[64 * 1024];
while (enabled && !context.RequestAborted.IsCancellationRequested)
{
var count = await process.StandardOutput.BaseStream.ReadAsync(buffer, context.RequestAborted); if (count == 0) break;
await context.Response.Body.WriteAsync(buffer.AsMemory(0, count), context.RequestAborted); await context.Response.Body.FlushAsync(context.RequestAborted);
}
}
catch (OperationCanceledException) { }
catch (IOException) { }
finally { if (!process.HasExited) process.Kill(true); await process.WaitForExitAsync(); Interlocked.Decrement(ref active); context.Abort(); }
}
}
+69
View File
@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Configuration;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
public static class ContinuityTests
{
public static void Run(Action<bool, string> check)
{
check(StartupMessages.ResolveLanguage("de-DE", "pl", "fr") == "de", "explicit Jellyfin UI language wins over HTTP and server language");
check(StartupMessages.ResolveLanguage(null, "en;q=0,uk-UA;q=0.8,pl;q=0.6", "de") == "uk", "language matching honours regional locale, quality and q=0");
check(StartupMessages.ResolveLanguage(null, null, "pl-PL") == "pl", "missing client locale uses Jellyfin server UI culture");
check(StartupMessages.ResolveLanguage("../../secret", "xx;q=1,de;q=oops", "xx") == "en", "unsupported and malformed locales use safe English fallback");
var cfg = DispatchProxy.Create<IServerConfigurationManager, FeedbackConfig>();
var prefs = DispatchProxy.Create<IDisplayPreferencesManager, FeedbackPreferences>();
var proxy = (FeedbackPreferences)(object)prefs; var user = Guid.NewGuid(); proxy.User = user;
var messages = new StartupMessages(cfg, prefs);
check(messages.Resolve(user, null, null) == "pl", "empty server profile uses actual server UI setting");
proxy.Language = "fr-FR";
check(messages.Resolve(user, null, "de") == "fr" && proxy.LastUser == user, "stored interface language is read only for the authenticated profile");
check(messages.Resolve(user, "uk", "de") == "uk", "current device UI choice overrides stored profile language");
messages.RememberClientLanguage(user, "web-a", "es");
check(messages.ClientLanguage(user, "web-a") == "es" && messages.ClientLanguage(user, "tv-a") == null && messages.ClientLanguage(Guid.NewGuid(), "web-a") == null,
"UI language handoff isolates users and devices");
var keys = messages.Texts("en").Keys.Order().ToArray();
foreach (var locale in new[] { "pl", "en", "de", "fr", "es", "uk" })
check(messages.Texts(locale).Keys.Order().SequenceEqual(keys) && messages.Text(locale, "slate_retrying").Contains('\n'), "complete shared text and FFmpeg slate catalogue: " + locale);
check(Math.Abs(ContinuityTimeline.SegmentSeconds * 50 - 112) < 0.00001 && Math.Abs(ContinuityTimeline.SegmentSeconds * 48000 / 1024 - 105) < 0.00001,
"segment boundary aligns whole video frames and whole AAC frames");
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]=128;packet[12]=5;
packet[13]=33;packet[15]=1;packet[17]=1;
var rebased=TransportStreamClock.Rebase(packet,(1L<<33)+90000);
var p=201;long pts=((long)(rebased[p]&14)<<29)|((long)rebased[p+1]<<22)|((long)(rebased[p+2]&254)<<14)|((long)rebased[p+3]<<7)|((long)rebased[p+4]>>1);
check(pts==90000&&rebased.Length==376&&rebased[5]==128&&packet[13]==33,"TS rebasing signals continuity reset, wraps 33-bit PTS and preserves cached input");
var now = DateTime.UtcNow; var timeline = new ContinuityTimeline(now);
check(!timeline.Playlist().Contains("ENDLIST") && timeline.Find(0).Slate == "waiting", "an immediately playable slate precedes decoder readiness");
timeline.Live(new byte[] { 1 }, "first", 2, now.AddSeconds(4));
check(timeline.Find(2).Discontinuity && timeline.Find(2).Video[0] == 1, "slate to real video marks an HLS discontinuity");
timeline.Tick(now.AddSeconds(11), true);
check(timeline.Find(3).Slate == "retrying" && !timeline.Playlist().Contains("ENDLIST"), "input loss keeps the same playlist alive with a retry slate");
timeline.Live(new byte[] { 2 }, "second", 2, now.AddSeconds(15));
check(timeline.Find(4).Discontinuity && timeline.Find(4).Video[0] == 2, "recovered producer returns to live video in the same stream");
for (var i = 0; i < 10000; i++) timeline.Tick(now.AddSeconds(20 + i * ContinuityTimeline.SegmentSeconds), true);
check(timeline.Find(0) == null && !timeline.Playlist().Contains("ENDLIST") && timeline.Find(10004) != null, "six-hour failures keep a bounded monotonic live timeline");
}
}
public class FeedbackConfig : DispatchProxy
{
protected override object Invoke(MethodInfo method, object[] args) => method.Name == "get_Configuration" ? new ServerConfiguration { UICulture = "pl" } : null;
}
public class FeedbackPreferences : DispatchProxy
{
public Guid User, LastUser;
public string Language;
protected override object Invoke(MethodInfo method, object[] args)
{
if (method.Name != "ListCustomItemDisplayPreferences") throw new Exception("Unexpected preference mutation");
LastUser = (Guid)args[0]; if (LastUser != User) throw new Exception("Wrong profile");
return Language == null ? new Dictionary<string, string>() : new Dictionary<string, string> { ["language"] = Language };
}
}
+85
View File
@@ -0,0 +1,85 @@
using Jellyfin.Data;
using System.Collections.Concurrent;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Logging.Abstractions;
using System;
using System.Linq;
using System.Reflection;
using System.Security.Claims;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
public static class PlaybackFeedbackTests
{
public static async Task Run(Action<bool,string> check)
{
var user=new User("feedback-user","auth","reset");user.SetPermission(PermissionKind.EnableLiveTvAccess,true);
var auth=DispatchProxy.Create<IAuthorizationContext,FeedbackAuth>();
((FeedbackAuth)(object)auth).Info=new AuthorizationInfo{User=user,DeviceId="device-a",Client="Jellyfin Web"};
var sessions=DispatchProxy.Create<ISessionManager,FeedbackSessions>();
var contexts=new HttpContextAccessor{HttpContext=new DefaultHttpContext()};
var feedback=new PlaybackFeedback(contexts,auth,sessions);
var first=await feedback.Begin("channel-a",CancellationToken.None);
first.Report("checking_stream");
string Snapshot(Guid id,string device,string reference)=>JsonSerializer.Serialize(feedback.Snapshot(id,device,reference));
check(Snapshot(user.Id,"device-a","channel-a").Contains("checking_stream"),"startup feedback exposes actual probe phase");
check(!Snapshot(user.Id,"device-b","channel-a").Contains("checking_stream")&&!Snapshot(Guid.NewGuid(),"device-a","channel-a").Contains("checking_stream"),"startup progress isolates user and device");
check(!Snapshot(user.Id,"device-a","channel-b").Contains("checking_stream"),"startup progress isolates channels");
var second=await feedback.Begin("channel-a",CancellationToken.None);first.Report("failed");second.Report("ready");
check(Snapshot(user.Id,"device-a","channel-a").Contains("ready")&&!Snapshot(user.Id,"device-a","channel-a").Contains("failed"),"late old attempt cannot replace current progress");
check(!Snapshot(user.Id,"device-a","channel-a").Contains("device-a")&&!Snapshot(user.Id,"device-a","channel-a").Contains(user.Id.ToString()),"progress response excludes user and device identifiers");
check(((FeedbackSessions)(object)sessions).Messages==0,"web requests never send unsolicited native messages");
contexts.HttpContext=null;check(await feedback.Begin("recording",CancellationToken.None)==null,"background recording has no guessed message destination");
contexts.HttpContext=new DefaultHttpContext();user.SetPermission(PermissionKind.EnableLiveTvAccess,false);
check(await feedback.Begin("channel-a",CancellationToken.None)==null,"feedback requires live TV permission");
check(PlaybackFeedback.IsAndroidTv("Jellyfin Android TV")&&!PlaybackFeedback.IsAndroidTv("Jellyfin Android")&&!PlaybackFeedback.IsAndroidTv("Jellyfin Web"),"native messages scoped to Android TV clients");
user.SetPermission(PermissionKind.EnableLiveTvAccess,true);
var library=DispatchProxy.Create<MediaBrowser.Controller.Library.ILibraryManager,PolicyLibraryProxy>();
((PolicyLibraryProxy)(object)library).Current=user;((PolicyLibraryProxy)(object)library).Item=new MediaBrowser.Controller.LiveTv.LiveTvChannel{ServiceName="Enigma2",ExternalId="channel-a"};
var users=DispatchProxy.Create<MediaBrowser.Controller.Library.IUserManager,PolicyUserProxy>();((PolicyUserProxy)(object)users).Current=user;
var context=new DefaultHttpContext();context.User=new ClaimsPrincipal(new ClaimsIdentity(new[]{new Claim("Jellyfin-UserId",user.Id.ToString())},"test"));
var controller=new PlaybackFeedbackController(feedback,library,users,auth){ControllerContext=new ControllerContext{HttpContext=context}};
check(await controller.Get(Guid.NewGuid()) is OkObjectResult,"progress endpoint uses native user-filtered channel access");
((PolicyLibraryProxy)(object)library).Item=null;check(await controller.Get(Guid.NewGuid()) is NotFoundResult,"invisible native channel cannot expose startup state");
context.User=new ClaimsPrincipal();check(await controller.Get(Guid.NewGuid()) is UnauthorizedResult,"progress endpoint rejects absent authentication");
var manager=(FeedbackSessions)(object)sessions;
var native=new SessionInfo(sessions,NullLogger.Instance){Id="tv-session",UserId=user.Id,DeviceId="device-a",Client="Jellyfin Android TV",LastActivityDate=DateTime.UtcNow,
Capabilities=new ClientCapabilities{SupportedCommands=new[]{GeneralCommandType.DisplayMessage}}};
var other=new SessionInfo(sessions,NullLogger.Instance){Id="other-session",UserId=user.Id,DeviceId="device-b",Client="Jellyfin Android TV",LastActivityDate=DateTime.UtcNow};
manager.Entries=new[]{native,other}; contexts.HttpContext.Request.Headers.AcceptLanguage="uk-UA";
check(native.IsActive,"native feedback fixture has an active session");
var nativeAttempt=await feedback.Begin("native",CancellationToken.None);nativeAttempt.Report("waiting_for_stream");nativeAttempt.Report("ready");nativeAttempt.Report("failed");
for(var i=0;i<100&&manager.Messages<3;i++)await Task.Delay(10);
check(manager.Messages==3&&manager.Sent.All(m=>m.Target==native.Id&&m.Message.Header=="Телебачення"),"native localized feedback targets only the requesting user/device and has a three-message ceiling");
manager.Entries=new[]{native,native};var ambiguous=await feedback.Begin("ambiguous",CancellationToken.None);ambiguous.Report("ready");await Task.Delay(30);
check(manager.Messages==3,"ambiguous native session never broadcasts a message");
((FeedbackAuth)(object)auth).Info.IsApiKey=true;
check(await feedback.Begin("api-key",CancellationToken.None)==null,"server API keys cannot impersonate a native viewer");
}
}
public class FeedbackAuth:DispatchProxy
{
public AuthorizationInfo Info;
protected override object Invoke(MethodInfo method,object[] args)=>Task.FromResult(Info);
}
public class FeedbackSessions:DispatchProxy
{
public int Messages;
public SessionInfo[] Entries=Array.Empty<SessionInfo>();
public ConcurrentQueue<(string Target,MessageCommand Message)> Sent=new();
protected override object Invoke(MethodInfo method,object[] args)
{
if(method.Name=="get_Sessions")return Entries;
if(method.Name=="SendMessageCommand"){Sent.Enqueue(((string)args[1],(MessageCommand)args[2]));Interlocked.Increment(ref Messages);return Task.CompletedTask;}
throw new InvalidOperationException(method.Name);
}
}
+3
View File
@@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
if (args.Length == 3 && (args[0] == "--continuity-e2e" || args[0] == "--long-continuity-e2e")) { await ContinuityE2E.Run(args[1], args[2], args[0].StartsWith("--long")); return; }
var time=DateTime.UtcNow;
void Check(bool ok,string name){if(!ok)throw new Exception(name);Console.WriteLine("PASS "+name);}
ChannelReferenceTests.Run(Check);
@@ -126,6 +127,8 @@ using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler)
}
await SavedChannelPlaybackTests.Run(Check);
await ChannelSyncTests.Run(Check);
await PlaybackFeedbackTests.Run(Check);
ContinuityTests.Run(Check);
public class PathsProxy : DispatchProxy
{
public string Folder;