Files
ersatztv/ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs
T
timothyandClaude Opus 4.8 3bc8192d3b feat(iptv): add configurable advertised base URL for M3U/XMLTV (fixes #340)
ErsatzTV built every absolute M3U/XMLTV URL from the incoming request's
Scheme/Host/PathBase, so a client fetching via a host that downstream
consumers can't resolve (e.g. Dispatcharr over Docker DNS → Kodi) baked
that internal host into programme-image/stream URLs.

Add an optional advertised IPTV base URL, backed by the existing
ConfigElement key/value store (key `iptv.base_url`, no EF migration):

- Central pure Core helper `AdvertisedBaseUrl` (TryParse/Resolve):
  validates absolute http(s), no credentials/query/fragment, preserves
  port + path prefix, normalizes trailing slash. Blank/invalid falls
  back to the request-derived values, so unset output is byte-identical.
- Resolved inside `GetChannelPlaylistHandler` (M3U guide/logo/stream) and
  `GetChannelGuideHandler` (both XMLTV {RequestBase} sites) — controllers
  stay thin, golden tests untouched.
- New `iptv` settings group: GET/PUT /api/v1/settings/iptv (blank clears,
  malformed → 422) + a new IPTV section on the SPA Settings screen.
- Scoped to M3U + XMLTV; HDHomeRun deliberately out of scope. Distinct
  from ETV_BASE_URL (which only sets ASP.NET PathBase).

Tests: AdvertisedBaseUrl unit tests (override/fallback/port/path/invalid),
handler override tests for both generators, settings controller + handler
tests, SPA client + screen tests. Docs: m3u-xmltv, decisions, domain-model,
regenerated OpenAPI v1.json + v1.d.ts + endpoint-index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:08:42 +02:00

70 lines
2.3 KiB
C#

using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Channels;
public class GetChannelPlaylistHandler(
IChannelRepository channelRepository,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
{
public async Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken 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);
List<Channel> channels = EnsureMode(await channelRepository.GetAll(cancellationToken), request.Mode);
return new ChannelPlaylist(
scheme,
host,
baseUrl,
channels,
request.UserAgent,
request.AccessToken);
}
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
{
var result = new List<Channel>();
foreach (Channel channel in channels)
{
if (!channel.IsEnabled)
{
continue;
}
switch (mode.ToLowerInvariant())
{
case "segmenter":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
result.Add(channel);
break;
case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel);
break;
case "ts-legacy":
channel.StreamingMode = StreamingMode.TransportStream;
result.Add(channel);
break;
case "ts":
channel.StreamingMode = StreamingMode.TransportStreamHybrid;
result.Add(channel);
break;
default:
result.Add(channel);
break;
}
}
return result;
}
}