Synchronize native Enigma2 channels every six hours and migrate preferences

This commit is contained in:
2026-10-23 19:26:28 +02:00
parent 50b39fa40b
commit 2e7662f018
11 changed files with 391 additions and 6 deletions
+177
View File
@@ -0,0 +1,177 @@
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; }
}
}
@@ -30,6 +30,7 @@ namespace Jellyfin.Plugin.Enigma2.Configuration
public bool EnableDebugLogging { get; set; }
public bool EnableVuRecordings { get; set; } = false;
public bool PreferH264ForWebLiveTv { get; set; } = true;
public bool EnableAutomaticChannelSync { get; set; } = true;
public PluginConfiguration()
@@ -158,6 +158,12 @@
<span>Enable Enigma2 debug logging</span>
</label>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkEnableAutomaticChannelSync" />
<span>Automatycznie synchronizuj kanały z dekodera</span>
</label>
<div class="fieldDescription">Domyślnie przy starcie i co 6 godzin. Po zmianie listy uruchamia natywne odświeżenie Telewizji, zachowując ulubione i preferencje kanałów. Harmonogram zadania „Synchronizacja kanałów Enigma2” można zmienić w zadaniach zaplanowanych.</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button>
</div>
@@ -194,6 +200,7 @@
document.getElementById('chkDebugLogging').checked = config.EnableDebugLogging || false;
document.getElementById('chkEnableVuRecordings').checked = config.EnableVuRecordings || false;
document.getElementById('chkPreferH264ForWebLiveTv').checked = config.PreferH264ForWebLiveTv !== false;
document.getElementById('chkEnableAutomaticChannelSync').checked = config.EnableAutomaticChannelSync !== false;
Dashboard.hideLoadingMsg();
});
@@ -222,6 +229,7 @@
config.EnableDebugLogging = document.getElementById('chkDebugLogging').checked;
config.EnableVuRecordings = document.getElementById('chkEnableVuRecordings').checked;
config.PreferH264ForWebLiveTv = document.getElementById('chkPreferH264ForWebLiveTv').checked;
config.EnableAutomaticChannelSync = document.getElementById('chkEnableAutomaticChannelSync').checked;
ApiClient.updatePluginConfiguration(Enigma2ConfigurationPage.pluginUniqueId, config).then(Dashboard.processPluginConfigurationUpdateResult);
});
@@ -2,8 +2,8 @@
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AssemblyVersion>7.0.9.0</AssemblyVersion>
<FileVersion>7.0.9.0</FileVersion>
<AssemblyVersion>7.0.10.0</AssemblyVersion>
<FileVersion>7.0.10.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
@@ -57,6 +57,23 @@ public sealed class PreferencesStore
await File.WriteAllTextAsync(temp,JsonSerializer.Serialize(value),token); File.Move(temp,file,true); }
finally { gate.Release(); }
}
public async Task Remap(Guid user, System.Collections.Generic.IReadOnlyDictionary<string,string> mapping, CancellationToken token)
{
var gate=locks.GetOrAdd(user,_=>new(1)); await gate.WaitAsync(token);
try
{
var file=Path.Combine(folder,user.ToString("N")+".json");
if(!File.Exists(file))return;
var value=JsonSerializer.Deserialize<ChannelPreferences>(await File.ReadAllTextAsync(file,token))??new();value.Validate();
var before=JsonSerializer.Serialize(value);
string[] Map(string[] ids)=>ids.Select(id=>mapping.TryGetValue(id,out var target)?target:id).Distinct().ToArray();
value.FavoriteIds=Map(value.FavoriteIds);value.HiddenIds=Map(value.HiddenIds);value.OrderedIds=Map(value.OrderedIds);
var after=JsonSerializer.Serialize(value);
if(before==after)return;
await File.WriteAllTextAsync(file+".tmp",after,token);File.Move(file+".tmp",file,true);
}
finally { gate.Release(); }
}
}
[ApiController]
[Route("DecoderIntegration")]
@@ -69,7 +86,8 @@ public sealed class PreferencesController : ControllerBase
private readonly LiveStreamProbe probe;
private readonly ILibraryManager library;
private readonly IUserManager users;
public PreferencesController(PreferencesStore store,InternetEpg epg,LiveStreamRelay relay,LiveStreamProbe probe,ILibraryManager library,IUserManager users) { this.store=store; this.epg=epg; this.relay=relay; this.probe=probe; this.library=library; this.users=users; }
private readonly ChannelSyncTask channelSync;
public PreferencesController(PreferencesStore store,InternetEpg epg,LiveStreamRelay relay,LiveStreamProbe probe,ILibraryManager library,IUserManager users,ChannelSyncTask channelSync=null) { this.store=store; this.epg=epg; this.relay=relay; this.probe=probe; this.library=library; this.users=users; this.channelSync=channelSync; }
private Guid UserId => Guid.TryParse(User.FindFirst("Jellyfin-UserId")?.Value,out var id) ? id : Guid.Empty;
[HttpGet("Channels")]
[AllowAnonymous]
@@ -98,5 +116,5 @@ public sealed class PreferencesController : ControllerBase
}
[HttpGet("Status")]
[Authorize(Policy="RequiresElevation")]
public IActionResult Status() => Ok(new { epg.LastSuccess,epg.MatchedImages,epg.LastError, Source="https://epg.polishteam.one/guide.xml", Streams=relay.Status(), ProbeError=probe.LastError });
public IActionResult Status() => Ok(new { epg.LastSuccess,epg.MatchedImages,epg.LastError, Source="https://epg.polishteam.one/guide.xml", Streams=relay.Status(), ProbeError=probe.LastError, ChannelSync=channelSync==null?null:new {channelSync.LastCheckedUtc,channelSync.LastSynchronizedUtc,channelSync.LastError} });
}
@@ -24,6 +24,8 @@ namespace Jellyfin.Plugin.Enigma2
serviceCollection.AddSingleton<PreferencesStore>();
serviceCollection.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, NativeChannelFilter>();
serviceCollection.AddSingleton<ILiveTvService, LiveTvService>();
serviceCollection.AddSingleton<ChannelSyncTask>();
serviceCollection.AddSingleton<MediaBrowser.Model.Tasks.IScheduledTask>(provider => provider.GetRequiredService<ChannelSyncTask>());
// Jellyfin registers services before constructing Plugin.Instance.
var paths = serviceCollection.Last(x => x.ServiceType == typeof(IApplicationPaths)).ImplementationInstance as IApplicationPaths;
if (VuRecordingsEnabled(Path.Combine(paths.PluginConfigurationsPath, "Jellyfin.Plugin.Enigma2.xml")))