Files
ersatztv/ErsatzTV.Core/Iptv/AdvertisedBaseUrl.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

68 lines
2.7 KiB
C#

namespace ErsatzTV.Core.Iptv;
// Central resolution/validation for the optional advertised IPTV base URL (issue #340).
//
// ErsatzTV builds every absolute M3U/XMLTV URL from the incoming request's scheme + host + PathBase.
// When a downstream consumer fetches ErsatzTV via a host that other consumers can't resolve (e.g.
// Dispatcharr fetching over Docker DNS, then Kodi receiving those internal hostnames), the emitted
// URLs break. An operator can configure an advertised base URL to override those request-derived
// values consistently across the M3U (guide/logo/stream) and XMLTV ({RequestBase}) surfaces.
//
// When the configured value is blank or invalid, resolution falls back to the request-derived values
// so today's behavior is preserved byte-for-byte.
public static class AdvertisedBaseUrl
{
// Returns the effective (scheme, host, baseUrl) to use for absolute IPTV URLs. When the configured
// value is blank or invalid, returns the request-derived values unchanged.
public static (string Scheme, string Host, string BaseUrl) Resolve(
string configured,
string requestScheme,
string requestHost,
string requestBaseUrl) =>
TryParse(configured).Match(
Some: parsed => parsed,
None: () => (requestScheme, requestHost, requestBaseUrl));
// Validates + normalizes an advertised base URL. None => blank or invalid. A valid value is an
// absolute http(s) URL with no credentials, query, or fragment; an optional port and path prefix
// are preserved, and a trailing slash is normalized away (so a root "/" yields an empty base, and
// "/etv/" yields "/etv" — matching the PathBase convention the URL builders concatenate).
public static Option<(string Scheme, string Host, string BaseUrl)> TryParse(string configured)
{
if (string.IsNullOrWhiteSpace(configured))
{
return None;
}
if (!Uri.TryCreate(configured.Trim(), UriKind.Absolute, out Uri uri))
{
return None;
}
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
{
return None;
}
if (!string.IsNullOrEmpty(uri.UserInfo))
{
return None;
}
if (!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment))
{
return None;
}
if (string.IsNullOrEmpty(uri.Host))
{
return None;
}
// Uri.Authority is host[:port] (omitting a redundant default port, wrapping IPv6 in brackets)
// and excludes any userinfo — exactly the "{host}" the URL builders expect.
string baseUrl = uri.AbsolutePath.TrimEnd('/');
return (uri.Scheme, uri.Authority, baseUrl);
}
}