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; /// Synchronize the receiver's bouquet through Jellyfin's native guide job. public sealed class ChannelSyncTask : IScheduledTask { private readonly IEnumerable services; private readonly ITaskManager tasks; private readonly ILibraryManager library; private readonly IUserManager users; private readonly IUserDataManager userData; private readonly PreferencesStore preferences; private readonly ILogger 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 services,ITaskManager tasks,ILibraryManager library,IUserManager users,IUserDataManager userData,PreferencesStore preferences,IApplicationPaths paths,ILogger 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 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().Where(c=>string.Equals(c.ServiceName,"Enigma2",StringComparison.OrdinalIgnoreCase)).ToArray(); public async Task ExecuteAsync(IProgress 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(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&¤t.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.StartTimeUtcafter.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 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 BuildMapping(IEnumerable before,ChannelInfo[] current,LiveTvChannel[] after) { var mapping=new Dictionary(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(); public Dictionary Favorites { get; set; }=new(); } public sealed class SyncState { public string Fingerprint { get; set; } public DateTime? LastSynchronizedUtc { get; set; } public PendingState Pending { get; set; } } }