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 check) { var user=new User("feedback-user","auth","reset");user.SetPermission(PermissionKind.EnableLiveTvAccess,true); var auth=DispatchProxy.Create(); ((FeedbackAuth)(object)auth).Info=new AuthorizationInfo{User=user,DeviceId="device-a",Client="Jellyfin Web"}; var sessions=DispatchProxy.Create(); 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(); ((PolicyLibraryProxy)(object)library).Current=user;((PolicyLibraryProxy)(object)library).Item=new MediaBrowser.Controller.LiveTv.LiveTvChannel{ServiceName="Enigma2",ExternalId="channel-a"}; var users=DispatchProxy.Create();((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(); 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); } }