using System; using Jellyfin.Plugin.Enigma2; using System.IO; using System.Reflection; using System.Security.Claims; using System.Text.Json.Nodes; using System.Threading; using System.Linq; using Jellyfin.Data; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Plugin.Enigma2.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; var time=DateTime.UtcNow; void Check(bool ok,string name){if(!ok)throw new Exception(name);Console.WriteLine("PASS "+name);} var entry=new InternetEpg.Entry("Barwy szczęścia",time,"https://ptv-prod.wphcdn.cloud/media/a.jpg"); Check(InternetEpg.Match(new[]{entry},"Barwy szczęścia",time.AddMinutes(5))==entry,"matching channel programme time"); Check(InternetEpg.Match(new[]{entry},"Inny program",time)==null,"reject different title"); Check(InternetEpg.Match(new[]{entry},entry.Title,time.AddMinutes(8))==null,"reject distant emission"); Check(InternetEpg.Match(new[]{entry,entry},entry.Title,time)==null,"reject ambiguous emission"); Check(InternetEpg.ChannelKey("TVP 2 HD")==InternetEpg.ChannelKey("TVP 2"),"HD channel alias"); Check(!InternetEpg.AllowedImage("http://127.0.0.1/a"),"reject local image URL"); Check(!InternetEpg.AllowedImage("https://ptv-prod.wphcdn.cloud.evil.example/a"),"exact artwork provider host"); Check(InternetEpg.AllowedImage(entry.Image),"real provider image"); var prefs=new ChannelPreferences{FavoriteIds=new[]{Guid.NewGuid().ToString(),"../other-user"},DefaultView="invalid"};prefs.Validate(); Check(prefs.FavoriteIds.Length==1 && prefs.DefaultView=="all","preference input validation"); var legacyConfiguration=(PluginConfiguration)new System.Xml.Serialization.XmlSerializer(typeof(PluginConfiguration)).Deserialize(new StringReader("receiver")); Check(legacyConfiguration.PreferH264ForWebLiveTv,"legacy configuration defaults to web H264 preference"); var ownedChannel=new LiveTvChannel{Id=Guid.NewGuid(),ServiceName="Enigma2"}; var policy=WebPlaybackPolicy.For(ownedChannel,true); Check(policy.IsEnigmaChannel&&policy.PreferH264,"web codec policy recognizes native Enigma service"); policy=WebPlaybackPolicy.For(ownedChannel,false); Check(policy.IsEnigmaChannel&&!policy.PreferH264,"disabled web codec policy preserves native negotiation"); Check(!WebPlaybackPolicy.For(new LiveTvChannel{ServiceName="OtherProvider"},true).PreferH264&&!WebPlaybackPolicy.For(new Video{ServiceName="Enigma2"},true).PreferH264&&!WebPlaybackPolicy.For(null,true).PreferH264,"web codec policy excludes other providers, movies and missing items"); var policyUser=new User("policy-test","test-auth","test-reset");policyUser.SetPermission(PermissionKind.EnableLiveTvAccess,true); var userManager=DispatchProxy.Create();((PolicyUserProxy)(object)userManager).Current=policyUser; var libraryManager=DispatchProxy.Create();var libraryProxy=(PolicyLibraryProxy)(object)libraryManager;libraryProxy.Current=policyUser;libraryProxy.Item=ownedChannel; var policyContext=new DefaultHttpContext();policyContext.User=new ClaimsPrincipal(new ClaimsIdentity(new[]{new Claim("Jellyfin-UserId",policyUser.Id.ToString())},"test"));policyContext.Request.QueryString=new QueryString("?userId="+Guid.NewGuid()); var policyController=new PreferencesController(null,null,null,null,libraryManager,userManager){ControllerContext=new ControllerContext{HttpContext=policyContext}}; Check(policyController.GetWebPlaybackPolicy(ownedChannel.Id).Value.IsEnigmaChannel&&libraryProxy.Calls==1,"web policy uses native user-filtered lookup for authenticated owner"); libraryProxy.Item=null;policy=policyController.GetWebPlaybackPolicy(ownedChannel.Id).Value;Check(!policy.IsEnigmaChannel&&!policy.PreferH264,"native visibility denial leaves web profile unchanged"); policyUser.SetPermission(PermissionKind.EnableLiveTvAccess,false);Check(policyController.GetWebPlaybackPolicy(ownedChannel.Id).Result is ForbidResult&&libraryProxy.Calls==2,"web policy denies user without live TV permission before item lookup"); ((PolicyUserProxy)(object)userManager).Current=null;Check(policyController.GetWebPlaybackPolicy(ownedChannel.Id).Result is UnauthorizedResult&&libraryProxy.Calls==2,"web policy rejects deleted user without unrestricted native lookup"); policyContext.User=new ClaimsPrincipal();Check(policyController.GetWebPlaybackPolicy(ownedChannel.Id).Result is UnauthorizedResult,"web policy requires authenticated user claim"); var folder=Path.Combine(Path.GetTempPath(),"decoder-tests-"+Guid.NewGuid().ToString("N")); var paths=DispatchProxy.Create();((PathsProxy)(object)paths).Folder=folder; try { Directory.CreateDirectory(folder);var config=Path.Combine(folder,"Jellyfin.Plugin.Enigma2.xml"); var registration=new ServiceCollection().AddSingleton(paths);new ServiceRegistrator().RegisterServices(registration,null); Check(!System.Linq.Enumerable.Any(registration,x=>x.ServiceType==typeof(MediaBrowser.Controller.Channels.IChannel)),"startup before Plugin.Instance: recordings disabled by default"); File.WriteAllText(config,"true"); registration=new ServiceCollection().AddSingleton(paths);new ServiceRegistrator().RegisterServices(registration,null); Check(System.Linq.Enumerable.Any(registration,x=>x.ServiceType==typeof(MediaBrowser.Controller.Channels.IChannel)),"startup recordings enabled from persisted configuration"); File.WriteAllText(config,"false"); Check(!ServiceRegistrator.VuRecordingsEnabled(config),"explicit recordings disabled"); var store=new PreferencesStore(paths);var user=Guid.NewGuid();var a=Guid.NewGuid().ToString("N");var b=Guid.NewGuid().ToString("N"); await store.Save(user,new ChannelPreferences{HiddenIds=new[]{a},OrderedIds=new[]{b,a}},CancellationToken.None); var provider=new ServiceCollection().AddLogging().AddSingleton(store).BuildServiceProvider(); async System.Threading.Tasks.Task Request(Guid current,string query) { var app=new ApplicationBuilder(provider); new NativeChannelFilter().Configure(next=>next.Run(async ctx=>{ Check(!ctx.Request.Headers.ContainsKey("Accept-Encoding"),"native buffered JSON is uncompressed"); Check(!ctx.Request.Query.ContainsKey("Limit"),"pagination applied after native permission query"); ctx.User=new ClaimsPrincipal(new ClaimsIdentity(new[]{new Claim("Jellyfin-UserId",current.ToString())},"test")); ctx.Response.ContentType="application/json"; await ctx.Response.WriteAsync(new JsonObject{["Items"]=new JsonArray(new JsonObject{["Id"]=a},new JsonObject{["Id"]=b}),["TotalRecordCount"]=2}.ToJsonString()); }))(app); var context=new DefaultHttpContext{RequestServices=provider};context.Request.Headers.AcceptEncoding="gzip, br";context.Request.Method="GET";context.Request.Path="/LiveTv/Channels";context.Request.QueryString=new QueryString(query);context.Response.Body=new MemoryStream(); await app.Build()(context);Check(context.Request.Headers.AcceptEncoding=="gzip, br","restore compression request header");context.Response.Body.Position=0;return await JsonNode.ParseAsync(context.Response.Body); } var result=await Request(user,"?Limit=1");Check((int)result["TotalRecordCount"]==1&&(string)result["Items"][0]["Id"]==b,"native hidden and channel order"); result=await Request(user,"?Limit=0");Check(result["Items"].AsArray().Count==0&&(int)result["TotalRecordCount"]==1,"native zero-limit count"); result=await Request(Guid.NewGuid(),"?StartIndex=1&Limit=1");Check((int)result["TotalRecordCount"]==2&&(string)result["Items"][0]["Id"]==b,"native user preference isolation and pagination"); result=await Request(user,"?UserId="+Guid.NewGuid()+"&Limit=1");Check((int)result["TotalRecordCount"]==1,"query cannot replace authenticated preference owner"); result=await Request(user,"?DecoderIncludeHidden=true&Limit=2");Check((int)result["TotalRecordCount"]==2,"preference editor can restore hidden channels"); } finally { if(Directory.Exists(folder))Directory.Delete(folder,true); } var probeJson="""{"streams":[{"index":0,"codec_type":"video","codec_name":"h264","width":1920,"height":1080,"field_order":"tt","avg_frame_rate":"25/1"},{"index":1,"codec_type":"audio","codec_name":"mp2","channels":2,"sample_rate":"48000"}]}"""; var calls=0; var sharedProbe=new LiveStreamProbe(async (url,token)=>{Interlocked.Increment(ref calls);await System.Threading.Tasks.Task.Delay(100,token);return probeJson;}); var probes=await System.Threading.Tasks.Task.WhenAll(System.Linq.Enumerable.Range(0,4).Select(_=>sharedProbe.Get("channel",CancellationToken.None))); Check(calls==1&&probes.All(x=>x.Count==2),"four simultaneous requests share one upstream probe"); Check(probes[0][0].Index==0&&probes[0][0].IsInterlaced&&probes[0][0].AverageFrameRate==25,"real stream indexes and interlacing preserved"); await sharedProbe.Get("channel",CancellationToken.None);Check(calls==1,"successful probe cached"); using(var cancelled=new CancellationTokenSource(20)) { var abandoned=sharedProbe.Get("another",cancelled.Token);var surviving=sharedProbe.Get("another",CancellationToken.None); try{await abandoned;throw new Exception("cancellation expected");}catch(OperationCanceledException){} Check((await surviving).Count==2&&calls==2,"one cancelled caller does not break shared probe"); } var failingCalls=0; var stalled=new LiveStreamProbe(async (url,token)=>{Interlocked.Increment(ref failingCalls);await System.Threading.Tasks.Task.Delay(10000,token);return probeJson;},TimeSpan.FromMilliseconds(100)); var watch=System.Diagnostics.Stopwatch.StartNew(); for(var i=0;i<2;i++)try{await stalled.Get("stalled",CancellationToken.None);throw new Exception("failure expected");}catch(InvalidOperationException){} Check(watch.Elapsed.TotalSeconds<2&&failingCalls==1,"stalled source bounded and failed retries coalesced"); try{LiveStreamProbe.Parse("{\"streams\":[]}");throw new Exception("invalid video expected");}catch(InvalidOperationException){} 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 RelayStartupTests.Run(Check); var relayHandler=new RelayHandler(); using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler),8096,TimeSpan.FromMilliseconds(100))) { var address=relay.Register("http://receiver/channel");var id=address.Split('/').Last(); Check(relay.Register("http://receiver/channel")==address,"one stable relay address for a channel"); var first=await relay.Subscribe(id,CancellationToken.None);var second=await relay.Subscribe(id,CancellationToken.None); var one=await first.Reader.ReadAsync();var two=await second.Reader.ReadAsync(); Check(relayHandler.Calls==1&&one[0]==0x47&&two[0]==0x47,"two data consumers share one receiver HTTP connection"); await first.DisposeAsync();Check((await second.Reader.ReadAsync()).Length>0&&relayHandler.Calls==1,"one consumer stopping does not interrupt the other"); var context=new DefaultHttpContext();context.Connection.RemoteIpAddress=System.Net.IPAddress.Parse("192.0.2.5"); var controller=new LiveRelayController(relay){ControllerContext=new Microsoft.AspNetCore.Mvc.ControllerContext{HttpContext=context}}; await controller.Stream(id,CancellationToken.None);Check(context.Response.StatusCode==403,"relay denied to non-loopback clients"); await second.DisposeAsync();await System.Threading.Tasks.Task.Delay(250); Check(System.Text.Json.JsonSerializer.Serialize(relay.Status()).Contains("\"ActiveSources\":0"),"receiver connection released after final consumer"); } public class PathsProxy : DispatchProxy { public string Folder; protected override object Invoke(MethodInfo method,object[] args)=>(method.Name=="get_DataPath" || method.Name=="get_PluginConfigurationsPath")?Folder:null; } public class PolicyUserProxy : DispatchProxy { public User Current; protected override object Invoke(MethodInfo method,object[] args) => method.Name=="GetUserById"&&Current!=null&&(Guid)args[0]==Current.Id?Current:null; } public class PolicyLibraryProxy : DispatchProxy { public User Current;public BaseItem Item;public int Calls; protected override object Invoke(MethodInfo method,object[] args) { if(method.Name!="GetItemById"||args.Length!=2||!ReferenceEquals(args[1],Current))throw new Exception("Expected native item lookup using authenticated User"); Calls++;return Item; } } public class RelayHandler : System.Net.Http.HttpMessageHandler { public int Calls; protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request,CancellationToken token) { Interlocked.Increment(ref Calls);return System.Threading.Tasks.Task.FromResult(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK){Content=new System.Net.Http.StreamContent(new PacketStream())}); } } public class PacketStream : Stream { 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 async System.Threading.Tasks.ValueTask ReadAsync(Memory buffer,CancellationToken token=default) { await System.Threading.Tasks.Task.Delay(10,token);var size=Math.Min(buffer.Length,188*256);size-=size%188;buffer.Span[..size].Clear();for(int i=0;ithrow new NotSupportedException();public override void Flush(){}public override long Seek(long o,SeekOrigin s)=>throw new NotSupportedException();public override void SetLength(long n)=>throw new NotSupportedException();public override void Write(byte[] b,int o,int c)=>throw new NotSupportedException(); }