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; /// Enrich receiver EIT without replacing its timer identifiers or schedule. public sealed class InternetEpg { public static InternetEpg Instance { get; private set; } private readonly IHttpClientFactory factory; private readonly ILogger logger; private readonly SemaphoreSlim gate = new(1); private Dictionary> entries = new(); private Dictionary 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 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 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>(); 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 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++; } } }