180 lines
9.6 KiB
C#
180 lines
9.6 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&¤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;
|
|
// A first seven-day import may take much longer than a channel check.
|
|
// Keep the native job's ordinary execution/cancellation policy.
|
|
await tasks.Execute(worker,new TaskOptions()).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; }
|
|
}
|
|
}
|