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
+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);
}
}