Integrate native decoder TV and internet EPG artwork with Jellyfin 12.2
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
knackebrot/jellyfin-plugin-enigma2 jellyfin branch
|
||||
d38c1595d8fdf129dcdef1f78da4935cf7b3358c
|
||||
CC0-1.0
|
||||
@@ -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}")
|
||||
@@ -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)}))
|
||||
@@ -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)}))
|
||||
@@ -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});
|
||||
@@ -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}))
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user