using System.Collections.Immutable; using System.IO.Abstractions; using System.Security; using System.Text; using System.Text.RegularExpressions; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Iptv; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.IO; namespace ErsatzTV.Application.Channels; public partial class GetChannelGuideHandler( IDbContextFactory dbContextFactory, RecyclableMemoryStreamManager recyclableMemoryStreamManager, IFileSystem fileSystem, ILocalFileSystem localFileSystem, IConfigElementRepository configElementRepository) : IRequestHandler> { public async Task> Handle( GetChannelGuide request, CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); Option maybeBaseUrl = await configElementRepository.GetValue(ConfigElementKey.IptvBaseUrl, cancellationToken); (string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve( maybeBaseUrl.IfNone(string.Empty), request.Scheme, request.Host, request.BaseUrl); // The cache fragments are pre-built XML written raw (like {AccessTokenUri}, which is already // emitted as &), so the substituted base must be XML-escaped. A path prefix can legally // contain '&' (Uri keeps it out of the query), which would otherwise emit a bare '&' and // malform the whole guide. Normal URLs have no special chars, so this is a no-op for them. string requestBase = SecurityElement.Escape($"{scheme}://{host}{baseUrl}"); var hiddenChannelNumbers = dbContext.Channels .Where(c => c.ShowInEpg == false) .Select(c => c.Number) .AsEnumerable() .Select(n => $"{n}.xml") .ToImmutableHashSet(); string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml"); if (!fileSystem.File.Exists(channelsFile)) { return BaseError.New($"Required file {channelsFile} is missing"); } long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks; var accessTokenUri = $"?v={mtime}"; if (!string.IsNullOrWhiteSpace(request.AccessToken)) { // The token value is HTTP-request-derived and interpolated raw into the pre-built XMLTV // cache fragments, so it must be XML-escaped like {RequestBase} above — a token containing // '&', '<', '>', or '"' would otherwise malform the whole guide. Opaque tokens are a no-op. accessTokenUri += $"&access_token={SecurityElement.Escape(request.AccessToken)}"; } string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken); // TODO: is regex faster? channelsFragment = channelsFragment .Replace("{RequestBase}", requestBase) .Replace("{AccessTokenUri}", accessTokenUri); var channelDataFragments = new Dictionary(); foreach (string fileName in localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)) { if (fileName.Contains("channels")) { continue; } if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName))) { continue; } try { string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken); channelDataFragment = channelDataFragment .Replace("{RequestBase}", requestBase) .Replace("{AccessTokenUri}", accessTokenUri); channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty); channelDataFragments.Add(fileSystem.Path.GetFileNameWithoutExtension(fileName), channelDataFragment); } catch (FileNotFoundException) { // ignore this channel fragment } catch (IOException) { // ignore this channel fragment } } return new ChannelGuide(recyclableMemoryStreamManager, channelsFragment, channelDataFragments); } private async Task ReadAllTextShared(string fileName, CancellationToken cancellationToken) { await using var stream = fileSystem.FileStream.New( fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); return await reader.ReadToEndAsync(cancellationToken); } [GeneratedRegex(@"]+?>.*?<\/etv:[^>]+?>|]+?\/>", RegexOptions.Singleline)] private static partial Regex EtvTagRegex(); }