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>
+11 -6
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
@@ -812,7 +816,7 @@ namespace Jellyfin.Plugin.Enigma2
/// <summary>
/// Cancel pending scheduled Recording
/// Cancel pending scheduled Recording
/// </summary>
/// <param name="timerId">The timerId</param>
/// <param name="cancellationToken">The cancellationToken</param>
@@ -1145,7 +1149,7 @@ namespace Jellyfin.Plugin.Enigma2
}
var baseUrl = protocol + "://" + Plugin.Instance.Configuration.HostName + ":" + streamingPort;
//check if we need to zap to channel - single tuner
if (Plugin.Instance.Configuration.ZapToChannel)
{
@@ -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;
}
}
}