Files
jellyfin-plugin-enigma2/Jellyfin.Plugin.Enigma2/ChannelSyncTask.cs
T

178 lines
9.5 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Synchronize the receiver's bouquet through Jellyfin's native guide job.</summary>
public sealed class ChannelSyncTask : IScheduledTask
{
private readonly IEnumerable<ILiveTvService> services;
private readonly ITaskManager tasks;
private readonly ILibraryManager library;
private readonly IUserManager users;
private readonly IUserDataManager userData;
private readonly PreferencesStore preferences;
private readonly ILogger<ChannelSyncTask> logger;
private readonly string stateFile;
private readonly SemaphoreSlim gate=new(1,1);
public string Name=>"Synchronizacja kanałów Enigma2";
public string Key=>"Enigma2ChannelSync";
public string Description=>"Sprawdza listę dekodera co 6 godzin. Po zmianach odświeża natywną Telewizję i zachowuje preferencje kanałów. Harmonogram można zmienić w zadaniach zaplanowanych.";
public string Category=>"Live TV";
public DateTime? LastCheckedUtc { get; private set; }
public DateTime? LastSynchronizedUtc { get; private set; }
public string LastError { get; private set; }
public ChannelSyncTask(IEnumerable<ILiveTvService> services,ITaskManager tasks,ILibraryManager library,IUserManager users,IUserDataManager userData,PreferencesStore preferences,IApplicationPaths paths,ILogger<ChannelSyncTask> logger)
{
this.services=services;this.tasks=tasks;this.library=library;this.users=users;this.userData=userData;this.preferences=preferences;this.logger=logger;
stateFile=Path.Combine(paths.DataPath,"decoder-integration","channel-sync.json");
}
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()=>new[]
{
new TaskTriggerInfo{Type=TaskTriggerInfoType.IntervalTrigger,IntervalTicks=TimeSpan.FromHours(6).Ticks},
new TaskTriggerInfo{Type=TaskTriggerInfoType.StartupTrigger}
};
private LiveTvChannel[] NativeChannels()=>library.GetItemList(new InternalItemsQuery{IncludeItemTypes=new[]{BaseItemKind.LiveTvChannel}})
.OfType<LiveTvChannel>().Where(c=>string.Equals(c.ServiceName,"Enigma2",StringComparison.OrdinalIgnoreCase)).ToArray();
public async Task ExecuteAsync(IProgress<double> progress,CancellationToken token)
{
if(Plugin.Instance?.Configuration.EnableAutomaticChannelSync==false){progress.Report(100);return;}
if(!await gate.WaitAsync(0,token).ConfigureAwait(false))return;
try
{
LastCheckedUtc=DateTime.UtcNow;LastError=null;
var state=File.Exists(stateFile)?JsonSerializer.Deserialize<SyncState>(await File.ReadAllTextAsync(stateFile,token))??new():new SyncState();
LastSynchronizedUtc=state.LastSynchronizedUtc;
var service=services.Single(s=>s.Name=="Enigma2");
ChannelInfo[] current;
using(var deadline=CancellationTokenSource.CreateLinkedTokenSource(token))
{
deadline.CancelAfter(TimeSpan.FromSeconds(30));
current=(await service.GetChannelsAsync(deadline.Token).ConfigureAwait(false)).ToArray();
}
if(current.Length==0||current.Any(c=>string.IsNullOrWhiteSpace(c.Id)||string.IsNullOrWhiteSpace(c.Name)))
throw new InvalidOperationException("Empty or invalid receiver bouquet; native guide was not refreshed.");
var fingerprint=Fingerprint(current);
var before=NativeChannels();
if(state.Pending==null&&state.Fingerprint==fingerprint&&current.All(c=>before.Any(n=>n.ExternalId==c.Id)))
{
logger.LogInformation("[Enigma2] Channel synchronization: receiver bouquet unchanged ({ChannelCount} positions)",current.Length);
progress.Report(100);return;
}
var worker=tasks.ScheduledTasks.SingleOrDefault(t=>t.ScheduledTask.Key=="RefreshGuide")
??throw new InvalidOperationException("Native RefreshGuide task unavailable.");
if(worker.State!=TaskState.Idle)
{
LastError="NativeGuideBusy";
logger.LogInformation("[Enigma2] Native guide is already running; channel synchronization deferred to next trigger");return;
}
var owners=users.GetUsers().ToArray();
if(state.Pending==null)
{
state.Pending=new PendingState{Channels=before.Select(c=>new SavedChannel{Id=c.Id,Reference=c.ExternalId,Name=c.Name}).ToArray()};
foreach(var owner in owners)
{
var favorites=userData.GetUserDataBatch(before,owner).Where(p=>p.Value.IsFavorite).Select(p=>p.Key).ToArray();
if(favorites.Length>0)state.Pending.Favorites[owner.Id]=favorites;
}
// Keep a recovery checkpoint before the native job can remove old IDs.
await Save(state,token).ConfigureAwait(false);
}
progress.Report(10);
var refreshStarted=DateTime.UtcNow;
await tasks.Execute(worker,new TaskOptions{MaxRuntimeTicks=TimeSpan.FromMinutes(5).Ticks}).WaitAsync(token).ConfigureAwait(false);
token.ThrowIfCancellationRequested();
if(worker.LastExecutionResult?.Status!=TaskCompletionStatus.Completed||worker.LastExecutionResult.StartTimeUtc<refreshStarted.AddSeconds(-1))
throw new InvalidOperationException("Native guide refresh did not complete successfully.");
var after=NativeChannels();
if(!current.All(c=>after.Any(n=>n.ExternalId==c.Id)))
throw new InvalidOperationException("Native channel list is incomplete after guide refresh.");
var mapping=BuildMapping(state.Pending.Channels,current,after);
foreach(var owner in owners)
{
token.ThrowIfCancellationRequested();
await preferences.Remap(owner.Id,mapping,token).ConfigureAwait(false);
if(!state.Pending.Favorites.TryGetValue(owner.Id,out var favorites))continue;
foreach(var oldId in favorites)
{
if(!mapping.TryGetValue(oldId.ToString("N"),out var target))continue;
var channel=after.Single(c=>c.Id==Guid.Parse(target));
var data=userData.GetUserData(owner,channel);
if(data.IsFavorite)continue;
data.IsFavorite=true;
userData.SaveUserData(owner,channel,data,UserDataSaveReason.UpdateUserData,token);
}
}
state.Pending=null;state.Fingerprint=fingerprint;state.LastSynchronizedUtc=DateTime.UtcNow;
await Save(state,token).ConfigureAwait(false);
LastSynchronizedUtc=state.LastSynchronizedUtc;
logger.LogInformation("[Enigma2] Synchronized {ChannelCount} receiver positions with native Live TV; remapped {MigratedCount} saved channel IDs",current.Length,mapping.Count);
progress.Report(100);
}
catch(Exception error)
{
LastError=error.GetType().Name;
logger.LogWarning("[Enigma2] Channel synchronization failed ({FailureType}); checkpoint retained for retry",LastError);
throw;
}
finally { gate.Release(); }
}
private async Task Save(SyncState state,CancellationToken token)
{
Directory.CreateDirectory(Path.GetDirectoryName(stateFile));
await File.WriteAllTextAsync(stateFile+".tmp",JsonSerializer.Serialize(state),token).ConfigureAwait(false);
File.Move(stateFile+".tmp",stateFile,true);
}
public static string Fingerprint(IEnumerable<ChannelInfo> channels)
{
var values=channels.Select(c=>new[]{c.Id,c.Name,c.Number,c.ImageUrl,c.ImagePath}).ToArray();
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(values))));
}
public static Dictionary<string,string> BuildMapping(IEnumerable<SavedChannel> before,ChannelInfo[] current,LiveTvChannel[] after)
{
var mapping=new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);
foreach(var old in before)
{
var reference=ChannelReferenceResolver.Resolve(old.Reference,current,()=>old.Name);
var targets=after.Where(c=>string.Equals(c.ExternalId,reference,StringComparison.OrdinalIgnoreCase)).Select(c=>c.Id).Distinct().ToArray();
if(targets.Length==1&&targets[0]!=old.Id)mapping[old.Id.ToString("N")]=targets[0].ToString("N");
}
return mapping;
}
public sealed class SavedChannel
{
public Guid Id { get; set; }
public string Reference { get; set; }
public string Name { get; set; }
}
public sealed class PendingState
{
public SavedChannel[] Channels { get; set; }=Array.Empty<SavedChannel>();
public Dictionary<Guid,Guid[]> Favorites { get; set; }=new();
}
public sealed class SyncState
{
public string Fingerprint { get; set; }
public DateTime? LastSynchronizedUtc { get; set; }
public PendingState Pending { get; set; }
}
}