Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m45s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m27s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m10s
Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com>
130 lines
5.2 KiB
C#
130 lines
5.2 KiB
C#
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<TvContext> dbContextFactory,
|
|
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
|
IFileSystem fileSystem,
|
|
ILocalFileSystem localFileSystem,
|
|
IConfigElementRepository configElementRepository)
|
|
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
|
{
|
|
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
|
GetChannelGuide request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
|
|
Option<string> maybeBaseUrl =
|
|
await configElementRepository.GetValue<string>(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 lands in a URL query value inside an XMLTV attribute, so it needs BOTH layers:
|
|
// percent-encode first (#421 — a token with '&' would otherwise split the query and truncate
|
|
// the token once the consumer URL-decodes the attribute; mirrors the M3U fix), then XML-escape
|
|
// the result so it can't malform the guide (#376). Both are no-ops for an opaque base64url token.
|
|
accessTokenUri += $"&access_token={SecurityElement.Escape(Uri.EscapeDataString(request.AccessToken))}";
|
|
}
|
|
|
|
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
|
|
|
|
// TODO: is regex faster?
|
|
channelsFragment = channelsFragment
|
|
.Replace("{RequestBase}", requestBase)
|
|
.Replace("{AccessTokenUri}", accessTokenUri);
|
|
|
|
var channelDataFragments = new Dictionary<string, string>();
|
|
|
|
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<string> 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:[^>]+?>.*?<\/etv:[^>]+?>|<etv:[^>]+?\/>", RegexOptions.Singleline)]
|
|
private static partial Regex EtvTagRegex();
|
|
}
|