Add opt-in FFmpeg continuity slates with per-viewer localization
This commit is contained in:
@@ -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(); }
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user