Synchronize native Enigma2 channels every six hours and migrate preferences
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
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>();
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,7 @@ using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler)
|
||||
Check(System.Text.Json.JsonSerializer.Serialize(relay.Status()).Contains("\"ActiveSources\":0"),"receiver connection released after final consumer");
|
||||
}
|
||||
await SavedChannelPlaybackTests.Run(Check);
|
||||
await ChannelSyncTests.Run(Check);
|
||||
public class PathsProxy : DispatchProxy
|
||||
{
|
||||
public string Folder;
|
||||
|
||||
Reference in New Issue
Block a user