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>
70 lines
2.3 KiB
C#
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;
|
|
}
|
|
}
|