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
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)".