Integrate native decoder TV and internet EPG artwork with Jellyfin 12.2
This commit is contained in:
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user