Files

97 lines
5.9 KiB
C#

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++;
}
}
}