Adds an optional advertised IPTV base URL so operators can pin M3U/XMLTV absolute URLs to a fixed public origin instead of whatever Host the incoming request carried. This is the mitigation for the Dispatcharr→Kodi topology where ErsatzTV emits Docker-internal hostnames that downstream clients can't resolve (see #340 / server-management#501).
How
ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs — a pure helper. TryParse validates an absolute http(s) URL with no credentials/query/fragment, preserves port + path prefix, and normalizes a trailing slash → Option<(scheme, host, baseUrl)>. Resolve returns the parsed override or falls back to the request-derived values when blank/invalid.
Resolution happens inside the two generators (controllers stay thin): GetChannelPlaylistHandler (M3U guide/logo/stream) and GetChannelGuideHandler (both XMLTV {RequestBase} sites). Because the fallback returns the exact request values, the M3U + XMLTV golden tests are unchanged — unset output is byte-for-byte identical.
Backed by the existing ConfigElement store — new key iptv.base_url (ConfigElementKey.IptvBaseUrl), no EF migration.
New iptv settings group: GET/PUT /api/v1/settings/iptv ([RequiresAuthentication]), body { baseUrl }. Blank clears the key; a non-blank malformed value → 422. New "IPTV" section on the SPA Settings screen.
Deliberately scoped to M3U + XMLTV; HDHomeRun lineup URLs are out of scope. Distinct from ETV_BASE_URL (which only sets ASP.NET PathBase).
SPA client + screen tests. Full C# suites (Core.Tests 537, ErsatzTV.Tests 1603) and web suite (782) green locally; dotnet format whitespace clean on touched files.
Docs
docs/m3u-xmltv.md (the old "optional future hardening" section is now "IPTV base URL (#340)"), docs/decisions.md (append-only entry), docs/domain-model.md, and regenerated v1.json / v1.d.ts / endpoint-index.md.
Remaining before merge (per the issue's ## Done-when): live-E2E of generated M3U/XMLTV URLs with a configured base + adversarial review.
Fixes #340.
## What
Adds an optional **advertised IPTV base URL** so operators can pin M3U/XMLTV absolute URLs to a fixed public origin instead of whatever `Host` the incoming request carried. This is the mitigation for the Dispatcharr→Kodi topology where ErsatzTV emits Docker-internal hostnames that downstream clients can't resolve (see #340 / server-management#501).
## How
- **`ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`** — a pure helper. `TryParse` validates an absolute http(s) URL with no credentials/query/fragment, preserves port + path prefix, and normalizes a trailing slash → `Option<(scheme, host, baseUrl)>`. `Resolve` returns the parsed override or **falls back to the request-derived values** when blank/invalid.
- Resolution happens **inside the two generators** (controllers stay thin): `GetChannelPlaylistHandler` (M3U guide/logo/stream) and `GetChannelGuideHandler` (both XMLTV `{RequestBase}` sites). Because the fallback returns the exact request values, the M3U + XMLTV **golden tests are unchanged** — unset output is byte-for-byte identical.
- Backed by the existing `ConfigElement` store — new key `iptv.base_url` (`ConfigElementKey.IptvBaseUrl`), **no EF migration**.
- New **`iptv` settings group**: `GET`/`PUT /api/v1/settings/iptv` (`[RequiresAuthentication]`), body `{ baseUrl }`. Blank clears the key; a non-blank malformed value → **422**. New "IPTV" section on the SPA Settings screen.
- Deliberately scoped to **M3U + XMLTV**; HDHomeRun lineup URLs are out of scope. Distinct from `ETV_BASE_URL` (which only sets ASP.NET `PathBase`).
## Tests
- `AdvertisedBaseUrlTests` — override, fallback, port, path prefix, trailing-slash normalization, IPv6, and invalid-input rejection (credentials/query/fragment/relative/non-http).
- Handler override tests for both generators (`GetChannelPlaylistHandlerTests` + a new case in `ChannelGuideGoldenTests`).
- `SettingsController` + `GetIptvSettings`/`UpdateIptvSettings` handler tests (map/422/upsert/delete-on-blank).
- SPA client + screen tests. Full C# suites (Core.Tests 537, ErsatzTV.Tests 1603) and web suite (782) green locally; `dotnet format whitespace` clean on touched files.
## Docs
`docs/m3u-xmltv.md` (the old "optional future hardening" section is now "IPTV base URL (#340)"), `docs/decisions.md` (append-only entry), `docs/domain-model.md`, and regenerated `v1.json` / `v1.d.ts` / `endpoint-index.md`.
> Remaining before merge (per the issue's `## Done-when`): live-E2E of generated M3U/XMLTV URLs with a configured base + adversarial review.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Round 1 (Codex, cross-model) — found one Medium correctness bug: AdvertisedBaseUrl.TryParse accepts a path prefix containing & (a legal URL-path char kept out of uri.Query), but GetChannelGuideHandler substituted {RequestBase} raw into pre-built XML written unescaped → a bare & malforms the whole XMLTV guide. Fixed in 27d01db2 by XML-escaping the substituted base with SecurityElement.Escape (mirroring the already-pre-escaped {AccessTokenUri}); M3U left unescaped (not XML); regression test added. No-op for normal URLs → goldens byte-identical.
Round 2 (cold-context re-review of the fix commit) — confirmed the fix is correct (escaping applied at both .Replace sites via one variable) and complete (only {RequestBase} + {AccessTokenUri} are raw-substituted; everything else is encoded at cache-build), goldens unchanged (40 IPTV tests pass unregenerated), M3U genuinely safe (uri.AbsolutePath percent-encodes "/space/control chars), and UpdateIptvSettingsHandler null-safe + 422-correct.
Non-blocking, pre-existing (NOT introduced by #340): the access_token query value is interpolated raw into the XMLTV {AccessTokenUri} — a token containing &/</" would malform the guide. Same code before this PR; filed as a follow-up.
CI green (Build & test, Formatting, API-docs-in-sync, migration integrity, Functional-E2E). Live-E2E verified: settings write-path round-trips through the read path; M3U guide/logo/stream URLs use the configured base; blank reverts to request host byte-for-byte; invalid → 422 not persisted; generated logo image reachable (200 image/png).
Independent review complete (two rounds).
**Round 1 (Codex, cross-model)** — found one Medium correctness bug: `AdvertisedBaseUrl.TryParse` accepts a path prefix containing `&` (a legal URL-path char kept out of `uri.Query`), but `GetChannelGuideHandler` substituted `{RequestBase}` raw into pre-built XML written unescaped → a bare `&` malforms the whole XMLTV guide. Fixed in `27d01db2` by XML-escaping the substituted base with `SecurityElement.Escape` (mirroring the already-pre-escaped `{AccessTokenUri}`); M3U left unescaped (not XML); regression test added. No-op for normal URLs → goldens byte-identical.
**Round 2 (cold-context re-review of the fix commit)** — confirmed the fix is correct (escaping applied at both `.Replace` sites via one variable) and complete (only `{RequestBase}` + `{AccessTokenUri}` are raw-substituted; everything else is encoded at cache-build), goldens unchanged (40 IPTV tests pass unregenerated), M3U genuinely safe (`uri.AbsolutePath` percent-encodes `"`/space/control chars), and `UpdateIptvSettingsHandler` null-safe + 422-correct.
Non-blocking, pre-existing (NOT introduced by #340): the `access_token` query *value* is interpolated raw into the XMLTV `{AccessTokenUri}` — a token containing `&`/`<`/`"` would malform the guide. Same code before this PR; filed as a follow-up.
CI green (Build & test, Formatting, API-docs-in-sync, migration integrity, Functional-E2E). Live-E2E verified: settings write-path round-trips through the read path; M3U guide/logo/stream URLs use the configured base; blank reverts to request host byte-for-byte; invalid → 422 not persisted; generated logo image reachable (200 image/png).
Review-verdict: MERGEABLE @ 27d01db26582922d908c1b58d2369d52104e7895
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes #340.
What
Adds an optional advertised IPTV base URL so operators can pin M3U/XMLTV absolute URLs to a fixed public origin instead of whatever
Hostthe incoming request carried. This is the mitigation for the Dispatcharr→Kodi topology where ErsatzTV emits Docker-internal hostnames that downstream clients can't resolve (see #340 / server-management#501).How
ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs— a pure helper.TryParsevalidates an absolute http(s) URL with no credentials/query/fragment, preserves port + path prefix, and normalizes a trailing slash →Option<(scheme, host, baseUrl)>.Resolvereturns the parsed override or falls back to the request-derived values when blank/invalid.GetChannelPlaylistHandler(M3U guide/logo/stream) andGetChannelGuideHandler(both XMLTV{RequestBase}sites). Because the fallback returns the exact request values, the M3U + XMLTV golden tests are unchanged — unset output is byte-for-byte identical.ConfigElementstore — new keyiptv.base_url(ConfigElementKey.IptvBaseUrl), no EF migration.iptvsettings group:GET/PUT /api/v1/settings/iptv([RequiresAuthentication]), body{ baseUrl }. Blank clears the key; a non-blank malformed value → 422. New "IPTV" section on the SPA Settings screen.ETV_BASE_URL(which only sets ASP.NETPathBase).Tests
AdvertisedBaseUrlTests— override, fallback, port, path prefix, trailing-slash normalization, IPv6, and invalid-input rejection (credentials/query/fragment/relative/non-http).GetChannelPlaylistHandlerTests+ a new case inChannelGuideGoldenTests).SettingsController+GetIptvSettings/UpdateIptvSettingshandler tests (map/422/upsert/delete-on-blank).dotnet format whitespaceclean on touched files.Docs
docs/m3u-xmltv.md(the old "optional future hardening" section is now "IPTV base URL (#340)"),docs/decisions.md(append-only entry),docs/domain-model.md, and regeneratedv1.json/v1.d.ts/endpoint-index.md.🤖 Generated with Claude Code
e232849cadto27d01db265Independent review complete (two rounds).
Round 1 (Codex, cross-model) — found one Medium correctness bug:
AdvertisedBaseUrl.TryParseaccepts a path prefix containing&(a legal URL-path char kept out ofuri.Query), butGetChannelGuideHandlersubstituted{RequestBase}raw into pre-built XML written unescaped → a bare&malforms the whole XMLTV guide. Fixed in27d01db2by XML-escaping the substituted base withSecurityElement.Escape(mirroring the already-pre-escaped{AccessTokenUri}); M3U left unescaped (not XML); regression test added. No-op for normal URLs → goldens byte-identical.Round 2 (cold-context re-review of the fix commit) — confirmed the fix is correct (escaping applied at both
.Replacesites via one variable) and complete (only{RequestBase}+{AccessTokenUri}are raw-substituted; everything else is encoded at cache-build), goldens unchanged (40 IPTV tests pass unregenerated), M3U genuinely safe (uri.AbsolutePathpercent-encodes"/space/control chars), andUpdateIptvSettingsHandlernull-safe + 422-correct.Non-blocking, pre-existing (NOT introduced by #340): the
access_tokenquery value is interpolated raw into the XMLTV{AccessTokenUri}— a token containing&/</"would malform the guide. Same code before this PR; filed as a follow-up.CI green (Build & test, Formatting, API-docs-in-sync, migration integrity, Functional-E2E). Live-E2E verified: settings write-path round-trips through the read path; M3U guide/logo/stream URLs use the configured base; blank reverts to request host byte-for-byte; invalid → 422 not persisted; generated logo image reachable (200 image/png).
Review-verdict: MERGEABLE @
27d01db265