From 4ff46f547ba41fe72a408b27b403d1b0572e4151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kacprzak?= Date: Fri, 23 Oct 2026 18:31:55 +0200 Subject: [PATCH] Resolve saved native channel references after receiver migrations --- .../ChannelReferenceResolver.cs | 42 +++++++ .../Jellyfin.Plugin.Enigma2.csproj | 4 +- Jellyfin.Plugin.Enigma2/LiveTvService.cs | 27 ++++- README.md | 7 ++ Tests/ChannelReferenceTests.cs | 32 +++++ Tests/Program.cs | 2 + Tests/SavedChannelPlaybackTests.cs | 114 ++++++++++++++++++ build.yaml | 2 +- deploy-plugin-only.py | 2 +- 9 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 Jellyfin.Plugin.Enigma2/ChannelReferenceResolver.cs create mode 100644 Tests/ChannelReferenceTests.cs create mode 100644 Tests/SavedChannelPlaybackTests.cs diff --git a/Jellyfin.Plugin.Enigma2/ChannelReferenceResolver.cs b/Jellyfin.Plugin.Enigma2/ChannelReferenceResolver.cs new file mode 100644 index 0000000..7ae80b0 --- /dev/null +++ b/Jellyfin.Plugin.Enigma2/ChannelReferenceResolver.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Controller.LiveTv; + +namespace Jellyfin.Plugin.Enigma2; + +/// Keep saved native channels playable after a receiver bouquet migration. +public static class ChannelReferenceResolver +{ + public static string Resolve(string reference, IEnumerable channels, Func previousName) + { + var current = channels.Where(c => !string.IsNullOrWhiteSpace(c.Id)).ToArray(); + var exact = current.FirstOrDefault(c => string.Equals(c.Id, reference, StringComparison.OrdinalIgnoreCase)); + if (exact != null) return exact.Id; + + // A changed Enigma service type does not change the DVB service identity. + var identity = Identity(reference); + var matches = current.Where(c => identity != null && Identity(c.Id) == identity) + .Select(c => c.Id).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (matches.Length == 1) return matches[0]; + if (matches.Length > 1) return reference; + + // A moved service has a new SID/TSID. Use only a unique exact historical + // name; never guess between HD/SD, regional or alternative services. + var name = previousName()?.Trim(); + if (string.IsNullOrEmpty(name)) return reference; + matches = current.Where(c => string.Equals(c.Name?.Trim(), name, StringComparison.OrdinalIgnoreCase)) + .Select(c => c.Id).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + return matches.Length == 1 ? matches[0] : reference; + } + + private static string Identity(string reference) + { + var fields = reference?.TrimEnd(':').Split(':'); + if (fields == null || fields.Length != 10 || fields[0] != "1" || fields[1] != "0") return null; + // No URLs, labels or non-DVB references participate in this fallback. + if (fields.Any(f => f.Length == 0 || !f.All(Uri.IsHexDigit))) return null; + fields[2] = ""; + return string.Join(':', fields).ToUpperInvariant(); + } +} diff --git a/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj b/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj index 68cd0a6..911181a 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.8.0 - 7.0.8.0 + 7.0.9.0 + 7.0.9.0 diff --git a/Jellyfin.Plugin.Enigma2/LiveTvService.cs b/Jellyfin.Plugin.Enigma2/LiveTvService.cs index ce82be9..04f4130 100644 --- a/Jellyfin.Plugin.Enigma2/LiveTvService.cs +++ b/Jellyfin.Plugin.Enigma2/LiveTvService.cs @@ -14,6 +14,9 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Jellyfin.Data.Enums; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.LiveTv; @@ -32,6 +35,7 @@ namespace Jellyfin.Plugin.Enigma2 private int _liveStreams; private readonly LiveStreamProbe _streamProbe; private readonly LiveStreamRelay _streamRelay; + private readonly ILibraryManager _library; private string tvBouquetSRef; private List tvChannelInfos = new List(); @@ -42,12 +46,13 @@ namespace Jellyfin.Plugin.Enigma2 public static LiveTvService Instance { get; private set; } - public LiveTvService(ILogger logger, IHttpClientFactory httpClientFactory, InternetEpg internetEpg, LiveStreamProbe streamProbe, LiveStreamRelay streamRelay) + public LiveTvService(ILogger logger, IHttpClientFactory httpClientFactory, InternetEpg internetEpg, LiveStreamProbe streamProbe, LiveStreamRelay streamRelay, ILibraryManager library) { _logger = logger; _httpClientFactory = httpClientFactory; _streamProbe = streamProbe; _streamRelay = streamRelay; + _library = library; Instance = this; } @@ -1139,6 +1144,26 @@ namespace Jellyfin.Plugin.Enigma2 { _logger.LogInformation("[Enigma2] Start GetChannelStream"); + var requestedReference = channelOid; + var currentChannels = await GetChannelsAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + channelOid = ChannelReferenceResolver.Resolve(channelOid, currentChannels, () => + { + // Native playback has already checked the user's channel access. + // This internal lookup reads only Enigma2 channel metadata and + // does not expose items or alter the user's native channel ID. + var names = _library.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel } + }).OfType() + .Where(c => string.Equals(c.ServiceName, "Enigma2", StringComparison.OrdinalIgnoreCase) + && string.Equals(c.ExternalId, requestedReference, StringComparison.OrdinalIgnoreCase)) + .Select(c => c.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + return names.Length == 1 ? names[0] : null; + }); + if (!string.Equals(channelOid, requestedReference, StringComparison.OrdinalIgnoreCase)) + _logger.LogInformation("[Enigma2] Resolved saved channel reference {PreviousReference} to current bouquet reference {CurrentReference}", requestedReference, channelOid); + var protocol = "http"; if (Plugin.Instance.Configuration.UseSecureHTTPSForStreams) { diff --git a/README.md b/README.md index c93c1ea..7660df6 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,10 @@ Plugin Debugging ## Local Jellyfin 10.11 integration See [integration changes and deployment](README.integration.md). Internet EPG artwork, native channel preferences, optional Vu+ recordings provider; local version 6.1.0.0. +## Kandydat 7.0.9 — stare wpisy kanałów po migracji, 23.10.2026 + +Odczyt metadanych LiveTvChannel potwierdził stare i nowe wpisy Pulsu/Pulsu 2 oraz stare wpisy WP i TVN24 po zmianie bukietów dekodera. GetChannelStream odświeża listę z dekodera i rozwiązuje zapamiętaną referencję przed zapem, rejestracją relay i wyznaczeniem SID do probe. Najpierw zachowuje dokładny aktualny wpis; potem dopuszcza jednoznaczną zmianę samego typu usługi DVB; migrację SID/TSID rozpoznaje tylko po unikalnej dokładnej nazwie odczytanej z historycznego kanału Enigma2. Brak dopasowania lub niejednoznaczność zachowuje poprzednie zachowanie. Nie zgadujemy HD/SD ani wariantów regionalnych. Natywny identyfikator kanału i preferencje użytkownika pozostają zachowane. Stary i nowy wpis wskazują ten sam adres relay, więc nie otwierają dwóch źródeł. + +100 kontroli .NET10 przeszło, 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. diff --git a/Tests/ChannelReferenceTests.cs b/Tests/ChannelReferenceTests.cs new file mode 100644 index 0000000..53d77ab --- /dev/null +++ b/Tests/ChannelReferenceTests.cs @@ -0,0 +1,32 @@ +using System; +using Jellyfin.Plugin.Enigma2; +using MediaBrowser.Controller.LiveTv; + +public static class ChannelReferenceTests +{ + public static void Run(Action check) + { + const string oldPuls = "1:0:1:3D66:2C88:13E:820000:0:0:0:"; + const string newPuls = "1:0:1:13F1:5DC:13E:820000:0:0:0:"; + const string oldTvn = "1:0:1:3DD2:640:13E:820000:0:0:0:"; + const string newTvn = "1:0:19:3DD2:640:13E:820000:0:0:0:"; + var channels = new[] { new ChannelInfo { Id=newPuls, Name="TV PULS HD" }, new ChannelInfo { Id=newTvn, Name="TVN24 HD" } }; + check(ChannelReferenceResolver.Resolve(newPuls,channels,()=>throw new Exception("Unnecessary library lookup"))==newPuls,"current channel uses its exact reference without historical lookup"); + check(ChannelReferenceResolver.Resolve(oldTvn,channels,()=>throw new Exception("Unnecessary library lookup"))==newTvn,"saved TVN24 reference resolves changed service type"); + check(ChannelReferenceResolver.Resolve(oldPuls,channels,()=>"TV PULS HD")==newPuls,"saved Puls reference resolves migrated SID and transponder"); + check(ChannelReferenceResolver.Resolve(oldPuls,channels,()=>"tv puls hd ")==newPuls,"historical name matches case and surrounding whitespace only"); + check(ChannelReferenceResolver.Resolve(oldPuls,channels,()=>"TV PULS")==oldPuls,"HD and SD names are not guessed"); + check(ChannelReferenceResolver.Resolve(oldPuls,channels,()=>null)==oldPuls,"unknown historical name preserves original service"); + var duplicate = new[] { channels[0], channels[0] }; + check(ChannelReferenceResolver.Resolve(oldPuls,duplicate,()=>"TV PULS HD")==newPuls,"same reference in two bouquets is not ambiguous"); + var ambiguous = new[] { channels[0], new ChannelInfo { Id=oldTvn, Name="TV PULS HD" } }; + check(ChannelReferenceResolver.Resolve(oldPuls,ambiguous,()=>"TV PULS HD")==oldPuls,"different services with the same name are not guessed"); + var types = new[] { channels[1], new ChannelInfo { Id="1:0:1F:3DD2:640:13E:820000:0:0:0:",Name="TVN24 HD" } }; + check(ChannelReferenceResolver.Resolve(oldTvn,types,()=>throw new Exception("Ambiguous identity must not fall back"))==oldTvn,"ambiguous DVB identity cannot select arbitrary service type"); + var presentOld = new[] { channels[0], new ChannelInfo { Id=oldPuls,Name="TV PULS HD" } }; + check(ChannelReferenceResolver.Resolve(oldPuls,presentOld,()=>throw new Exception("Exact reference wins"))==oldPuls,"reference still present in bouquet wins over same-name alternative"); + check(ChannelReferenceResolver.Resolve(oldTvn,Array.Empty(),()=>"TVN24 HD")==oldTvn,"empty bouquet cannot redirect a saved channel"); + const string nonDvb = "4097:0:1:3DD2:640:13E:820000:0:0:0:"; + check(ChannelReferenceResolver.Resolve(nonDvb,channels,()=>null)==nonDvb,"non-DVB reference cannot use service-type fallback"); + } +} diff --git a/Tests/Program.cs b/Tests/Program.cs index 058d894..bd87920 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; var time=DateTime.UtcNow; void Check(bool ok,string name){if(!ok)throw new Exception(name);Console.WriteLine("PASS "+name);} +ChannelReferenceTests.Run(Check); var entry=new InternetEpg.Entry("Barwy szczęścia",time,"https://ptv-prod.wphcdn.cloud/media/a.jpg"); Check(InternetEpg.Match(new[]{entry},"Barwy szczęścia",time.AddMinutes(5))==entry,"matching channel programme time"); Check(InternetEpg.Match(new[]{entry},"Inny program",time)==null,"reject different title"); @@ -123,6 +124,7 @@ using(var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(relayHandler) await second.DisposeAsync();await System.Threading.Tasks.Task.Delay(250); Check(System.Text.Json.JsonSerializer.Serialize(relay.Status()).Contains("\"ActiveSources\":0"),"receiver connection released after final consumer"); } +await SavedChannelPlaybackTests.Run(Check); public class PathsProxy : DispatchProxy { public string Folder; diff --git a/Tests/SavedChannelPlaybackTests.cs b/Tests/SavedChannelPlaybackTests.cs new file mode 100644 index 0000000..5112955 --- /dev/null +++ b/Tests/SavedChannelPlaybackTests.cs @@ -0,0 +1,114 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +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 Microsoft.Extensions.Logging.Abstractions; + +public static class SavedChannelPlaybackTests +{ + public const string Old = "1:0:1:3D66:2C88:13E:820000:0:0:0:"; + public const string Current = "1:0:1:13F1:5DC:13E:820000:0:0:0:"; + public static async Task Run(Action check) + { + var previous = Plugin.Instance; + var folder = Path.Combine(Path.GetTempPath(),"saved-channel-"+Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + try + { + var paths=DispatchProxy.Create();((SavedPaths)(object)paths).Folder=folder; + var plugin=new Plugin(paths,DispatchProxy.Create()); + plugin.Configuration.HostName="receiver";plugin.Configuration.WebInterfacePort="80"; + plugin.Configuration.StreamingPort="8001";plugin.Configuration.OnlyOneBouquet=true; + plugin.Configuration.TVBouquet="Polskie";plugin.Configuration.FetchPiconsFromWebInterface=true; + var library=DispatchProxy.Create(); + var handler=new ReceiverHandler(); + using var relay=new LiveStreamRelay(new HttpClient(handler),8096,TimeSpan.FromMilliseconds(50)); + var calls=0; + var probe=new LiveStreamProbe((url,token)=> + { + calls++; + check(url.EndsWith("?programme=5105",StringComparison.Ordinal),"saved channel probes the migrated SID rather than the historical SID"); + return Task.FromResult("{\"programs\":[{\"program_id\":5105,\"streams\":[{\"index\":0,\"codec_type\":\"video\",\"codec_name\":\"h264\",\"width\":1920,\"height\":1080}]}],\"streams\":[{\"index\":0,\"codec_type\":\"video\",\"codec_name\":\"h264\",\"width\":1920,\"height\":1080}]}"); + }); + var service=new LiveTvService(NullLogger.Instance,new ReceiverFactory(),null,probe,relay,library); + var saved=await service.GetChannelStream(Old,null,CancellationToken.None); + var current=await service.GetChannelStream(Current,null,CancellationToken.None); + check(saved.Path==current.Path&&calls==1,"saved and current native channels share the same relay and metadata probe"); + check(((SavedChannelLibrary)(object)library).Calls==1,"current reference avoids the native historical lookup"); + var id=saved.Path.Split('/').Last(); + await using var first=await relay.Subscribe(id,CancellationToken.None); + await using var second=await relay.Subscribe(id,CancellationToken.None); + await first.Reader.ReadAsync();await second.Reader.ReadAsync(); + check(handler.Calls==1&&handler.Reference==Current,"both native IDs open only one upstream using the migrated receiver reference"); + using var cancelled=new CancellationTokenSource();cancelled.Cancel(); + try { await service.GetChannelStream(Old,null,cancelled.Token);throw new Exception("Expected cancellation"); } + catch(OperationCanceledException) { check(handler.Calls==1,"cancelled channel lookup opens no extra upstream"); } + } + finally + { + typeof(Plugin).GetProperty(nameof(Plugin.Instance)).SetValue(null,previous); + Directory.Delete(folder,true); + } + } + + public class NoXmlFiles:DispatchProxy + { + protected override object Invoke(MethodInfo method,object[] args) + { + if(method.Name=="SerializeToFile")return null; // Keep default configuration in memory. + throw new Exception("No persisted configuration expected in isolated test"); + } + } + public class SavedPaths:DispatchProxy + { + public string Folder; + protected override object Invoke(MethodInfo method,object[] args) => method.ReturnType==typeof(string)?Folder:null; + } + public class SavedChannelLibrary:DispatchProxy + { + public int Calls; + protected override object Invoke(MethodInfo method,object[] args) + { + if(method.Name!="GetItemList"||args[0] is not InternalItemsQuery q||!q.IncludeItemTypes.SequenceEqual(new[]{BaseItemKind.LiveTvChannel})) + throw new Exception("Expected metadata lookup restricted to live TV channels"); + Calls++; + return new BaseItem[]{new LiveTvChannel{ExternalId=Old,Name="TV PULS HD",ServiceName="Enigma2"}, + new LiveTvChannel{ExternalId=Old,Name="Different channel",ServiceName="Other provider"}}; + } + } + private sealed class ReceiverFactory:IHttpClientFactory + { + public HttpClient CreateClient(string name)=>new(new BouquetHandler()); + } + private sealed class BouquetHandler:HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request,CancellationToken token) + { + token.ThrowIfCancellationRequested(); + var body=string.IsNullOrEmpty(request.RequestUri.Query) + ? "1:7:1:0:0:0:0:0:0:0:FROM BOUQUETPolskie" + : ""+Current+"TV PULS HD"; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK){Content=new StringContent(body)}); + } + } + private sealed class ReceiverHandler:HttpMessageHandler + { + public int Calls;public string Reference; + protected override Task SendAsync(HttpRequestMessage request,CancellationToken token) + { + Calls++;Reference=request.RequestUri.AbsolutePath.TrimStart('/'); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK){Content=new StreamContent(new PacketStream())}); + } + } +} diff --git a/build.yaml b/build.yaml index 1b2145c..8f54435 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.8.0" # Please increment with each pull request +version: "7.0.9.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 6a4d646..ba34fdc 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.8.0' +VERSION = '7.0.9.0' ABI = '12.2.0.0' GUID = '193f29f9-ea6c-4595-a6f6-55e79d7c590a' DLL = 'Jellyfin.Plugin.Enigma2.dll'