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.8 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| graphics.channel-logo-caching | 2026-07-21 — External channel-logo URLs are downloaded and cached at save time; the render path never fetches a logo (#525) | active | 2026-07-21 | none | none | An external `http(s)` channel-logo URL is fetched, decode-budget-validated, and stored in the image cache under a content-hash name at SAVE time — becoming byte-identical to an uploaded logo — so the render path never fetches a logo over HTTP; a bad URL fails the save with a 422 (BaseError → ValidationProblemDetails). | channel logo url · watermark on-screen bug · paths: `ErsatzTV.Infrastructure/Images/RemoteLogoCacher.cs`, `ErsatzTV.Core/Images/RemoteImageDecodeBudget.cs`, `ErsatzTV.Application/Channels/Commands/*ChannelHandler*.cs`, `ErsatzTV/Services/RunOnce/ExternalLogoMigratorService.cs` · issues: #525, #511, #502 | `docs/channels.md` → Channel logo & on-screen bug; `docs/api-conventions.md` → error mapping |
ImageElementBase.LoadImage used to fetch an external-URL logo over HTTP inside stream startup,
once per playout item. #511 bounded that fetch (timeout, wire cap, redirect cap, decode budgets) but
left it in the render path, where a dead/slow/oversized/non-image URL surfaces only as a render-time
log line — invisible to the operator who typed it, and re-paid every playout-item transition.
A URL is now an input method, not a storage format. On save, IRemoteLogoCacher fetches the URL
(reusing #511's hardened IRemoteImageFetcher), validates it against the shared
RemoteImageDecodeBudget (via IRemoteImageValidator), and writes the bytes through
IImageCache.SaveArtworkToCache, storing the returned MD5 content-hash name in Artwork.Path. After
a successful save the logo is indistinguishable from an uploaded one, so every downstream consumer
is unchanged — M3U, XMLTV and the SPA mapper all resolve Artwork.Path to an /iptv/logos/… URL,
and the render path finds a local cached file. To refresh a changed remote image the operator
re-enters the URL; there is deliberately no refresh button and no staleness/ETag tracking (the
content hash makes a re-add of unchanged bytes a natural no-op and of changed bytes a natural new
name).
Content-hash name, not a GUID. SaveArtworkToCache already returns an opaque MD5-of-bytes name
identical to the upload path, so there is one cache convention rather than two — and dedup + change
detection fall out for free. A GUID would deviate for no benefit.
The decode budget now guards uploads too. The byte/wire cap does not bound decoding, so the same
RemoteImageDecodeBudget (product of width × height × frames ≤ 50 MP, ≤ 600 frames, enforced on
the decoder via DecoderOptions.MaxFrames and re-verified against the decoded image — header frame
counts lie, see #511) is applied at BOTH the URL-download path and UploadArtworkHandler. One rule:
anything entering the logo cache is budget-checked, however it arrived. This closes a pre-existing
gap that this feature would otherwise have widened (a URL logo becoming an unchecked upload).
Narrows ffmpeg.remote-image-fetcher-bounded (#511) and ffmpeg.external-logo-graphics-engine
(#502), does not reverse either. #511's IRemoteImageFetcher bounded-fetch primitive and its "not
cached, re-fetched per element init" statement REMAIN active for operator-authored YAML image:
graphics elements, which still legitimately fetch a URL at render time — only channel logos moved to
save-time caching. #502's "external artwork passes through, it is not downloaded into the image
cache" still describes the CLIENT-facing consumers (M3U/XMLTV/SPA emit whatever Artwork.Path
resolves to) — now a cache URL rather than the raw external URL, because the row no longer holds a
URL. So neither predecessor is superseded (both stay active); this is a new decision layered on
top, hence supersedes: none — not a keyed supersession.
Existing rows migrate at startup, fail-open. ExternalLogoMigratorService (a run-once
BackgroundService, after the schema migrator + DB cleaner) downloads existing URL logo rows into
the cache; a row whose download fails is left exactly as-is with a warning naming it, and
WatermarkSelector.ChannelLogoWatermarkOptions degrades such a leftover URL to "no on-screen bug"
(a warning, never a render-time fetch). The migration is idempotent by construction — a converted
row's path is no longer a URL, so a second pass selects it out — and all-or-nothing on cancel (a
single trailing SaveChangesAsync).
Accepted residual: the SPA can render a not-yet-migrated external-URL logo as an <img> preview
that looks working while the server-side bug won't resolve until the row is re-saved/migrated — a
narrow transitional-state cosmetic mismatch, since the startup migration eagerly converts old rows.