Synchronize native Enigma2 channels every six hours and migrate preferences
This commit is contained in:
@@ -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&¤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.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")))
|
||||
|
||||
Reference in New Issue
Block a user