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
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user