feat(iptv): add configurable advertised base URL for M3U/XMLTV (#340) #374

Merged
timothy merged 2 commits from feat/340-advertised-base-url into main 2026-07-16 21:51:28 +02:00
Owner

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

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)
timothy force-pushed feat/340-advertised-base-url from e232849cad to 27d01db265 2026-07-16 21:09:20 +02:00 Compare
Author
Owner

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 @ 27d01db265

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
timothy closed this pull request 2026-07-16 21:29:53 +02:00
timothy reopened this pull request 2026-07-16 21:29:58 +02:00
timothy merged commit 9938d9a6cd into main 2026-07-16 21:51:28 +02:00
timothy deleted branch feat/340-advertised-base-url 2026-07-16 21:51:29 +02:00
Sign in to join this conversation.