Integrate native decoder TV and internet EPG artwork with Jellyfin 12.2

This commit is contained in:
2026-10-11 13:20:56 +02:00
parent d38c1595d8
commit a049a63d7a
21 changed files with 681 additions and 18 deletions
@@ -28,6 +28,7 @@ namespace Jellyfin.Plugin.Enigma2.Configuration
public string TranscodingPort { get; set; }
public bool EnableDebugLogging { get; set; }
public bool EnableVuRecordings { get; set; } = false;
public PluginConfiguration()
@@ -0,0 +1,54 @@
<!DOCTYPE html><html><head><title>Telewizja</title><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{background:#181818;color:#eee;font:16px system-ui;margin:0} .content-primary{max-width:1200px;margin:auto;padding:24px}button,input,select{font:inherit;background:#303030;color:#eee;border:1px solid #666;border-radius:6px;padding:10px;margin:4px}button{cursor:pointer}button:hover{background:#455a64}a{color:#7cd5ef}img{object-fit:contain;border-radius:5px}section>div{flex:1;min-width:160px}</style></head><body>
<div id="decoderChannels" data-role="page" class="page type-interior pluginConfigurationPage">
<div data-role="content"><div class="content-primary">
<h1>Telewizja</h1><p>Cała lista kanałów dekodera. Ustawienia dotyczą Twojego konta.</p>
<input id="channelSearch" is="emby-input" placeholder="Szukaj kanału lub audycji" aria-label="Szukaj">
<select id="channelView" is="emby-select" aria-label="Widok"><option value="all">Wszystkie kanały</option><option value="favorites">Ulubione</option><option value="guide">Program telewizyjny</option><option value="hidden">Ukryte kanały</option></select>
<button id="saveDefault" is="emby-button">Ustaw ten widok jako domyślny</button>
<a id="nativeGuide" href="/web/index.html#/livetv">Natywny przewodnik, nagrania i timery</a>
<p id="channelMessage" role="status"></p><div id="channelRows"></div>
</div></div>
<script>
(function(){
const standalone=typeof ApiClient==='undefined';
const base=standalone?location.pathname.replace(/\/DecoderIntegration\/Channels\/?$/,''):'';
if(standalone){
let servers=[];try{servers=JSON.parse(localStorage.getItem('jellyfin_credentials')||'{}').Servers||[];}catch(e){}
const server=servers.find(s=>s.AccessToken&&s.UserId&&[s.ManualAddress,s.LocalAddress,s.RemoteAddress].some(a=>{try{return new URL(a).host===location.host;}catch(e){return false;}}));
if(!server){document.querySelector('#channelMessage').textContent='Zaloguj się w Jellyfinie z opcją „Zapamiętaj mnie”, a następnie otwórz tę stronę ponownie.';const link=document.createElement('a');link.href=base+'/web/index.html#/login';link.textContent='Przejdź do logowania Jellyfin';document.querySelector('#channelRows').append(link);return;}
window.ApiClient={getCurrentUserId:()=>server.UserId,serverId:()=>server.Id,getUrl:(path,query)=>base+'/'+path+(query?'?'+new URLSearchParams(query):''),
ajax:async options=>{const response=await fetch(options.url,{method:options.type||'GET',headers:{'Authorization':'MediaBrowser Token="'+server.AccessToken+'", Client="DecoderIntegration", Device="Browser", DeviceId="decoder-integration", Version="7"','Content-Type':'application/json'},body:options.data});if(!response.ok)throw Error('HTTP '+response.status);return options.dataType==='json'?response.json():undefined;},
getImageUrl:(id,options)=>base+'/Items/'+encodeURIComponent(id)+'/Images/'+options.type+'?'+new URLSearchParams({tag:options.tag,maxWidth:options.maxWidth})};
}
const page=document.querySelector('#decoderChannels'); let channels=[], preferences, programs={}, saving=Promise.resolve();
const q=id=>page.querySelector('#'+id), key=id=>id.replace(/-/g,'').toLowerCase();
function request(path,type='GET',data){return ApiClient.ajax({type,url:ApiClient.getUrl(path),data:data?JSON.stringify(data):undefined,contentType:'application/json',dataType:type==='GET'?'json':undefined});}
function save(){const snapshot=JSON.parse(JSON.stringify(preferences));saving=saving.catch(()=>{}).then(()=>request('DecoderIntegration/Preferences','PUT',snapshot));saving.catch(()=>q('channelMessage').textContent='Nie udało się zapisać ustawień.');}
function button(text,action){const b=document.createElement('button');b.textContent=text;b.className='raised emby-button';b.onclick=action;return b;}
function details(item){location.href=base+'/web/index.html#/details?id='+encodeURIComponent(item.Id)+'&serverId='+encodeURIComponent(ApiClient.serverId());}
q('nativeGuide').href=base+'/web/index.html#/livetv';
function toggle(field,id){const a=preferences[field],i=a.indexOf(id);if(i<0)a.push(id);else a.splice(i,1);if(field==='FavoriteIds')request('Users/'+ApiClient.getCurrentUserId()+'/FavoriteItems/'+id,i<0?'POST':'DELETE').catch(()=>q('channelMessage').textContent='Nie udało się zsynchronizować ulubionych z Jellyfinem.');save();render();}
function render(){
const view=q('channelView').value,search=q('channelSearch').value.toLocaleLowerCase(),hidden=new Set(preferences.HiddenIds),fav=new Set(preferences.FavoriteIds);
const order=new Map(preferences.OrderedIds.map((id,i)=>[id,i]));
const sorted=[...channels].sort((a,b)=>(order.get(key(a.Id))??99999)-(order.get(key(b.Id))??99999));
const visible=sorted.filter(c=>{const id=key(c.Id);return (view==='hidden'?hidden.has(id):!hidden.has(id))&&(view!=='favorites'||fav.has(id))&&(c.Name+' '+(c.CurrentProgram?.Name||'')).toLocaleLowerCase().includes(search);});
q('channelRows').replaceChildren();q('channelMessage').textContent=visible.length+' / '+channels.length+' kanałów';
visible.forEach(c=>{const id=key(c.Id),row=document.createElement('section');row.style.cssText='padding:1em 0;border-bottom:1px solid #666;display:flex;gap:1em;flex-wrap:wrap;align-items:center';
if(c.ImageTags?.Primary){const image=document.createElement('img');image.src=ApiClient.getImageUrl(c.Id,{type:'Primary',tag:c.ImageTags.Primary,maxWidth:100});image.alt='';image.width=80;image.loading='lazy';row.append(image);}
row.append(button((c.Number?c.Number+'. ':'')+c.Name,()=>details(c)),button(fav.has(id)?'★ Ulubiony':'☆ Ulubiony',()=>toggle('FavoriteIds',id)),button(hidden.has(id)?'Pokaż':'Ukryj',()=>toggle('HiddenIds',id)));
row.append(button('↑',()=>{const list=sorted.map(x=>key(x.Id)),i=list.indexOf(id);if(i>0){[list[i-1],list[i]]=[list[i],list[i-1]];preferences.OrderedIds=list;save();render();}}));
const events=view==='guide'?(programs[c.Id]||[]):[c.CurrentProgram].filter(Boolean);
events.forEach(p=>{const card=document.createElement('div');card.style.cssText='max-width:22em;cursor:pointer';card.onclick=()=>details(p);
if(p.ImageTags?.Primary){const image=document.createElement('img');image.src=ApiClient.getImageUrl(p.Id,{type:'Primary',tag:p.ImageTags.Primary,maxWidth:240});image.width=160;image.alt='';image.loading='lazy';card.append(image);}
const text=document.createElement('p');text.textContent=new Date(p.StartDate).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})+'–'+new Date(p.EndDate).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})+' '+p.Name;card.append(text);row.append(card);
});q('channelRows').append(row);
});
}
async function guide(){if(Object.keys(programs).length)return;const now=new Date(),until=new Date(now.getTime()+6*3600000);let offset=0;while(true){const result=await ApiClient.ajax({type:'GET',url:ApiClient.getUrl('LiveTv/Programs',{UserId:ApiClient.getCurrentUserId(),MinEndDate:now.toISOString(),MaxStartDate:until.toISOString(),StartIndex:offset,Limit:500}),dataType:'json'});for(const p of result.Items)(programs[p.ChannelId]??=[]).push(p);render();offset+=result.Items.length;if(!result.Items.length||offset>=result.TotalRecordCount)break;}render();}
q('channelSearch').oninput=()=>preferences&&render();q('channelView').onchange=()=>{render();if(q('channelView').value==='guide')guide().catch(()=>q('channelMessage').textContent='Nie udało się pobrać przewodnika.');};
q('saveDefault').onclick=()=>{if(q('channelView').value!=='hidden'){preferences.DefaultView=q('channelView').value;save();}};
async function load(){try{preferences=await request('DecoderIntegration/Preferences');channels=[];programs={};let offset=0;while(true){const result=await ApiClient.ajax({type:'GET',url:ApiClient.getUrl('LiveTv/Channels',{UserId:ApiClient.getCurrentUserId(),AddCurrentProgram:true,EnableUserData:true,DecoderIncludeHidden:true,StartIndex:offset,Limit:250}),dataType:'json'});channels.push(...result.Items);offset+=result.Items.length;if(!result.Items.length||offset>=result.TotalRecordCount)break;}preferences.FavoriteIds=[...new Set([...preferences.FavoriteIds,...channels.filter(c=>c.UserData?.IsFavorite).map(c=>key(c.Id))])];q('channelView').value=preferences.DefaultView;render();if(preferences.DefaultView==='guide')await guide();}catch(e){q('channelMessage').textContent='Nie udało się pobrać kanałów. Sprawdź dostęp do telewizji na swoim koncie.';}}
page.addEventListener('pageshow',load);if(standalone)load();
})();
</script></div></body></html>
@@ -143,6 +143,11 @@
</div>
</div>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkEnableVuRecordings" />
<span>Obsługuj nagrania z Vu+ (Enigma2 Recordings)</span>
</label>
<div class="fieldDescription">Włącza bibliotekę nagrań dekodera. Zmiana wymaga ponownego uruchomienia Jellyfina. Wyłączenie nie usuwa plików nagrań.</div>
<label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="chkDebugLogging" />
<span>Enable Enigma2 debug logging</span>
@@ -182,6 +187,7 @@
document.getElementById('chkTranscodedStream').checked = config.TranscodedStream || false;
document.getElementById('txtTranscodingPort').value = config.TranscodingPort || "";
document.getElementById('chkDebugLogging').checked = config.EnableDebugLogging || false;
document.getElementById('chkEnableVuRecordings').checked = config.EnableVuRecordings || false;
Dashboard.hideLoadingMsg();
});
@@ -208,6 +214,7 @@
config.TranscodedStream = document.getElementById('chkTranscodedStream').checked;
config.TranscodingPort = document.getElementById('txtTranscodingPort').value;
config.EnableDebugLogging = document.getElementById('chkDebugLogging').checked;
config.EnableVuRecordings = document.getElementById('chkEnableVuRecordings').checked;
ApiClient.updatePluginConfiguration(Enigma2ConfigurationPage.pluginUniqueId, config).then(Dashboard.processPluginConfigurationUpdateResult);
});
+96
View File
@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using MediaBrowser.Controller.LiveTv;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Enrich receiver EIT without replacing its timer identifiers or schedule.</summary>
public sealed class InternetEpg
{
public static InternetEpg Instance { get; private set; }
private readonly IHttpClientFactory factory;
private readonly ILogger<InternetEpg> logger;
private readonly SemaphoreSlim gate = new(1);
private Dictionary<string, List<Entry>> entries = new();
private Dictionary<string, string[]> aliases = new();
private DateTime nextAttempt;
public DateTime? LastSuccess { get; private set; }
public int MatchedImages { get; private set; }
public string LastError { get; private set; }
public record Entry(string Title, DateTime Start, string Image);
public InternetEpg(IHttpClientFactory factory, ILogger<InternetEpg> logger)
{ this.factory = factory; this.logger = logger; Instance = this; }
public static string Normalize(string text) => Regex.Replace((text ?? "").ToLowerInvariant(), @"[^\p{L}\p{N}]", "");
public static string ChannelKey(string name) => Normalize(Regex.Replace(name ?? "", @"\s+(?:FHD|UHD|HD|SD)$", "", RegexOptions.IgnoreCase));
public static Entry Match(IEnumerable<Entry> candidates, string title, DateTime start)
{
var matches = candidates.Where(e => Normalize(e.Title) == Normalize(title) && Math.Abs((e.Start-start).TotalMinutes) <= 7).ToArray();
return matches.Length == 1 ? matches[0] : null;
}
public static bool AllowedImage(string value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == "https" && uri.Port == 443 && string.IsNullOrEmpty(uri.UserInfo) &&
(uri.Host == "ptv-prod.wphcdn.cloud" || uri.Host == "media.teleman.pl");
private async Task Refresh(CancellationToken token)
{
if (DateTime.UtcNow < nextAttempt) return;
await gate.WaitAsync(token).ConfigureAwait(false);
try
{
if (DateTime.UtcNow < nextAttempt) return;
nextAttempt = DateTime.UtcNow.AddMinutes(15);
using var client = factory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(90);
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 Jellyfin-Enigma2/7.0");
using var response = await client.GetAsync("https://epg.polishteam.one/guide.xml", HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength > 134217728) throw new InvalidOperationException("EPG too large");
using var stream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
using var reader = XmlReader.Create(stream, new XmlReaderSettings { Async=true, DtdProcessing=DtdProcessing.Prohibit, XmlResolver=null, MaxCharactersInDocument=134217728 });
var doc = await XDocument.LoadAsync(reader, LoadOptions.None, token).ConfigureAwait(false);
var newAliases = doc.Root.Elements("channel").SelectMany(c => c.Elements("display-name").Select(n => (Key:ChannelKey(n.Value), Id:(string)c.Attribute("id"))))
.Where(a => !string.IsNullOrEmpty(a.Id)).GroupBy(a=>a.Key).ToDictionary(g=>g.Key,g=>g.Select(a=>a.Id).Distinct().ToArray());
var newEntries = new Dictionary<string,List<Entry>>();
foreach (var programme in doc.Root.Elements("programme"))
{
var image = (string)programme.Element("icon")?.Attribute("src");
var raw = (string)programme.Attribute("start");
var id = (string)programme.Attribute("channel");
if (!AllowedImage(image) || id == null || raw == null) continue;
if (!DateTimeOffset.TryParseExact(raw, "yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture, DateTimeStyles.None, out var start))
{
if (raw.Length != 20 || !DateTimeOffset.TryParseExact(raw.Insert(18, ":"), "yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture, DateTimeStyles.None, out start)) continue;
}
if (!newEntries.TryGetValue(id, out var list)) newEntries[id] = list = new();
list.Add(new Entry(programme.Element("title")?.Value ?? "", start.UtcDateTime, image));
}
if (newEntries.Count == 0) throw new InvalidOperationException("EPG has no supported images");
entries = newEntries; aliases = newAliases; LastSuccess = DateTime.UtcNow; LastError = null;
nextAttempt = DateTime.UtcNow.AddHours(6);
logger.LogInformation("Internet EPG refreshed: {Channels} channels with programme images", entries.Count);
}
catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; }
catch (Exception ex) { LastError=ex.GetType().Name; logger.LogWarning("Internet EPG unavailable ({Type}); receiver EPG retained", LastError); }
finally { gate.Release(); }
}
public async Task EnrichAsync(string channelName, IEnumerable<ProgramInfo> programmes, CancellationToken token)
{
await Refresh(token).ConfigureAwait(false);
if (LastSuccess == null || DateTime.UtcNow - LastSuccess > TimeSpan.FromHours(48)) return;
if (!aliases.TryGetValue(ChannelKey(channelName), out var ids) || ids.Length != 1 || !entries.TryGetValue(ids[0], out var candidates)) return;
foreach (var programme in programmes)
{
var match = Match(candidates, programme.Name, programme.StartDate);
if (match == null) continue;
programme.ImagePath = null; programme.ImageUrl = match.Image; programme.HasImage = true;
MatchedImages++;
}
}
}
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0</TargetFrameworks>
<AssemblyVersion>6.0.0.0</AssemblyVersion>
<FileVersion>6.0.0.0</FileVersion>
<TargetFrameworks>net10.0</TargetFrameworks>
<AssemblyVersion>7.0.0.0</AssemblyVersion>
<FileVersion>7.0.0.0</FileVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
@@ -11,7 +11,7 @@
<DebugSymbols>false</DebugSymbols>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net8.0|AnyCPU'">
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0|AnyCPU'">
<DebugType>none</DebugType>
</PropertyGroup>
@@ -24,12 +24,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Common" Version="10.9.0" />
<PackageReference Include="Jellyfin.Naming" Version="10.9.0" />
<PackageReference Include="Jellyfin.Controller" Version="10.9.0" />
<PackageReference Include="Jellyfin.Model" Version="10.9.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Jellyfin.Common" Version="12.2.0" />
<PackageReference Include="Jellyfin.Naming" Version="12.2.0" />
<PackageReference Include="Jellyfin.Controller" Version="12.2.0" />
<PackageReference Include="Jellyfin.Model" Version="12.2.0" />
</ItemGroup>
<ItemGroup><FrameworkReference Include="Microsoft.AspNetCore.App" /><EmbeddedResource Include="Configuration/channels.html" /></ItemGroup>
</Project>
+9 -4
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.IO;
using System.Net;
using System.Net.Http;
@@ -39,7 +40,7 @@ namespace Jellyfin.Plugin.Enigma2
public static LiveTvService Instance { get; private set; }
public LiveTvService(ILogger<LiveTvService> logger, IHttpClientFactory httpClientFactory)
public LiveTvService(ILogger<LiveTvService> logger, IHttpClientFactory httpClientFactory, InternetEpg internetEpg)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
@@ -339,6 +340,9 @@ namespace Jellyfin.Plugin.Enigma2
channelInfos.AddRange(channelInfosForBouquet);
}
channelInfos = channelInfos.GroupBy(c => c.Id).Select(g => g.First()).ToList();
for (var i = 0; i < channelInfos.Count; i++) channelInfos[i].Number = (i + 1).ToString(CultureInfo.InvariantCulture);
tvChannelInfos = channelInfos;
return channelInfos;
}
else
@@ -1301,7 +1305,7 @@ namespace Jellyfin.Plugin.Enigma2
_logger.LogInformation("[Enigma2] Start GetProgramsAsync");
await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false);
var rnd = new Random();
var imagePath = "";
var imageUrl = "";
@@ -1395,7 +1399,7 @@ namespace Jellyfin.Plugin.Enigma2
if (sdate > endDateUtc)
{
UtilsHelper.DebugInformation(_logger, string.Format("[Enigma2] GetProgramsAsync epc full ending without adding channel name : {0} program : {1}", e2eventservicename, e2eventtitle));
return programInfos;
continue;
}
else
{
@@ -1427,7 +1431,7 @@ namespace Jellyfin.Plugin.Enigma2
programInfo.ChannelId = e2eventservicereference;
// for some reason the Id appears to have to be unique so will make it so
programInfo.Id = e2eventservicereference + "~" + e2eventid + "~" + count + "~" + rnd.Next();
programInfo.Id = e2eventservicereference + "~" + e2eventid + "~" + e2eventstart;
programInfo.Overview = e2eventdescriptionextended;
@@ -1461,6 +1465,7 @@ namespace Jellyfin.Plugin.Enigma2
count = count + 1;
}
}
await InternetEpg.Instance.EnrichAsync(tvChannelInfos.FirstOrDefault(c => c.Id == channelId)?.Name, programInfos, cancellationToken).ConfigureAwait(false);
return programInfos;
}
catch (Exception e)
@@ -0,0 +1,67 @@
using System;
using System.Linq;
using System.Text.Json.Nodes;
using System.IO;
using MediaBrowser.Common.Configuration;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Apply presentation preferences after the native API has enforced access rights.</summary>
public sealed class NativeChannelFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
{
app.Use(async (context, continuePipeline) =>
{
if (!HttpMethods.IsGet(context.Request.Method) ||
!context.Request.Path.Value.TrimEnd('/').Equals("/LiveTv/Channels",StringComparison.OrdinalIgnoreCase))
{ await continuePipeline(); return; }
var originalQuery=context.Request.QueryString;
var originalEncoding=context.Request.Headers.AcceptEncoding;
// Buffer JSON before applying preferences; compression must not run inside this buffer.
context.Request.Headers.Remove("Accept-Encoding");
var parsed=QueryHelpers.ParseQuery(originalQuery.Value ?? "");
int Number(string name,int fallback) => parsed.TryGetValue(name,out var value) && int.TryParse(value,out var number) && number>=0 ? number : fallback;
var start=Number("StartIndex",0);var limit=Number("Limit",int.MaxValue);
// Fetch the permission-filtered full result, then paginate after preferences.
context.Request.QueryString=QueryString.Create(parsed.Where(p=>!p.Key.Equals("StartIndex",StringComparison.OrdinalIgnoreCase)&&!p.Key.Equals("Limit",StringComparison.OrdinalIgnoreCase)));
var originalBody=context.Response.Body;
await using var buffer=new MemoryStream();context.Response.Body=buffer;
try
{
await continuePipeline();
context.Response.Body=originalBody;
if (context.Response.StatusCode==200 && context.Response.ContentType?.StartsWith("application/json",StringComparison.OrdinalIgnoreCase)==true)
{
buffer.Position=0;
var document=await JsonNode.ParseAsync(buffer,cancellationToken:context.RequestAborted);
if(document?["Items"] is JsonArray items)
{
Guid.TryParse(context.User.FindFirst("Jellyfin-UserId")?.Value,out var userId);
if(userId==Guid.Empty && parsed.TryGetValue("UserId",out var requested)&&Guid.TryParse(requested,out var requestedId)) userId=requestedId;
var preferences=userId==Guid.Empty?new ChannelPreferences():await context.RequestServices.GetRequiredService<PreferencesStore>().Load(userId,context.RequestAborted);
var hidden=preferences.HiddenIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
var includeHidden=parsed.TryGetValue("DecoderIncludeHidden",out var includeValue)&&bool.TryParse(includeValue,out var include)&&include;
var order=preferences.OrderedIds.Select((id,index)=>(id,index)).ToDictionary(x=>x.id,x=>x.index,StringComparer.OrdinalIgnoreCase);
string Id(JsonNode node) => Guid.TryParse((string)node?["Id"],out var id)?id.ToString("N"):"";
var filtered=items.Where(item=>includeHidden||!hidden.Contains(Id(item))).OrderBy(item=>order.TryGetValue(Id(item),out var position)?position:int.MaxValue).ToArray();
document["TotalRecordCount"]=filtered.Length;
document["StartIndex"]=start;
document["Items"]=new JsonArray(filtered.Skip(start).Take(limit).Select(item=>item.DeepClone()).ToArray());
context.Response.ContentLength=null;
await context.Response.WriteAsync(document.ToJsonString(),context.RequestAborted);
return;
}
}
buffer.Position=0;await buffer.CopyToAsync(originalBody,context.RequestAborted);
}
finally { context.Response.Body=originalBody;context.Request.QueryString=originalQuery;context.Request.Headers.AcceptEncoding=originalEncoding; }
});
next(app);
};
}
+2 -1
View File
@@ -50,7 +50,8 @@ namespace Jellyfin.Plugin.Enigma2
{
Name = "enigma2",
EmbeddedResourcePath = "Jellyfin.Plugin.Enigma2.Configuration.configPage.html"
}
},
new PluginPageInfo { Name = "decoder-channels", DisplayName = "Telewizja", EmbeddedResourcePath = "Jellyfin.Plugin.Enigma2.Configuration.channels.html" }
};
}
}
@@ -0,0 +1,70 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.Enigma2;
public sealed class ChannelPreferences
{
public string[] FavoriteIds { get; set; } = Array.Empty<string>();
public string[] HiddenIds { get; set; } = Array.Empty<string>();
public string[] OrderedIds { get; set; } = Array.Empty<string>();
public string DefaultView { get; set; } = "all";
public void Validate()
{
string[] Clean(string[] values) => (values ?? Array.Empty<string>()).Take(2000).Where(v=>Guid.TryParse(v,out _)).Select(v=>Guid.Parse(v).ToString("N")).Distinct().ToArray();
FavoriteIds=Clean(FavoriteIds); HiddenIds=Clean(HiddenIds); OrderedIds=Clean(OrderedIds);
if (DefaultView != "all" && DefaultView != "favorites" && DefaultView != "guide") DefaultView="all";
}
}
public sealed class PreferencesStore
{
private readonly string folder;
private readonly ConcurrentDictionary<Guid,SemaphoreSlim> locks = new();
public PreferencesStore(IApplicationPaths paths) { folder=Path.Combine(paths.DataPath,"decoder-integration","users"); }
public async Task<ChannelPreferences> Load(Guid user, 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 new();
var result=JsonSerializer.Deserialize<ChannelPreferences>(await File.ReadAllTextAsync(file,token)) ?? new(); result.Validate(); return result; }
finally { gate.Release(); }
}
public async Task Save(Guid user, ChannelPreferences value, CancellationToken token)
{
value.Validate(); var gate=locks.GetOrAdd(user,_=>new(1)); await gate.WaitAsync(token);
try { Directory.CreateDirectory(folder); var file=Path.Combine(folder,user.ToString("N")+".json"); var temp=file+".tmp";
await File.WriteAllTextAsync(temp,JsonSerializer.Serialize(value),token); File.Move(temp,file,true); }
finally { gate.Release(); }
}
}
[ApiController]
[Route("DecoderIntegration")]
[Authorize(Policy="LiveTvAccess")]
public sealed class PreferencesController : ControllerBase
{
private readonly PreferencesStore store;
private readonly InternetEpg epg;
public PreferencesController(PreferencesStore store,InternetEpg epg) { this.store=store; this.epg=epg; }
private Guid UserId => Guid.TryParse(User.FindFirst("Jellyfin-UserId")?.Value,out var id) ? id : Guid.Empty;
[HttpGet("Channels")]
[AllowAnonymous]
public IActionResult Page()
{
var stream=typeof(Plugin).Assembly.GetManifestResourceStream("Jellyfin.Plugin.Enigma2.Configuration.channels.html");
return File(stream,"text/html; charset=utf-8");
}
[HttpGet("Preferences")]
public async Task<ActionResult<ChannelPreferences>> Get(CancellationToken token) => UserId == Guid.Empty ? Unauthorized() : Ok(await store.Load(UserId,token));
[HttpPut("Preferences")]
public async Task<IActionResult> Put(ChannelPreferences value,CancellationToken token)
{ if (UserId == Guid.Empty) return Unauthorized(); await store.Save(UserId,value,token); return NoContent(); }
[HttpGet("Status")]
[Authorize(Policy="RequiresElevation")]
public IActionResult Status() => Ok(new { epg.LastSuccess,epg.MatchedImages,epg.LastError, Source="https://epg.polishteam.one/guide.xml" });
}
+20 -1
View File
@@ -5,6 +5,11 @@ using MediaBrowser.Controller;
using Microsoft.Extensions.DependencyInjection;
using System.Net.Http;
using Jellyfin.Plugin.Enigma2.Helpers;
using MediaBrowser.Common.Configuration;
using System.IO;
using System.Linq;
using System.Xml;
using System.Xml.Linq;
namespace Jellyfin.Plugin.Enigma2
{
@@ -13,8 +18,22 @@ namespace Jellyfin.Plugin.Enigma2
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<InternetEpg>();
serviceCollection.AddSingleton<PreferencesStore>();
serviceCollection.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, NativeChannelFilter>();
serviceCollection.AddSingleton<ILiveTvService, LiveTvService>();
serviceCollection.AddSingleton<IChannel, RecordingsChannel>();
// 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")))
serviceCollection.AddSingleton<IChannel, RecordingsChannel>();
}
public static bool VuRecordingsEnabled(string configurationPath)
{
if (!File.Exists(configurationPath)) return false;
using var reader = XmlReader.Create(configurationPath, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null });
var value = XDocument.Load(reader).Root?.Element("EnableVuRecordings")?.Value;
return bool.TryParse(value, out var enabled) && enabled;
}
}
}
+54
View File
@@ -0,0 +1,54 @@
# Enigma2 dla Jellyfin 12.2
Lokalne rozszerzenie wtyczki Enigma2 6.0.0.0, wersja 7.0.0.0. Zachowuje identyfikator wtyczki, nazwę usługi, konfigurację połączenia, natywne strumienie, nagrania i timery dekodera. Profile transkodowania pozostają na dekoderze.
## Funkcje
- Pełna lista 216 kanałów skonfigurowanego bukietu „Polskie”, z numerami, logo i wyszukiwaniem. Stronicowanie API obsługuje również większe listy.
- Ulubione, ukrywanie, kolejność i domyślny widok osobno dla każdego użytkownika. Ulubione synchronizują się z natywnym Jellyfinem. Ukrywanie i kolejność są nakładane także na natywny endpoint `/LiveTv/Channels`, po kontroli uprawnień i przed stronicowaniem; widok domyślny dotyczy strony dodatku.
- Globalny przełącznik **Obsługuj nagrania z Vu+ (Enigma2 Recordings)** w konfiguracji wtyczki Enigma2, domyślnie wyłączony. Wyłączenie nie rejestruje dostawcy biblioteki nagrań, pozostawia telewizję, EPG oraz timery i nie usuwa plików z dekodera. Zmiana wymaga restartu Jellyfina oraz odświeżenia kanałów dostawców. Nie jest to przełącznik na stronie kanałów.
- Widok audycji na najbliższe sześć godzin, przejścia do szczegółów kanału/audycji oraz natywnego przewodnika, nagrań i timerów.
- Rzeczywiste grafiki audycji z `https://epg.polishteam.one/guide.xml`, pobierane i buforowane przez Jellyfin. Brak generowania obrazków.
- Konserwatywne dopasowanie po jednoznacznym kanale, dokładnym znormalizowanym tytule oraz czasie rozpoczęcia ±7 minut. Brak zgadywania przy niejednoznacznych danych. Harmonogram i EIT do timerów pozostają z Enigmy.
- Stałe identyfikatory audycji: service reference, EIT i początek emisji, zamiast losowej liczby. Poprawiona deduplikacja i cache kanałów przy odczycie wielu bukietów.
## Otwieranie
Jellyfin Web → menu **Telewizja dekodera**. Bezpośrednio: `/DecoderIntegration/Channels` na tym samym serwerze i adresie, pod którym użytkownik loguje się do Jellyfina. Strona korzysta z zapisanej sesji Jellyfin Web; wymagane logowanie z opcją **Zapamiętaj mnie**. Nie ma oddzielnych kont ani haseł dodatku. Bez zapisanej sesji strona pokazuje odnośnik do logowania.
W Jellyfin 10.11 strony `configurationpage` są ograniczone do administratorów w routerze klienta. Dlatego dla wszystkich użytkowników użyto standardowego `menuLinks` w `/usr/share/jellyfin/web/config.json` i strony HTML serwowanej przez wtyczkę. API preferencji wymaga uprawnienia Live TV i identyfikuje użytkownika z uwierzytelnionych claims, bez przyjmowania dowolnego UserId od klienta. Administracyjny stan importu: `GET /DecoderIntegration/Status`.
Grafiki trafiają do natywnego EPG i są dostępne również w innych klientach Jellyfina. Własna strona i jej układ są funkcją przeglądarkową; nie zmieniają interfejsu aplikacji Android TV. Uprawnienia kanałów nadal egzekwuje natywne API Jellyfina.
## Źródła i ograniczenia
Sprawdzono 11.10.2026: [wydania autora](https://github.com/knackebrot/jellyfin-plugin-enigma2/releases) i [manifest](https://github.com/knackebrot/jellyfin-repo/blob/master/manifest.json) nadal wskazują **6.0.0.0, 22.06.2024**, jako najnowsze opublikowane wydanie. **7.0.0.0 jest wersją lokalną tego projektu**, nie oficjalnym wydaniem autora. Pochodzenie vendora: `UPSTREAM.txt`, licencja CC0.
Źródło grafik: PolishTeam. Dozwolone adresy obrazków to HTTPS na `ptv-prod.wphcdn.cloud` i `media.teleman.pl`; dane XML mają limit 128 MiB i zablokowane DTD/encje zewnętrzne. Brak pasującej grafiki pozostawia dotychczasowe dane Enigmy. Awaria importu nie blokuje telewizji. Feed jest odświeżany najwyżej co 6 h przy odświeżaniu przewodnika; po błędzie ponowna próba po 15 minutach, ostatni dobry cache ważny do 48 h. Cache feedu jest w pamięci, po restarcie pobiera się ponownie.
Nie wszystkie audycje mają zgodny tytuł i grafikę w źródle. Dostępność zewnętrznego feedu nie jest gwarantowana. Pełne odświeżanie EPG i cache zdjęć może potrwać; dopiero zakończone natywne Refresh Guide usuwa stare losowe identyfikatory audycji.
## Budowanie i wdrożenie
SDK .NET 10, `dotnet run --project Tests -c Release`. Projekt odwołuje się do Jellyfin 12.2. Testy konsolowe sprawdzają dopasowanie, niejednoznaczność, zakres czasu, alias HD, ograniczenia URL i walidację preferencji.
Budowano w izolowanym `mcr.microsoft.com/dotnet/sdk:10.0` na CT100195 z limitem dwóch CPU i osobnym katalogiem `/tmp/jellyfin-decoder-plugin`. Nie zmieniono usług serwera strumieniowego. Artefakt: `Jellyfin.Plugin.Enigma2/bin/Release/net10.0/Jellyfin.Plugin.Enigma2.dll`.
`deploy.py` uruchamia się w CT100191 po umieszczeniu DLL w `/tmp/Jellyfin.Plugin.Enigma2.dll`. Odmawia restartu przy aktywnym odtwarzaniu. Zachowuje stare katalogi wtyczki, konfigurację oraz web-config w `/var/lib/jellyfin/decoder-plugin-backups/<UTC>`, przenosi starą wersję poza katalog plugins i instaluje nową z zachowaniem tożsamości. Restartuje tylko Jellyfin, nie dekoder.
Powrót po aktualizacji serwera wymaga przywrócenia **pełnej kopii danych i konfiguracji sprzed migracji oraz pakietów Jellyfin 10.11.6**. Sama zamiana DLL nie cofa zmian bazy danych. W obrębie Jellyfina 12.2 można przywrócić poprzednią zgodną wersję dodatku z kopii wdrożenia. Wrapper FFmpeg i profile odbiornika są zachowane.
`verify.py` sprawdza aktywność wtyczki, kanały, timery, izolację preferencji dwóch kont i politykę administracyjną. Konta testowe usuwa w finally. `test-browser.cjs` sprawdza rzeczywisty Jellyfin Web w izolowanym Edge headless i usuwa własne konto. Nie drukuje tokenów ani haseł.
Kod jest także aktualizowany w `C:\Users\michal\git\jellyfin-plugin-enigma2` na gałęzi `codex/decoder-integration`. Dodatkowy remote `ekacprzak`: `ssh://git@git.e-kacprzak.eu:2222/michal/jellyfin-plugin-enigma2.git`. Zmiany wtyczki są wypychane wyłącznie do tego remote; GitHub origin pozostaje źródłem upstream.
Przed końcowym wyłączeniem dostawcy ukryto jego kafelek i ostatnio dodane na 19 istniejących kontach przez natywne MyMediaExcludes/LatestItemsExcludes (wyraźna zgoda użytkownika); kopia poprzednich ustawień: `/var/lib/jellyfin/decoder-plugin-backups/home-exclusions-20261011T104028Z.json`. `hide-recordings-home.py` dokumentuje ten jednorazowy krok. Nie jest mechanizmem przełącznika obsługi nagrań. Po ewentualnym ponownym włączeniu dostawcy użytkownicy mogą odznaczyć wykluczenie biblioteki w ustawieniach ekranu głównego.
## Aktualizacja serwera 11.10.2026
Jellyfin w CT100191 zaktualizowano z 10.11.6 do **12.2.0**, FFmpeg do **8.1.3**. Pełna kopia po zatrzymaniu serwera: `/var/backups/jellyfin-pre12-20261011T110237Z/data-config.tar` (9 465 702 400 bajtów). Migracje zakończyły się pomyślnie; zachowano 19 kont, 216 kanałów i 33 timery. LDAP 19/22 usunięto, SSO zachowano. Test inicjacji OIDC z Host `jellyfin.e-kacprzak.eu` zwrócił 302 do `login.e-kacprzak.eu`; adres techniczny nie jest dozwolonym callbackiem w Keycloak. Pełnego logowania użytkownika w Keycloak nie automatyzowano.
SSO autora nie ma jeszcze paczki ABI 12. Przygotowano lokalny build 4.0.0.4 z upstream `f1e157bac29e6d27a0844378f3e1d4a4fdaa577a`, .NET10 i zależnościami Jellyfin12.2. Usunięto starą referencję XML cryptography6, przypięto Pkcs10; build bez ostrzeżeń i błędów. Reprodukcja bez instalacji: `python3 compat/build-sso12.py` na Linuxie z Dockerem. Oficjalne dodatki: DLNA14, OpenSubtitles25, TVmaze14, Webhook22, SessionCleaner6. Kopia starych paczek: `/var/lib/jellyfin/decoder-plugin-backups/upgrade12-20261011T110701Z`.
Enigma7 aktywna. Obsługa nagrań Vu wyłączona w konfiguracji, lista dostawców `/Channels` pusta. Testy konsolowe obejmują także rejestrację przed utworzeniem Plugin.Instance i odpowiedzi przy Accept-Encoding gzip/br. Test API: preferencje dwóch kont, natywna lista/paginacja, uprawnienia administracyjne. Test Edge headless: 216 kanałów, ulubione, ukrywanie/przywracanie, EPG, brak błędów JS. Po aktualizacji uruchomiono pełny skan bibliotek i odświeżanie EPG.
+4
View File
@@ -59,3 +59,7 @@ The location to store your recordings on your receiver (must end with appropria
Enable VuPlus debug logging:
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.
+64
View File
@@ -0,0 +1,64 @@
using System;
using Jellyfin.Plugin.Enigma2;
using System.IO;
using System.Reflection;
using System.Security.Claims;
using System.Text.Json.Nodes;
using System.Threading;
using MediaBrowser.Common.Configuration;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
var time=DateTime.UtcNow;
void Check(bool ok,string name){if(!ok)throw new Exception(name);Console.WriteLine("PASS "+name);}
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");
Check(InternetEpg.Match(new[]{entry},entry.Title,time.AddMinutes(8))==null,"reject distant emission");
Check(InternetEpg.Match(new[]{entry,entry},entry.Title,time)==null,"reject ambiguous emission");
Check(InternetEpg.ChannelKey("TVP 2 HD")==InternetEpg.ChannelKey("TVP 2"),"HD channel alias");
Check(!InternetEpg.AllowedImage("http://127.0.0.1/a"),"reject local image URL");
Check(!InternetEpg.AllowedImage("https://ptv-prod.wphcdn.cloud.evil.example/a"),"exact artwork provider host");
Check(InternetEpg.AllowedImage(entry.Image),"real provider image");
var prefs=new ChannelPreferences{FavoriteIds=new[]{Guid.NewGuid().ToString(),"../other-user"},DefaultView="invalid"};prefs.Validate();
Check(prefs.FavoriteIds.Length==1 && prefs.DefaultView=="all","preference input validation");
var folder=Path.Combine(Path.GetTempPath(),"decoder-tests-"+Guid.NewGuid().ToString("N"));
var paths=DispatchProxy.Create<IApplicationPaths,PathsProxy>();((PathsProxy)(object)paths).Folder=folder;
try
{
Directory.CreateDirectory(folder);var config=Path.Combine(folder,"Jellyfin.Plugin.Enigma2.xml");
var registration=new ServiceCollection().AddSingleton(paths);new ServiceRegistrator().RegisterServices(registration,null);
Check(!System.Linq.Enumerable.Any(registration,x=>x.ServiceType==typeof(MediaBrowser.Controller.Channels.IChannel)),"startup before Plugin.Instance: recordings disabled by default");
File.WriteAllText(config,"<PluginConfiguration><EnableVuRecordings>true</EnableVuRecordings></PluginConfiguration>");
registration=new ServiceCollection().AddSingleton(paths);new ServiceRegistrator().RegisterServices(registration,null);
Check(System.Linq.Enumerable.Any(registration,x=>x.ServiceType==typeof(MediaBrowser.Controller.Channels.IChannel)),"startup recordings enabled from persisted configuration");
File.WriteAllText(config,"<PluginConfiguration><EnableVuRecordings>false</EnableVuRecordings></PluginConfiguration>");
Check(!ServiceRegistrator.VuRecordingsEnabled(config),"explicit recordings disabled");
var store=new PreferencesStore(paths);var user=Guid.NewGuid();var a=Guid.NewGuid().ToString("N");var b=Guid.NewGuid().ToString("N");
await store.Save(user,new ChannelPreferences{HiddenIds=new[]{a},OrderedIds=new[]{b,a}},CancellationToken.None);
var provider=new ServiceCollection().AddLogging().AddSingleton(store).BuildServiceProvider();
async System.Threading.Tasks.Task<JsonNode> Request(Guid current,string query)
{
var app=new ApplicationBuilder(provider);
new NativeChannelFilter().Configure(next=>next.Run(async ctx=>{
Check(!ctx.Request.Headers.ContainsKey("Accept-Encoding"),"native buffered JSON is uncompressed");
Check(!ctx.Request.Query.ContainsKey("Limit"),"pagination applied after native permission query");
ctx.User=new ClaimsPrincipal(new ClaimsIdentity(new[]{new Claim("Jellyfin-UserId",current.ToString())},"test"));
ctx.Response.ContentType="application/json";
await ctx.Response.WriteAsync(new JsonObject{["Items"]=new JsonArray(new JsonObject{["Id"]=a},new JsonObject{["Id"]=b}),["TotalRecordCount"]=2}.ToJsonString());
}))(app);
var context=new DefaultHttpContext{RequestServices=provider};context.Request.Headers.AcceptEncoding="gzip, br";context.Request.Method="GET";context.Request.Path="/LiveTv/Channels";context.Request.QueryString=new QueryString(query);context.Response.Body=new MemoryStream();
await app.Build()(context);Check(context.Request.Headers.AcceptEncoding=="gzip, br","restore compression request header");context.Response.Body.Position=0;return await JsonNode.ParseAsync(context.Response.Body);
}
var result=await Request(user,"?Limit=1");Check((int)result["TotalRecordCount"]==1&&(string)result["Items"][0]["Id"]==b,"native hidden and channel order");
result=await Request(user,"?Limit=0");Check(result["Items"].AsArray().Count==0&&(int)result["TotalRecordCount"]==1,"native zero-limit count");
result=await Request(Guid.NewGuid(),"?StartIndex=1&Limit=1");Check((int)result["TotalRecordCount"]==2&&(string)result["Items"][0]["Id"]==b,"native user preference isolation and pagination");
result=await Request(user,"?UserId="+Guid.NewGuid()+"&Limit=1");Check((int)result["TotalRecordCount"]==1,"query cannot replace authenticated preference owner");
result=await Request(user,"?DecoderIncludeHidden=true&Limit=2");Check((int)result["TotalRecordCount"]==2,"preference editor can restore hidden channels");
}
finally { if(Directory.Exists(folder))Directory.Delete(folder,true); }
public class PathsProxy : DispatchProxy
{
public string Folder;
protected override object Invoke(MethodInfo method,object[] args)=>(method.Name=="get_DataPath" || method.Name=="get_PluginConfigurationsPath")?Folder:null;
}
+1
View File
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework></PropertyGroup><ItemGroup><ProjectReference Include="../Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj" /></ItemGroup></Project>
+3
View File
@@ -0,0 +1,3 @@
knackebrot/jellyfin-plugin-enigma2 jellyfin branch
d38c1595d8fdf129dcdef1f78da4935cf7b3358c
CC0-1.0
+50
View File
@@ -0,0 +1,50 @@
"""Rebuild pinned upstream SSO for Jellyfin 12.2 in an isolated Docker SDK.
Run on a Linux build host with Docker. Does not install or configure the plugin.
"""
import io
import pathlib
import shutil
import subprocess
import tempfile
import urllib.request
import zipfile
UPSTREAM = "f1e157bac29e6d27a0844378f3e1d4a4fdaa577a"
root = pathlib.Path(tempfile.mkdtemp(prefix="jellyfin-sso12-"))
archive = urllib.request.urlopen(
f"https://codeload.github.com/9p4/jellyfin-plugin-sso/zip/{UPSTREAM}", timeout=90
).read()
with zipfile.ZipFile(io.BytesIO(archive)) as zipped:
for item in zipped.infolist():
relative = pathlib.PurePosixPath(item.filename).parts[1:]
if not relative or item.is_dir():
continue
target = root.joinpath(*relative)
if not target.resolve().is_relative_to(root.resolve()):
raise ValueError("Unsafe archive path")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(zipped.read(item))
for project in list(root.rglob("*.csproj")) + list(root.rglob("*.fsproj")):
text = project.read_text(encoding="utf-8-sig")
text = text.replace("net9.0", "net10.0").replace("net8.0", "net10.0")
text = text.replace('Version="10.11.0"', 'Version="12.2.0"')
text = text.replace(
'<PackageReference Include="System.Security.Cryptography.Xml" Version="6.0.1" />',
'<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.0" />',
)
project.write_text(text, encoding="utf-8")
subprocess.run(
["docker", "run", "--rm", "--cpus=2", "-v", f"{root}:/src", "-w", "/src",
"mcr.microsoft.com/dotnet/sdk:10.0", "dotnet", "build", "SSO-Auth", "-c", "Release",
"-p:CopyLocalLockFileAssemblies=true", "-o", "/src/build"], check=True,
)
package = root / "package"
package.mkdir()
# Jellyfin provides its own assemblies. Include only SSO's additional runtime dependencies.
for name in ["SSO-Auth.dll", "Lib.dll", "FSharp.Core.dll",
"Duende.IdentityModel.OidcClient.dll", "Duende.IdentityModel.dll"]:
shutil.copy2(root / "build" / name, package / name)
for license_file in root.glob("LICENSE*"):
shutil.copy2(license_file, package / license_file.name)
print(f"SSO source: {UPSTREAM}; package: {package}")
+36
View File
@@ -0,0 +1,36 @@
"""Run in CT100191 after staging DLL at /tmp/Jellyfin.Plugin.Enigma2.dll."""
import datetime,json,pathlib,shutil,sqlite3,subprocess,urllib.request
db=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True)
token=db.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];db.close()
headers={'Authorization':'MediaBrowser Token="'+token+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"'}
def get(path):
return json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096'+path,headers=headers),timeout=30))
if get('/System/Info/Public')['Version']!='12.2.0':raise SystemExit('This build requires Jellyfin 12.2.0')
if any(s.get('NowPlayingItem') for s in get('/Sessions')):raise SystemExit('Active playback: restart deferred')
plugins=pathlib.Path('/var/lib/jellyfin/plugins')
backup=pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups')/datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
backup.mkdir(parents=True,mode=0o700)
config=plugins/'configurations/Jellyfin.Plugin.Enigma2.xml'
if config.exists():shutil.copy2(config,backup/config.name)
web_config=pathlib.Path('/usr/share/jellyfin/web/config.json')
shutil.copy2(web_config,backup/'web-config.json')
old=[p for p in plugins.glob('Enigma2_*') if p.is_dir()]
new=plugins/'Enigma2_7.0.0.0'
subprocess.run(['systemctl','stop','jellyfin'],check=True)
try:
for folder in old:shutil.move(str(folder),str(backup/folder.name))
new.mkdir();shutil.copy2('/tmp/Jellyfin.Plugin.Enigma2.dll',new/'Jellyfin.Plugin.Enigma2.dll')
metadata={'category':'Live TV','description':'Enigma2 with internet EPG artwork and per-user channel preferences','guid':'193f29f9-ea6c-4595-a6f6-55e79d7c590a','name':'Enigma2','overview':'Enigma2 decoder integration','owner':'local','status':'Active','targetAbi':'12.2.0.0','version':'7.0.0.0'}
(new/'meta.json').write_text(json.dumps(metadata))
subprocess.run(['chown','-R','jellyfin:jellyfin',str(new)],check=True)
settings=json.loads(web_config.read_text());links=settings.setdefault('menuLinks',[])
if not any(x.get('name')=='Telewizja dekodera' for x in links):links.append({'name':'Telewizja dekodera','icon':'tv','url':'../DecoderIntegration/Channels'})
web_config.write_text(json.dumps(settings,indent=2)+'\n')
except Exception:
shutil.copy2(backup/'web-config.json',web_config)
if new.exists():shutil.rmtree(new)
for folder in old:
if (backup/folder.name).exists():shutil.move(str(backup/folder.name),str(folder))
raise
finally:subprocess.run(['systemctl','start','jellyfin'],check=True)
print(json.dumps({'installed':'7.0.0.0','backup':str(backup)}))
+22
View File
@@ -0,0 +1,22 @@
"""Hide the duplicate home tile without removing the provider or recording files."""
import datetime,json,os,pathlib,sqlite3,urllib.request
db=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True)
key=db.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];db.close()
def api(path,data=None):
request=urllib.request.Request('http://127.0.0.1:8096'+path,headers={'Authorization':'MediaBrowser Token="'+key+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"','Content-Type':'application/json'},data=None if data is None else json.dumps(data).encode())
body=urllib.request.urlopen(request,timeout=30).read();return json.loads(body) if body else None
channel=next(c for c in api('/Channels')['Items'] if c['Name']=='Enigma2 Recordings')
users=api('/Users');backup={u['Id']:u['Configuration'] for u in users}
folder=pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups');folder.mkdir(mode=0o700,exist_ok=True)
file=folder/('home-exclusions-'+datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')+'.json')
fd=os.open(file,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
with os.fdopen(fd,'w') as output:json.dump(backup,output)
for user in users:
config=dict(user['Configuration'])
for field in ['MyMediaExcludes','LatestItemsExcludes']:
ids=config.get(field,[])
if channel['Id'].replace('-','').lower() not in [x.replace('-','').lower() for x in ids]:config[field]=ids+[channel['Id']]
api('/Users/'+user['Id']+'/Configuration',config)
check=api('/Users/'+user['Id'])['Configuration']
assert channel['Id'] in check['MyMediaExcludes'] and channel['Id'] in check['LatestItemsExcludes']
print(json.dumps({'home_tile_hidden_for_users':len(users),'provider_retained':True,'backup':str(file)}))
+37
View File
@@ -0,0 +1,37 @@
// Isolated headless browser; temporary server account is always removed.
const {execFileSync}=require('node:child_process');
const {chromium}=require('C:/Users/michal/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const ssh=py=>execFileSync('ssh',['-o','BatchMode=yes','root@192.168.100.25','pct exec 100191 -- python3 -'],{input:py,encoding:'utf8'}).trim().split('\n').at(-1);
const setup=`import sqlite3,json,secrets,urllib.request
c=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close()
def api(p,d):
r=urllib.request.Request('http://127.0.0.1:8096'+p,headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"','Content-Type':'application/json'},data=json.dumps(d).encode());b=urllib.request.urlopen(r).read();return json.loads(b) if b else None
n='decoder-browser-check-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=api('/Users/New',{'Name':n});api('/Users/'+u['Id']+'/Password',{'CurrentPw':'','NewPw':p});print(json.dumps({'id':u['Id'],'name':n,'password':p}))
`;
(async()=>{
const user=JSON.parse(ssh(setup));let browser;
try{
browser=await chromium.launch({headless:true,channel:'msedge'});const page=await browser.newPage({viewport:{width:1280,height:900}});
const errors=[];page.on('pageerror',e=>errors.push(e.message));
const base='http://192.168.100.191:8096/web/index.html';
await page.goto(base);await page.locator('#txtManualName').fill(user.name);await page.locator('#txtManualPassword').fill(user.password);await page.getByRole('checkbox').check();await page.getByRole('button',{name:'Sign In',exact:true}).click();
await page.waitForURL(/home/,{timeout:20000});
await page.waitForFunction(()=>JSON.parse(localStorage.getItem('jellyfin_credentials')||'{}').Servers?.some(s=>s.AccessToken&&s.UserId));
await page.goto('http://192.168.100.191:8096/DecoderIntegration/Channels');
try { await page.locator('#channelRows section').first().waitFor({timeout:30000}); }
catch(error) { console.log(JSON.stringify({url:page.url(),text:await page.locator('body').innerText(),errors})); await page.screenshot({path:process.argv[2]||'decoder-channels.png'});throw error; }
const total=await page.locator('#channelRows section').count();if(total!==216)throw Error('Expected all 216 channels, got '+total);
await page.getByRole('button',{name:'☆ Ulubiony',exact:true}).first().click();
await page.locator('#channelView').selectOption('favorites');if(await page.locator('#channelRows section').count()!==1)throw Error('Favorites filter');
await page.getByRole('button',{name:'Ukryj',exact:true}).click();if(await page.locator('#channelRows section').count()!==0)throw Error('Hidden filter');
await page.locator('#channelView').selectOption('hidden');await page.getByRole('button',{name:'Pokaż',exact:true}).click();
await page.locator('#channelView').selectOption('all');await page.getByRole('button',{name:'Ustaw ten widok jako domyślny'}).click();
await page.locator('#channelView').selectOption('guide');await page.locator('#channelRows section div p').first().waitFor({timeout:60000});
await page.screenshot({path:process.argv[2]||'decoder-channels.png'});
if(errors.length)throw Error(errors.join('; '));
console.log(JSON.stringify({channel_rows:total,favorites:'PASS',hidden:'PASS',user_page:'PASS',browser_errors:errors}));
}finally{
if(browser)await browser.close();
ssh(`import sqlite3,urllib.request,pathlib\nc=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close()\nurllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Users/${user.id}',headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"'},method='DELETE')).read()\npathlib.Path('/var/lib/jellyfin/decoder-integration/users/${user.id.replace(/-/g,'')}.json').unlink(missing_ok=True)\nprint('clean')`);
}
})().catch(e=>{console.error(e.message);process.exitCode=1});
+22
View File
@@ -0,0 +1,22 @@
"""Remove LDAP plugins through Jellyfin's administrative API; keep a private rollback copy."""
import datetime,json,pathlib,shutil,sqlite3,urllib.request,urllib.error
db=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True)
key=db.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];db.close()
def api(path,method=None):
body=urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096'+path,headers={'Authorization':'MediaBrowser Token="'+key+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"'},method=method),timeout=30).read();return json.loads(body) if body else None
backup=pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups')/('ldap-removed-'+datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ'));backup.mkdir(mode=0o700)
for folder in pathlib.Path('/var/lib/jellyfin/plugins').iterdir():
if folder.is_dir() and 'ldap' in folder.name.lower():shutil.copytree(folder,backup/folder.name)
removed=[]
for plugin in api('/Plugins'):
if 'ldap' in plugin['Name'].lower():
try:api('/Plugins/'+plugin['Id']+'/'+plugin['Version'],'DELETE')
except urllib.error.HTTPError as ex:
if ex.code!=404:raise
removed.append({'name':plugin['Name'],'version':plugin['Version']})
for folder in pathlib.Path('/var/lib/jellyfin/plugins').iterdir():
if folder.is_dir() and 'ldap' in folder.name.lower():
if (backup/folder.name).exists():shutil.rmtree(folder)
else:shutil.move(str(folder),str(backup/folder.name))
assert not any('ldap' in p.name.lower() for p in pathlib.Path('/var/lib/jellyfin/plugins').iterdir() if p.is_dir())
print(json.dumps({'removed':removed,'backup':str(backup),'restart_required':True}))
+51
View File
@@ -0,0 +1,51 @@
"""Integration checks in CT100191. Creates and always removes two test users."""
import datetime,json,secrets,sqlite3,urllib.error,urllib.parse,urllib.request
db=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True)
admin=db.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];db.close()
def api(path,data=None,method=None,token=admin):
headers={'Authorization':'MediaBrowser Token="'+token+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"','Content-Type':'application/json','X-Emby-Authorization':'MediaBrowser Client="DecoderVerification", Device="IntegrationCheck", DeviceId="decoder-plugin-check", Version="1"'}
req=urllib.request.Request('http://127.0.0.1:8096'+path,data=None if data is None else json.dumps(data).encode(),headers=headers,method=method)
with urllib.request.urlopen(req,timeout=90) as response:
body=response.read();return json.loads(body) if body else None
report={'checked_utc':datetime.datetime.now(datetime.timezone.utc).isoformat()}
report['plugin']=next(x for x in api('/Plugins') if x['Name']=='Enigma2')
assert report['plugin']['Version']=='7.0.0.0' and report['plugin']['Status']=='Active'
report['epg']=api('/DecoderIntegration/Status')
report['channel_count']=api('/LiveTv/Channels?Limit=0')['TotalRecordCount']
report['timer_count']=api('/LiveTv/Timers?Limit=0')['TotalRecordCount']
users=[]
try:
tokens=[]
for i in range(2):
name='decoder-plugin-check-'+secrets.token_hex(4);password=secrets.token_urlsafe(24)
user=api('/Users/New',{'Name':name});users.append(user['Id'])
api('/Users/'+user['Id']+'/Password',{'CurrentPw':'','NewPw':password})
login=api('/Users/AuthenticateByName',{'Username':name,'Pw':password})
tokens.append(login['AccessToken'])
for token in tokens: assert api('/DecoderIntegration/Preferences',token=token)['DefaultView']=='all'
channel=api('/LiveTv/Channels?Limit=1')['Items'][0]['Id']
api('/DecoderIntegration/Preferences',{'FavoriteIds':[channel],'HiddenIds':[],'OrderedIds':[channel],'DefaultView':'favorites'},'PUT',tokens[0])
assert api('/DecoderIntegration/Preferences',token=tokens[0])['FavoriteIds']==[channel.replace('-','')]
assert api('/DecoderIntegration/Preferences',token=tokens[1])['FavoriteIds']==[]
report['per_user_preferences']='PASS'
all_channels=api('/LiveTv/Channels?Limit=2')['Items'];first,second=[c['Id'] for c in all_channels]
api('/DecoderIntegration/Preferences',{'HiddenIds':[first],'OrderedIds':[second,first]},'PUT',tokens[0])
native=api('/LiveTv/Channels?UserId='+users[0]+'&Limit=1',token=tokens[0])
assert native['TotalRecordCount']==report['channel_count']-1 and native['Items'][0]['Id']==second
assert api('/LiveTv/Channels?UserId='+users[0]+'&Limit=0',token=tokens[0])['Items']==[]
assert api('/LiveTv/Channels?UserId='+users[1]+'&Limit=0',token=tokens[1])['TotalRecordCount']==report['channel_count']
assert api('/LiveTv/Channels?UserId='+users[0]+'&DecoderIncludeHidden=true&Limit=0',token=tokens[0])['TotalRecordCount']==report['channel_count']
report['native_channel_preferences_and_pagination']='PASS'
try:api('/DecoderIntegration/Status',token=tokens[1]);raise AssertionError('non-admin status permitted')
except urllib.error.HTTPError as ex:assert ex.code==403
report['admin_status_policy']='PASS'
for token in tokens:
req=urllib.request.Request('http://127.0.0.1:8096/web/ConfigurationPage?name=decoder-channels',headers={'Authorization':'MediaBrowser Token="'+token+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"'})
text=urllib.request.urlopen(req).read().decode();assert 'channelRows' in text
report['user_channel_page']='PASS'
finally:
for user in users:
api('/Users/'+user,method='DELETE')
from pathlib import Path
Path('/var/lib/jellyfin/decoder-integration/users/'+user.replace('-','')+'.json').unlink(missing_ok=True)
print(json.dumps(report,ensure_ascii=False))