Resolve saved native channel references after receiver migrations

This commit is contained in:
2026-10-23 18:31:55 +02:00
parent 0ab790d1e4
commit 4ff46f547b
9 changed files with 227 additions and 5 deletions
+32
View File
@@ -0,0 +1,32 @@
using System;
using Jellyfin.Plugin.Enigma2;
using MediaBrowser.Controller.LiveTv;
public static class ChannelReferenceTests
{
public static void Run(Action<bool,string> 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<ChannelInfo>(),()=>"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");
}
}
+2
View File
@@ -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;
+114
View File
@@ -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<bool,string> 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<IApplicationPaths,SavedPaths>();((SavedPaths)(object)paths).Folder=folder;
var plugin=new Plugin(paths,DispatchProxy.Create<IXmlSerializer,NoXmlFiles>());
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<ILibraryManager,SavedChannelLibrary>();
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<LiveTvService>.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<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token)
{
token.ThrowIfCancellationRequested();
var body=string.IsNullOrEmpty(request.RequestUri.Query)
? "<e2servicelist><e2service><e2servicereference>1:7:1:0:0:0:0:0:0:0:FROM BOUQUET</e2servicereference><e2servicename>Polskie</e2servicename></e2service></e2servicelist>"
: "<e2servicelist><e2service><e2servicereference>"+Current+"</e2servicereference><e2servicename>TV PULS HD</e2servicename></e2service></e2servicelist>";
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<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token)
{
Calls++;Reference=request.RequestUri.AbsolutePath.TrimStart('/');
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK){Content=new StreamContent(new PacketStream())});
}
}
}