Files
ersatztv/docs/decisions/records/graphics/channel-logo-caching.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.8 KiB
Raw Blame History

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.