---
key: iptv.base-url
title: 2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340)
status: active
since: '2026-07-16'
supersedes: none
superseded-by: none
rule: An optional advertised base URL (`iptv.base_url`) is resolved centrally via a pure Core helper (`AdvertisedBaseUrl`) inside the two IPTV generation handlers (M3U + XMLTV); unset/malformed values fall back byte-identical to the request-derived host, and it's a new `iptv` settings group distinct from `ETV_BASE_URL` and out of scope for HDHomeRun.
signals: '`AdvertisedBaseUrl.TryParse`/`Resolve`, `ConfigElementKey.IptvBaseUrl`, request-derived-host symptom (Gitea #1) · paths: `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`, `docs/m3u-xmltv.md` · issues: #340'
mechanics: '`ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`, `GET`/`PUT /api/v1/settings/iptv`'
---

ErsatzTV's absolute IPTV URLs (M3U stream/logo/guide URLs, XMLTV `<icon>`/artwork) were always
derived from the incoming request's `Scheme`/`Host`/`PathBase`, so any client that fetched with a
host downstream consumers can't resolve (the historical Gitea #1 `localhost:8409` symptom) baked
that host into the output. #340 adds an **optional** advertised base URL to pin those URLs to a
fixed public origin. Several deliberate decisions shaped it:

**(a) Resolve the override centrally in the two generation handlers, via a pure Core helper — not
in the controller.** A new `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs` exposes `TryParse(string) →
Option<(scheme, host, baseUrl)>` and `Resolve(configured, requestScheme, requestHost,
requestBaseUrl)`. `GetChannelPlaylistHandler` (M3U, via `ChannelPlaylist`) and
`GetChannelGuideHandler` (both XMLTV `{RequestBase}` substitution sites) call `Resolve` and use its
result instead of the raw request values. Keeping the logic in a pure, allocation-free Core helper
(not the thin controller) keeps controllers dumb, makes the parse/validate/resolve rules unit-testable
in isolation, and — because the fallback path returns the exact request-derived values — leaves the
M3U/XMLTV golden tests (`ChannelPlaylistGoldenTests`, `ChannelGuideGoldenTests`) untouched.

**(b) Validation rules, with blank/invalid → request-derived so "unset" is byte-identical.**
`TryParse` accepts only an **absolute http(s)** URL with **no credentials, query, or fragment**; it
**preserves the port and any path prefix** and **normalizes a trailing slash** off. Anything failing
these rules → `None`, and `Resolve` then falls back to the request-derived scheme/host/base. So an
unset or malformed value produces output byte-for-byte identical to today's request-derived behaviour
— the override is strictly opt-in and can never silently corrupt the default path.

**(c) A NEW `iptv` settings group, not folded under `xmltv`.** The base URL affects **both** the M3U
playlist and the XMLTV guide, so it does not belong under the existing XMLTV-only settings. `GET`/`PUT
/api/v1/settings/iptv` on `SettingsController` (tier `[RequiresAuthentication]`) with body `{ baseUrl }`,
backed by `GetIptvSettings`/`UpdateIptvSettings` handlers + `IptvSettingsViewModel` /
`IptvSettingsResponseModel` / `UpdateIptvSettingsRequest`, and a new "IPTV" section on the SPA Settings
screen. Follows the existing settings GET/PUT pattern — no new API convention.

**(d) Blank clears the key; non-blank malformed → 422.** A blank/whitespace `baseUrl` on PUT
**deletes** the `ConfigElement` (reverting to request-derived); a non-blank value that fails
`AdvertisedBaseUrl.TryParse` is rejected with **422** rather than being silently stored and ignored at
generation time — the error surfaces at the point of configuration.

**(e) Scoped to M3U + XMLTV, deliberately NOT HDHomeRun.** #340 covers only the M3U playlist and XMLTV
guide generators. The HDHomeRun lineup URLs still echo the request host; extending the override there
was explicitly out of scope.

**(f) Distinct from `ETV_BASE_URL`.** The `ETV_BASE_URL` environment variable only sets the ASP.NET
Core `PathBase` (request routing/prefix); it does not advertise a scheme+host. `iptv.base_url` is the
separate, DB-stored (`ConfigElementKey.IptvBaseUrl`, **no EF migration**) advertised origin for IPTV
output. See `docs/m3u-xmltv.md` → "IPTV base URL (#340)".
