diff --git a/Jellyfin.Plugin.Enigma2/ChannelSyncTask.cs b/Jellyfin.Plugin.Enigma2/ChannelSyncTask.cs
new file mode 100644
index 0000000..9fa034a
--- /dev/null
+++ b/Jellyfin.Plugin.Enigma2/ChannelSyncTask.cs
@@ -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;
+
+/// 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; }
+ }
+}
diff --git a/Jellyfin.Plugin.Enigma2/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Enigma2/Configuration/PluginConfiguration.cs
index e88509f..690bd4a 100644
--- a/Jellyfin.Plugin.Enigma2/Configuration/PluginConfiguration.cs
+++ b/Jellyfin.Plugin.Enigma2/Configuration/PluginConfiguration.cs
@@ -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()
diff --git a/Jellyfin.Plugin.Enigma2/Configuration/configPage.html b/Jellyfin.Plugin.Enigma2/Configuration/configPage.html
index b1d3cfe..2b73a30 100644
--- a/Jellyfin.Plugin.Enigma2/Configuration/configPage.html
+++ b/Jellyfin.Plugin.Enigma2/Configuration/configPage.html
@@ -158,6 +158,12 @@
Enable Enigma2 debug logging
+
+
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.
+
@@ -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);
});
diff --git a/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj b/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj
index 911181a..0398aff 100644
--- a/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj
+++ b/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj
@@ -2,8 +2,8 @@
net10.0
- 7.0.9.0
- 7.0.9.0
+ 7.0.10.0
+ 7.0.10.0
diff --git a/Jellyfin.Plugin.Enigma2/PreferencesController.cs b/Jellyfin.Plugin.Enigma2/PreferencesController.cs
index d05b98a..5e8eee9 100644
--- a/Jellyfin.Plugin.Enigma2/PreferencesController.cs
+++ b/Jellyfin.Plugin.Enigma2/PreferencesController.cs
@@ -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 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(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} });
}
diff --git a/Jellyfin.Plugin.Enigma2/ServiceRegistrator.cs b/Jellyfin.Plugin.Enigma2/ServiceRegistrator.cs
index 4bba65c..d93cc13 100644
--- a/Jellyfin.Plugin.Enigma2/ServiceRegistrator.cs
+++ b/Jellyfin.Plugin.Enigma2/ServiceRegistrator.cs
@@ -24,6 +24,8 @@ namespace Jellyfin.Plugin.Enigma2
serviceCollection.AddSingleton();
serviceCollection.AddTransient();
serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton(provider => provider.GetRequiredService());
// 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")))
diff --git a/README.md b/README.md
index b246bb8..bc776ba 100644
--- a/README.md
+++ b/README.md
@@ -70,3 +70,17 @@ Odczyt metadanych LiveTvChannel potwierdził stare i nowe wpisy Pulsu/Pulsu 2 or
102 kontrole .NET10 przeszły, w tym rzeczywiste GetChannelStream z atrapą OpenWebif i natywnej biblioteki: nowy SID 5105, wspólne metadane i jedno połączenie relay dla starego/nowego Pulsu, anulowanie bez dodatkowego połączenia. Build Release: 0 ostrzeżeń, 0 błędów. Restore korzystał z cache pakietów bez sieci; NuGetAudit wyłączono jedynie dla tego odtworzenia offline. Nie potwierdza to odbioru na zalogowanym urządzeniu.
Status na moment zapisu: **7.0.9 przygotowana, niewdrożona**. W produkcji pozostaje 7.0.8. Nowa sesja Wydarzeń 24 rozpoczęta o 16:21:45 UTC dała poprawne serwerowe HLS (248 klatek 1080p / 9,88 s oraz 10,027 s audio). Restart wymagany do podmiany DLL czeka na zgodę dotyczącą tej aktywnej sesji. Osobny brak odkodowanego wyjścia CANAL+4K wymaga diagnostyki źródła ECM i nie jest naprawiany przez ten resolver. Nie zmieniono ustawień Jellyfina, wrapperów, plików Web ani konfiguracji dekodera.
+
+### 7.0.9 wdrożona — 23.10.2026, 19:07 CEST
+
+Po poleceniu użytkownika „Restart” wdrożono DLL o SHA256 `8db3b3700ef417c591f2361e013e20c601052bd35bc124f449c899183950c986`. Produkcja używa teraz **7.0.9.0**, co potwierdził świeży log załadowania i publiczny health. Konfiguracja serwera/wtyczki, wrappery, moduł Web i 26 plików preferencji pozostały identyczne. Dwa aktywne nagrania dekodera zachowano; nie restartowano odbiornika. Backup: `/var/lib/jellyfin/decoder-plugin-backups/plugin-only-20261023T170741733074Z`; raport `deployment-7.0.9-20261023.json`. Poprzedni akapit opisuje etap przed zgodą i wdrożeniem. Obsługa automatycznej synchronizacji okresowej jest osobnym zadaniem; 7.0.9 sprawdza aktualne referencje przy odtwarzaniu.
+
+## 7.0.10 — automatyczna synchronizacja, 23.10.2026
+
+Na żądanie użytkownika domyślny harmonogram zadania wtyczki „Synchronizacja kanałów Enigma2” to **start serwera i co 6 godzin**. Dodano w ustawieniach wtyczki przełącznik automatycznej synchronizacji, domyślnie włączony. Godziny można zmienić w natywnych zadaniach zaplanowanych dla tego zadania. Nie zmieniamy globalnego harmonogramu Refresh Guide.
+
+Zadanie pobiera bukiety z limitem30s i wykrywa zmiany referencji, nazw, kolejności, numerów lub adresów logo. Przy zmianie albo braku kanałów w natywnej bibliotece uruchamia przez ITaskManager istniejące natywne RefreshGuide (limit5min), dzięki czemu aktualizowana jest właściwa Telewizja i EPG. Nie otwiera strumieni ani nie zmienia tunerów. Pusta lista lub błąd odbiornika nie uruchamia odświeżenia. Gdy RefreshGuide już trwa, synchronizacja jest odkładana do kolejnego wyzwolenia. Lista bez zmian nie wymaga ponownego pełnego odświeżania przewodnika.
+
+Przed natywnym odświeżeniem zapisuje własny checkpoint w `data/decoder-integration/channel-sync.json`: stare identyfikatory/ref/nazwy oraz natywne ulubione. Po jednoznacznej migracji aktualizuje natywne ulubione oraz ulubione/ukryte/kolejność we własnych preferencjach użytkowników. Aktualizacje plików preferencji używają tych samych blokad co edytor, zachowują aktualne ustawienia i usuwają duplikaty. Błąd/anulowanie pozostawia checkpoint do ponowienia również wtedy, gdy natywne zadanie usunęło już stare kanały. Bezpośrednich zapisów do bazy Jellyfina nie ma. Stan ostatniej kontroli/synchronizacji jest dostępny w administracyjnym DecoderIntegration/Status.
+
+120 kontroli .NET10 przeszło: dokładnie6h/start, zmiany numerów i logo, migracja natywnych i własnych preferencji, rozdzielenie użytkowników, pusta lista, zajęty przewodnik, częściowa awaria i ponowienie, współbieżność, anulowanie oraz wyłączenie funkcji. Źródła ani nagrania produkcyjne nie były używane w tych testach. Stan przed wdrożeniem: produkcja7.0.9, kandydat7.0.10.
diff --git a/Tests/ChannelSyncTests.cs b/Tests/ChannelSyncTests.cs
new file mode 100644
index 0000000..d902ab9
--- /dev/null
+++ b/Tests/ChannelSyncTests.cs
@@ -0,0 +1,164 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Plugin.Enigma2;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Model.Tasks;
+using Microsoft.Extensions.Logging.Abstractions;
+
+public static class ChannelSyncTests
+{
+ public static async Task Run(Action check)
+ {
+ var previous=Plugin.Instance;
+ var root=Path.Combine(Path.GetTempPath(),"channel-sync-tests-"+Guid.NewGuid().ToString("N"));Directory.CreateDirectory(root);
+ try
+ {
+ var paths=Paths(root);var plugin=new Plugin(paths,DispatchProxy.Create());
+ check(plugin.Configuration.EnableAutomaticChannelSync,"legacy plugin configuration enables periodic synchronization by default");
+ using(var c=new Context(root))
+ {
+ var triggers=c.Task.GetDefaultTriggers().ToArray();
+ check(triggers.Length==2&&triggers.Single(t=>t.Type==TaskTriggerInfoType.IntervalTrigger).IntervalTicks==TimeSpan.FromHours(6).Ticks&&triggers.Any(t=>t.Type==TaskTriggerInfoType.StartupTrigger),"channel synchronization defaults to startup and exactly six hours");
+ await c.Store.Save(c.Owner.Id,new ChannelPreferences{FavoriteIds=new[]{c.OldId.ToString(),c.NewId.ToString()},HiddenIds=new[]{c.OldId.ToString()},OrderedIds=new[]{c.OtherId.ToString(),c.OldId.ToString()},DefaultView="favorites"},CancellationToken.None);
+ await c.Store.Save(c.SecondOwner.Id,new ChannelPreferences{HiddenIds=new[]{c.OldId.ToString()}},CancellationToken.None);
+ await c.Run();
+ check(c.Executions==1&&c.SourceCalls==1,"changed bouquet invokes native RefreshGuide once without opening a receiver stream");
+ var prefs=await c.Store.Load(c.Owner.Id,CancellationToken.None);
+ check(prefs.FavoriteIds.SequenceEqual(new[]{c.NewId.ToString("N")})&&prefs.HiddenIds.SequenceEqual(new[]{c.NewId.ToString("N")})&&prefs.OrderedIds.SequenceEqual(new[]{c.OtherId.ToString("N"),c.NewId.ToString("N")})&&prefs.DefaultView=="favorites","native channel migration remaps and deduplicates saved favorites, hidden IDs and ordering");
+ check(c.Data[(c.Owner.Id,c.NewId)].IsFavorite&&c.Data[(c.Owner.Id,c.NewId)].PlayCount==7,"native favorite migration preserves existing play history");
+ check(!c.Data[(c.SecondOwner.Id,c.NewId)].IsFavorite&&(await c.Store.Load(c.SecondOwner.Id,CancellationToken.None)).HiddenIds.Single()==c.NewId.ToString("N"),"channel migration preserves preference and favorite isolation between users");
+ check(c.Task.LastSynchronizedUtc.HasValue&&c.ReadState().Pending==null,"successful native synchronization commits its checkpoint");
+ await c.Run();check(c.Executions==1,"unchanged bouquet skips redundant native guide refresh");
+ c.Current[0].Number="9";await c.Run();check(c.Executions==2,"receiver channel renumbering triggers native synchronization");
+ c.Current=Array.Empty();await MustFail(c.Run());check(c.Executions==2,"empty receiver bouquet never triggers destructive native refresh");
+ }
+ using(var c=new Context(root))
+ {
+ c.Worker.CurrentState=TaskState.Running;await c.Run();
+ check(c.Executions==0&&c.Task.LastError=="NativeGuideBusy","running native guide is not duplicated or cancelled");
+ }
+ using(var c=new Context(root))
+ {
+ await c.Store.Save(c.Owner.Id,new ChannelPreferences{FavoriteIds=new[]{c.OldId.ToString()}},CancellationToken.None);
+ c.Fail=true;await MustFail(c.Run());
+ check(c.ReadState().Pending!=null&&c.ReadState().Fingerprint==null&&(await c.Store.Load(c.Owner.Id,CancellationToken.None)).FavoriteIds.Single()==c.OldId.ToString("N"),"failed native job retains historical IDs and pending favorite checkpoint");
+ c.Fail=false;await c.Run();
+ check(c.Executions==2&&c.Data[(c.Owner.Id,c.NewId)].IsFavorite&&(await c.Store.Load(c.Owner.Id,CancellationToken.None)).FavoriteIds.Single()==c.NewId.ToString("N"),"retry restores native favorites after the first native job removed old channel metadata");
+ }
+ using(var c=new Context(root))
+ {
+ c.Block=new(TaskCreationOptions.RunContinuationsAsynchronously);
+ var first=c.Run();await c.Started.Task.WaitAsync(TimeSpan.FromSeconds(3));await c.Run();
+ check(c.Executions==1,"overlapping plugin synchronization requests coalesce into one native job");
+ c.Block.SetResult();await first;
+ }
+ using(var c=new Context(root))
+ {
+ c.Block=new(TaskCreationOptions.RunContinuationsAsynchronously);using var cancel=new CancellationTokenSource();
+ var run=c.Task.ExecuteAsync(new Progress(),cancel.Token);await c.Started.Task.WaitAsync(TimeSpan.FromSeconds(3));cancel.Cancel();
+ await MustFail(run);check(c.ReadState().Pending!=null&&c.Task.LastSynchronizedUtc==null,"cancelled synchronization retains recovery checkpoint without claiming completion");
+ c.Block.SetResult();
+ }
+ using(var c=new Context(root))
+ {
+ plugin.Configuration.EnableAutomaticChannelSync=false;await c.Run();plugin.Configuration.EnableAutomaticChannelSync=true;
+ check(c.SourceCalls==0&&c.Executions==0,"disabled plugin synchronization performs no receiver or native-guide work");
+ }
+ var a=new ChannelInfo{Id=SavedChannelPlaybackTests.Current,Name="TV PULS HD",Number="1",ImageUrl="http://receiver/picon.png"};
+ var hash=ChannelSyncTask.Fingerprint(new[]{a});a.ImageUrl="http://receiver/new-picon.png";
+ check(hash!=ChannelSyncTask.Fingerprint(new[]{a}),"changed receiver logo URL invalidates channel fingerprint");
+ var old=Guid.NewGuid();var newer=Guid.NewGuid();
+ var ambiguous=new[]{a,new ChannelInfo{Id="1:0:1:AAAA:BBBB:13E:820000:0:0:0:",Name=a.Name}};
+ var map=ChannelSyncTask.BuildMapping(new[]{new ChannelSyncTask.SavedChannel{Id=old,Reference=SavedChannelPlaybackTests.Old,Name=a.Name}},ambiguous,new[]{new LiveTvChannel{Id=newer,ExternalId=a.Id}});
+ check(map.Count==0,"ambiguous same-name services do not migrate user preferences arbitrarily");
+ }
+ finally {typeof(Plugin).GetProperty(nameof(Plugin.Instance)).SetValue(null,previous);Directory.Delete(root,true);}
+ }
+ private static async Task MustFail(Task task)
+ {try{await task;}catch(InvalidOperationException){return;}catch(OperationCanceledException){return;}throw new Exception("Expected synchronization failure");}
+ private static IApplicationPaths Paths(string folder)
+ {var paths=DispatchProxy.Create();((SavedChannelPlaybackTests.SavedPaths)(object)paths).Folder=folder;return paths;}
+
+ public sealed class Context:IDisposable
+ {
+ public Guid OldId=Guid.NewGuid(),NewId=Guid.NewGuid(),OtherId=Guid.NewGuid();
+ public User Owner=new("sync-owner","test","test"),SecondOwner=new("sync-second","test","test");
+ public ChannelInfo[] Current={new(){Id=SavedChannelPlaybackTests.Current,Name="TV PULS HD",Number="1"}};
+ public BaseItem[] Items;public int Executions,SourceCalls;public bool Fail;
+ public Dictionary<(Guid,Guid),UserItemData> Data=new();
+ public PreferencesStore Store;public ChannelSyncTask Task;public Worker Worker=new();
+ public TaskCompletionSource Block;public TaskCompletionSource Started=new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly string folder;
+ public Context(string root)
+ {
+ folder=Path.Combine(root,Guid.NewGuid().ToString("N"));Directory.CreateDirectory(folder);var paths=Paths(folder);Store=new(paths);
+ Items=new BaseItem[]{new LiveTvChannel{Id=OldId,ExternalId=SavedChannelPlaybackTests.Old,Name="TV PULS HD",ServiceName="Enigma2"},new LiveTvChannel{Id=OtherId,ExternalId="other",Name="Unrelated",ServiceName="Other provider"}};
+ foreach(var owner in new[]{Owner,SecondOwner})
+ {
+ Data[(owner.Id,OldId)]=new(){Key=OldId.ToString("N"),IsFavorite=owner==Owner};Data[(owner.Id,NewId)]=new(){Key=NewId.ToString("N"),PlayCount=7};Data[(owner.Id,OtherId)]=new(){Key=OtherId.ToString("N"),IsFavorite=true};
+ }
+ Task=new(new[]{Proxy()},Proxy(),Proxy(),Proxy(),Proxy(),Store,paths,NullLogger.Instance);
+ }
+ private T Proxy()where T:class
+ {var value=DispatchProxy.Create();((SyncProxy)(object)value).Context=this;return value;}
+ public Task Run()=>Task.ExecuteAsync(new Progress(),CancellationToken.None);
+ public ChannelSyncTask.SyncState ReadState()=>JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(folder,"decoder-integration","channel-sync.json")));
+ public void Dispose()=>Directory.Delete(folder,true);
+ public async Task Execute()
+ {
+ Executions++;Worker.CurrentState=TaskState.Running;Started.TrySetResult();
+ Items=new BaseItem[]{new LiveTvChannel{Id=NewId,ExternalId=SavedChannelPlaybackTests.Current,Name="TV PULS HD",ServiceName="Enigma2"},Items.Single(i=>i.Id==OtherId)};
+ foreach(var owner in new[]{Owner,SecondOwner})Data.Remove((owner.Id,OldId));
+ if(Block!=null)await Block.Task;
+ Worker.Result=new(){StartTimeUtc=DateTime.UtcNow,EndTimeUtc=DateTime.UtcNow,Status=Fail?TaskCompletionStatus.Failed:TaskCompletionStatus.Completed};Worker.CurrentState=TaskState.Idle;
+ }
+ }
+ public class SyncProxy:DispatchProxy
+ {
+ public Context Context;
+ protected override object Invoke(MethodInfo method,object[] args)
+ {
+ var c=Context;
+ switch(method.Name)
+ {
+ case "get_Name":return "Enigma2";
+ case "GetChannelsAsync":c.SourceCalls++;return System.Threading.Tasks.Task.FromResult>(c.Current);
+ case "get_ScheduledTasks":return new IScheduledTaskWorker[]{c.Worker};
+ case "Execute":if(!ReferenceEquals(args[0],c.Worker))throw new Exception("Wrong native task");return c.Execute();
+ case "GetItemList":return c.Items;
+ case "GetUsers":return new[]{c.Owner,c.SecondOwner};
+ case "GetUserDataBatch":
+ return ((IReadOnlyList)args[0]).ToDictionary(i=>i.Id,i=>c.Data[(((User)args[1]).Id,i.Id)]);
+ case "GetUserData":return c.Data[(((User)args[0]).Id,((BaseItem)args[1]).Id)];
+ case "SaveUserData":
+ if(((BaseItem)args[1]).Id!=c.NewId)throw new Exception("Unrelated native user data was changed");
+ c.Data[(((User)args[0]).Id,((BaseItem)args[1]).Id)]=(UserItemData)args[2];return null;
+ default:throw new Exception("Unexpected sync dependency: "+method.Name);
+ }
+ }
+ }
+ public sealed class Worker:IScheduledTaskWorker
+ {
+ public TaskState CurrentState=TaskState.Idle;public TaskResult Result;
+ public IScheduledTask ScheduledTask { get; }=new NativeTask();
+ public TaskResult LastExecutionResult=>Result;public string Name=>"Refresh Guide";public string Description=>"Test";public string Category=>"Live TV";
+ public TaskState State=>CurrentState;public double? CurrentProgress=>null;public IReadOnlyList Triggers {get;set;}=Array.Empty();public string Id=>"native";public void ReloadTriggerEvents(){}public void Dispose(){}
+ public event EventHandler> TaskProgress {add{}remove{}}
+ }
+ private sealed class NativeTask:IScheduledTask
+ {
+ public string Name=>"Refresh Guide";public string Key=>"RefreshGuide";public string Description=>"Test";public string Category=>"Live TV";
+ public Task ExecuteAsync(IProgress p,CancellationToken t)=>throw new Exception("Must use native task manager");public IEnumerable GetDefaultTriggers()=>Array.Empty();
+ }
+}
diff --git a/Tests/Program.cs b/Tests/Program.cs
index bd87920..2965193 100644
--- a/Tests/Program.cs
+++ b/Tests/Program.cs
@@ -125,6 +125,7 @@ using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler)
Check(System.Text.Json.JsonSerializer.Serialize(relay.Status()).Contains("\"ActiveSources\":0"),"receiver connection released after final consumer");
}
await SavedChannelPlaybackTests.Run(Check);
+await ChannelSyncTests.Run(Check);
public class PathsProxy : DispatchProxy
{
public string Folder;
diff --git a/build.yaml b/build.yaml
index 8f54435..d2d3806 100644
--- a/build.yaml
+++ b/build.yaml
@@ -8,7 +8,7 @@ guid: "193f29f9-ea6c-4595-a6f6-55e79d7c590a"
# The version of the plugin, starting at 1. Note that the AssemblyVersion
# and FileVersion flags in the `.csproj` file must have two additional
# fiels, e.g. 1.0.0 to be built correctly.
-version: "7.0.9.0" # Please increment with each pull request
+version: "7.0.10.0" # Please increment with each pull request
# The supported Jellyfin version, usually the earliest binary-compatible
# version. Based on the Jellyfin components from NuGet.
diff --git a/deploy-plugin-only.py b/deploy-plugin-only.py
index ba34fdc..7aef4c2 100644
--- a/deploy-plugin-only.py
+++ b/deploy-plugin-only.py
@@ -13,7 +13,7 @@ import urllib.error
import urllib.request
import uuid
-VERSION = '7.0.9.0'
+VERSION = '7.0.10.0'
ABI = '12.2.0.0'
GUID = '193f29f9-ea6c-4595-a6f6-55e79d7c590a'
DLL = 'Jellyfin.Plugin.Enigma2.dll'