Files
ersatztv/docs/decisions/records/iptv/base-url.md
T
timothy fba5233caf
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(610): split the decision corpus into one YAML-frontmatter file per record
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and
docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key,
so one-active-record-per-key becomes a filesystem property rather than a
validator check, and supersession becomes a `git mv`.

WHY: the monolith was a concurrency problem before an aesthetic one. A
3,900-line append target made parallel sessions collide -- PR #605 and PR #614
both hit append-vs-append conflicts during routine rebases, and hand-resolving
those inside the corpus is exactly the operation the rationale-rewrite guard
exists to police.

HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness
does not rest on reading it. The parser was taught BOTH formats first, so the
body-diff guard parses the old form at the merge-base and the new form at head --
the migration validates itself, no bypass. The proof is a field-level equivalence
harness: 168 records before and after, zero lost, zero gained, zero field
mismatches, zero rationale bodies differing. Reviewers should scrutinise the
harness; it is the actual evidence.

What measuring caught that reading would not have:

- ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each
  topic file's preamble, mostly the only copy. Source files are kept and
  stripped, never deleted. They also cannot be filed per-area: topic files hold
  several areas and 4 of 23 areas span several files.
- Archive discovery was a non-recursive glob; after the split it found ZERO
  archived records, surfacing as four bogus "supersedes points to unknown key"
  errors rather than an obvious failure.
- ~32 live docs point into the corpus BY DATE, which the split dangles. Each
  stripped file now ends with a generated "Records formerly in this file" index,
  which also rescues the identical breadcrumbs in old issue comments.
- decisions.md's "In this file:" list was 97 same-file anchor bullets that the
  split makes WRONG, not merely stale. Dropped; the generated index replaces
  them with links that resolve.

The equivalence harness now runs against a checked-in FIXTURE, not the live
corpus. The earlier version migrated the real tree, which made it a one-shot:
the moment the migration landed there was nothing left to move and the tests
failed for reasons unrelated to the code. A fixture keeps them testing the
SCRIPT rather than the repo's current state.

Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain
two directories for one concept. Renaming a key is not a move -- it changes
identity, breaks the equivalence proof, and invalidates MemPalace's per-key
drawers. Taxonomy normalisation is separate work.

refs #610
2026-07-25 19:45:09 +02:00

4.2 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
iptv.base-url 2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340) active 2026-07-16 none none 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. `AdvertisedBaseUrl.TryParse`/`Resolve`, `ConfigElementKey.IptvBaseUrl`, request-derived-host symptom (Gitea #1) · paths: `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`, `docs/m3u-xmltv.md` · issues: #340 `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)".