Files
jellyfin-plugin-enigma2/Tests/ChannelSyncTests.cs
T

165 lines
13 KiB
C#

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<bool,string> 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<IXmlSerializer,SavedChannelPlaybackTests.NoXmlFiles>());
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");
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<ChannelInfo>();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<double>(),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<IApplicationPaths,SavedChannelPlaybackTests.SavedPaths>();((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;
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<ILiveTvService>()},Proxy<ITaskManager>(),Proxy<ILibraryManager>(),Proxy<IUserManager>(),Proxy<IUserDataManager>(),Store,paths,NullLogger<ChannelSyncTask>.Instance);
}
private T Proxy<T>()where T:class
{var value=DispatchProxy.Create<T,SyncProxy>();((SyncProxy)(object)value).Context=this;return value;}
public Task Run()=>Task.ExecuteAsync(new Progress<double>(),CancellationToken.None);
public ChannelSyncTask.SyncState ReadState()=>JsonSerializer.Deserialize<ChannelSyncTask.SyncState>(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<IEnumerable<ChannelInfo>>(c.Current);
case "get_ScheduledTasks":return new IScheduledTaskWorker[]{c.Worker};
case "Execute":if(!ReferenceEquals(args[0],c.Worker))throw new Exception("Wrong native task");return c.Execute();
case "GetItemList":return c.Items;
case "GetUsers":return new[]{c.Owner,c.SecondOwner};
case "GetUserDataBatch":
return ((IReadOnlyList<BaseItem>)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<TaskTriggerInfo> Triggers {get;set;}=Array.Empty<TaskTriggerInfo>();public string Id=>"native";public void ReloadTriggerEvents(){}public void Dispose(){}
public event EventHandler<Jellyfin.Data.Events.GenericEventArgs<double>> 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<double> p,CancellationToken t)=>throw new Exception("Must use native task manager");public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()=>Array.Empty<TaskTriggerInfo>();
}
}