using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Plugin.Enigma2; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging.Abstractions; public static class ChannelSyncTests { public static async Task Run(Action check) { var previous=Plugin.Instance; var root=Path.Combine(Path.GetTempPath(),"channel-sync-tests-"+Guid.NewGuid().ToString("N"));Directory.CreateDirectory(root); try { var paths=Paths(root);var plugin=new Plugin(paths,DispatchProxy.Create()); check(plugin.Configuration.EnableAutomaticChannelSync,"legacy plugin configuration enables periodic synchronization by default"); using(var c=new Context(root)) { var triggers=c.Task.GetDefaultTriggers().ToArray(); check(triggers.Length==2&&triggers.Single(t=>t.Type==TaskTriggerInfoType.IntervalTrigger).IntervalTicks==TimeSpan.FromHours(6).Ticks&&triggers.Any(t=>t.Type==TaskTriggerInfoType.StartupTrigger),"channel synchronization defaults to startup and exactly six hours"); await c.Store.Save(c.Owner.Id,new ChannelPreferences{FavoriteIds=new[]{c.OldId.ToString(),c.NewId.ToString()},HiddenIds=new[]{c.OldId.ToString()},OrderedIds=new[]{c.OtherId.ToString(),c.OldId.ToString()},DefaultView="favorites"},CancellationToken.None); await c.Store.Save(c.SecondOwner.Id,new ChannelPreferences{HiddenIds=new[]{c.OldId.ToString()}},CancellationToken.None); await c.Run(); check(c.Executions==1&&c.SourceCalls==1,"changed bouquet invokes native RefreshGuide once without opening a receiver stream"); check(c.NativeRuntimePolicy,"full guide import retains native runtime policy without an artificial short timeout"); var prefs=await c.Store.Load(c.Owner.Id,CancellationToken.None); check(prefs.FavoriteIds.SequenceEqual(new[]{c.NewId.ToString("N")})&&prefs.HiddenIds.SequenceEqual(new[]{c.NewId.ToString("N")})&&prefs.OrderedIds.SequenceEqual(new[]{c.OtherId.ToString("N"),c.NewId.ToString("N")})&&prefs.DefaultView=="favorites","native channel migration remaps and deduplicates saved favorites, hidden IDs and ordering"); check(c.Data[(c.Owner.Id,c.NewId)].IsFavorite&&c.Data[(c.Owner.Id,c.NewId)].PlayCount==7,"native favorite migration preserves existing play history"); check(!c.Data[(c.SecondOwner.Id,c.NewId)].IsFavorite&&(await c.Store.Load(c.SecondOwner.Id,CancellationToken.None)).HiddenIds.Single()==c.NewId.ToString("N"),"channel migration preserves preference and favorite isolation between users"); check(c.Task.LastSynchronizedUtc.HasValue&&c.ReadState().Pending==null,"successful native synchronization commits its checkpoint"); await c.Run();check(c.Executions==1,"unchanged bouquet skips redundant native guide refresh"); c.Current[0].Number="9";await c.Run();check(c.Executions==2,"receiver channel renumbering triggers native synchronization"); c.Current=Array.Empty();await MustFail(c.Run());check(c.Executions==2,"empty receiver bouquet never triggers destructive native refresh"); } using(var c=new Context(root)) { c.Worker.CurrentState=TaskState.Running;await c.Run(); check(c.Executions==0&&c.Task.LastError=="NativeGuideBusy","running native guide is not duplicated or cancelled"); } using(var c=new Context(root)) { await c.Store.Save(c.Owner.Id,new ChannelPreferences{FavoriteIds=new[]{c.OldId.ToString()}},CancellationToken.None); c.Fail=true;await MustFail(c.Run()); check(c.ReadState().Pending!=null&&c.ReadState().Fingerprint==null&&(await c.Store.Load(c.Owner.Id,CancellationToken.None)).FavoriteIds.Single()==c.OldId.ToString("N"),"failed native job retains historical IDs and pending favorite checkpoint"); c.Fail=false;await c.Run(); check(c.Executions==2&&c.Data[(c.Owner.Id,c.NewId)].IsFavorite&&(await c.Store.Load(c.Owner.Id,CancellationToken.None)).FavoriteIds.Single()==c.NewId.ToString("N"),"retry restores native favorites after the first native job removed old channel metadata"); } using(var c=new Context(root)) { c.Block=new(TaskCreationOptions.RunContinuationsAsynchronously); var first=c.Run();await c.Started.Task.WaitAsync(TimeSpan.FromSeconds(3));await c.Run(); check(c.Executions==1,"overlapping plugin synchronization requests coalesce into one native job"); c.Block.SetResult();await first; } using(var c=new Context(root)) { c.Block=new(TaskCreationOptions.RunContinuationsAsynchronously);using var cancel=new CancellationTokenSource(); var run=c.Task.ExecuteAsync(new Progress(),cancel.Token);await c.Started.Task.WaitAsync(TimeSpan.FromSeconds(3));cancel.Cancel(); await MustFail(run);check(c.ReadState().Pending!=null&&c.Task.LastSynchronizedUtc==null,"cancelled synchronization retains recovery checkpoint without claiming completion"); c.Block.SetResult(); } using(var c=new Context(root)) { plugin.Configuration.EnableAutomaticChannelSync=false;await c.Run();plugin.Configuration.EnableAutomaticChannelSync=true; check(c.SourceCalls==0&&c.Executions==0,"disabled plugin synchronization performs no receiver or native-guide work"); } var a=new ChannelInfo{Id=SavedChannelPlaybackTests.Current,Name="TV PULS HD",Number="1",ImageUrl="http://receiver/picon.png"}; var hash=ChannelSyncTask.Fingerprint(new[]{a});a.ImageUrl="http://receiver/new-picon.png"; check(hash!=ChannelSyncTask.Fingerprint(new[]{a}),"changed receiver logo URL invalidates channel fingerprint"); var old=Guid.NewGuid();var newer=Guid.NewGuid(); var ambiguous=new[]{a,new ChannelInfo{Id="1:0:1:AAAA:BBBB:13E:820000:0:0:0:",Name=a.Name}}; var map=ChannelSyncTask.BuildMapping(new[]{new ChannelSyncTask.SavedChannel{Id=old,Reference=SavedChannelPlaybackTests.Old,Name=a.Name}},ambiguous,new[]{new LiveTvChannel{Id=newer,ExternalId=a.Id}}); check(map.Count==0,"ambiguous same-name services do not migrate user preferences arbitrarily"); } finally {typeof(Plugin).GetProperty(nameof(Plugin.Instance)).SetValue(null,previous);Directory.Delete(root,true);} } private static async Task MustFail(Task task) {try{await task;}catch(InvalidOperationException){return;}catch(OperationCanceledException){return;}throw new Exception("Expected synchronization failure");} private static IApplicationPaths Paths(string folder) {var paths=DispatchProxy.Create();((SavedChannelPlaybackTests.SavedPaths)(object)paths).Folder=folder;return paths;} public sealed class Context:IDisposable { public Guid OldId=Guid.NewGuid(),NewId=Guid.NewGuid(),OtherId=Guid.NewGuid(); public User Owner=new("sync-owner","test","test"),SecondOwner=new("sync-second","test","test"); public ChannelInfo[] Current={new(){Id=SavedChannelPlaybackTests.Current,Name="TV PULS HD",Number="1"}}; public BaseItem[] Items;public int Executions,SourceCalls;public bool Fail,NativeRuntimePolicy; public Dictionary<(Guid,Guid),UserItemData> Data=new(); public PreferencesStore Store;public ChannelSyncTask Task;public Worker Worker=new(); public TaskCompletionSource Block;public TaskCompletionSource Started=new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly string folder; public Context(string root) { folder=Path.Combine(root,Guid.NewGuid().ToString("N"));Directory.CreateDirectory(folder);var paths=Paths(folder);Store=new(paths); Items=new BaseItem[]{new LiveTvChannel{Id=OldId,ExternalId=SavedChannelPlaybackTests.Old,Name="TV PULS HD",ServiceName="Enigma2"},new LiveTvChannel{Id=OtherId,ExternalId="other",Name="Unrelated",ServiceName="Other provider"}}; foreach(var owner in new[]{Owner,SecondOwner}) { Data[(owner.Id,OldId)]=new(){Key=OldId.ToString("N"),IsFavorite=owner==Owner};Data[(owner.Id,NewId)]=new(){Key=NewId.ToString("N"),PlayCount=7};Data[(owner.Id,OtherId)]=new(){Key=OtherId.ToString("N"),IsFavorite=true}; } Task=new(new[]{Proxy()},Proxy(),Proxy(),Proxy(),Proxy(),Store,paths,NullLogger.Instance); } private T Proxy()where T:class {var value=DispatchProxy.Create();((SyncProxy)(object)value).Context=this;return value;} public Task Run()=>Task.ExecuteAsync(new Progress(),CancellationToken.None); public ChannelSyncTask.SyncState ReadState()=>JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(folder,"decoder-integration","channel-sync.json"))); public void Dispose()=>Directory.Delete(folder,true); public async Task Execute() { Executions++;Worker.CurrentState=TaskState.Running;Started.TrySetResult(); Items=new BaseItem[]{new LiveTvChannel{Id=NewId,ExternalId=SavedChannelPlaybackTests.Current,Name="TV PULS HD",ServiceName="Enigma2"},Items.Single(i=>i.Id==OtherId)}; foreach(var owner in new[]{Owner,SecondOwner})Data.Remove((owner.Id,OldId)); if(Block!=null)await Block.Task; Worker.Result=new(){StartTimeUtc=DateTime.UtcNow,EndTimeUtc=DateTime.UtcNow,Status=Fail?TaskCompletionStatus.Failed:TaskCompletionStatus.Completed};Worker.CurrentState=TaskState.Idle; } } public class SyncProxy:DispatchProxy { public Context Context; protected override object Invoke(MethodInfo method,object[] args) { var c=Context; switch(method.Name) { case "get_Name":return "Enigma2"; case "GetChannelsAsync":c.SourceCalls++;return System.Threading.Tasks.Task.FromResult>(c.Current); case "get_ScheduledTasks":return new IScheduledTaskWorker[]{c.Worker}; case "Execute":if(!ReferenceEquals(args[0],c.Worker))throw new Exception("Wrong native task");c.NativeRuntimePolicy=((TaskOptions)args[1]).MaxRuntimeTicks==null;return c.Execute(); case "GetItemList":return c.Items; case "GetUsers":return new[]{c.Owner,c.SecondOwner}; case "GetUserDataBatch": return ((IReadOnlyList)args[0]).ToDictionary(i=>i.Id,i=>c.Data[(((User)args[1]).Id,i.Id)]); case "GetUserData":return c.Data[(((User)args[0]).Id,((BaseItem)args[1]).Id)]; case "SaveUserData": if(((BaseItem)args[1]).Id!=c.NewId)throw new Exception("Unrelated native user data was changed"); c.Data[(((User)args[0]).Id,((BaseItem)args[1]).Id)]=(UserItemData)args[2];return null; default:throw new Exception("Unexpected sync dependency: "+method.Name); } } } public sealed class Worker:IScheduledTaskWorker { public TaskState CurrentState=TaskState.Idle;public TaskResult Result; public IScheduledTask ScheduledTask { get; }=new NativeTask(); public TaskResult LastExecutionResult=>Result;public string Name=>"Refresh Guide";public string Description=>"Test";public string Category=>"Live TV"; public TaskState State=>CurrentState;public double? CurrentProgress=>null;public IReadOnlyList Triggers {get;set;}=Array.Empty();public string Id=>"native";public void ReloadTriggerEvents(){}public void Dispose(){} public event EventHandler> TaskProgress {add{}remove{}} } private sealed class NativeTask:IScheduledTask { public string Name=>"Refresh Guide";public string Key=>"RefreshGuide";public string Description=>"Test";public string Category=>"Live TV"; public Task ExecuteAsync(IProgress p,CancellationToken t)=>throw new Exception("Must use native task manager");public IEnumerable GetDefaultTriggers()=>Array.Empty(); } }