docs(525): record download-on-save decision; update channels + api-conventions

New lifecycle record graphics.channel-logo-caching (supersedes: none — narrows
#511/#502 rather than reversing them; #511's bounded-fetch primitive stays active
for YAML image elements). Regenerated the active catalog. channels.md + api-
conventions.md updated for the new save-time download + 400s.
This commit is contained in:
2026-07-21 12:04:34 +02:00
parent a96b69b7b9
commit 6d5f6b2489
4 changed files with 86 additions and 10 deletions
+17 -6
View File
@@ -282,12 +282,23 @@ mappers) for new API DTOs — those still return the old Blazor-convention relat
the domain/VM directly and root the path yourself, following the PR #181 pattern.
Channel **logos** live under a different route than posters/thumbnails: an uploaded logo roots to
`/iptv/logos/{file}` (served by `IptvController`), and an external logo is an absolute URL passed
through unchanged. Browse-surface DTOs (`ChannelResponseModel` list, `ChannelGuideChannelResponseModel`
guide) get this rooted `Logo` URL from the single `Channels.Mapper.GetLogoUrl` helper (#464), which
returns `null` when the channel has no logo so the SPA falls back to its generated initials icon. The
raw un-rooted `{path, contentType}` form is still used only by the channel **editor** DTO
(`ChannelDetailResponseModel.Logo`), which round-trips it back on save.
`/iptv/logos/{file}` (served by `IptvController`). An **external logo URL is no longer stored as a URL** —
since #525, `PUT`/`POST /api/v1/channels…` downloads it, decode-validates it, and caches it at save
time, so `Artwork.Path` holds a content-hash name and the browse/guide DTOs emit an `/iptv/logos/…`
URL exactly as for an uploaded logo. Browse-surface DTOs (`ChannelResponseModel` list,
`ChannelGuideChannelResponseModel` guide) get this rooted `Logo` URL from the single
`Channels.Mapper.GetLogoUrl` helper (#464), which returns `null` when the channel has no logo so the
SPA falls back to its generated initials icon. The raw un-rooted `{path, contentType}` form is still
used only by the channel **editor** DTO (`ChannelDetailResponseModel.Logo`), which round-trips it back
on save.
**New 400s from the logo download (#525).** `PUT /api/v1/channels/{id}`, the two channel-create
endpoints, and `POST /api/v1/artwork/uploads` now return a **400** (`BaseError` → `ProblemDetails`)
when a supplied logo cannot be used: an external URL that is unreachable, times out (>10s), is
oversized (>10 MiB), is not an image, or is a decode bomb (over 50 MP total pixels or 600 frames);
an upload gets the same decode-budget check. The message names the reason
(e.g. *"Could not download logo from … : …"*, *"Image cannot be used: … pixel limit"*). Response
**shapes are unchanged** — only the error status/message set — so the OpenAPI models did not change.
`GET /api/v1/watermarks` returns picker-grade rows that carry `imageSource` alongside `id`/`name`
(#67), so a client can find the seeded logo-driven `Channel Bug` preset without matching its
+13 -4
View File
@@ -164,10 +164,19 @@ the system channel templates it creates, so the library-to-lineup builder (which
are left alone, so builder-created and auto-tuned channels there inherit whatever the template
already specifies.
**Limitation:** a logo set via **External logo URL** cannot drive the bug. `WatermarkSelector`
resolves it to the URL and then `File.Exists`-checks it, which is never true, so the watermark is
silently dropped — the URL wins for the guide listing but disables the on-screen bug. Tracked as
**#502**; the editor does not offer a bug preview in that case.
**An external logo URL drives the bug too — it is downloaded and cached at save time (#525).** When
you save a channel whose logo is an **External logo URL**, the URL is fetched, decode-validated, and
stored in the image cache under a content-hash name — after which it is byte-identical to an
uploaded logo. So `Artwork.Path` never holds a URL: the on-screen bug renders, the editor previews
it, and M3U/XMLTV emit the cached `/iptv/logos/…` URL like any uploaded logo. A URL that is dead,
slow (>10s), oversized (>10 MiB), a non-image, or a decode bomb (over 50 MP total or 600 frames)
**fails the save with a specific 400** in the editor — you see it immediately, rather than a silent
render-time drop at 3am (the pre-#502/#511 behavior). To change the remote image, re-enter the URL;
there is no refresh button by design. Existing channels that still hold a raw URL are converted by a
one-time startup migration; one that fails to download is left alone (a warning names it) and renders
with no bug until you re-save it. Historical context (the old `File.Exists`-on-a-URL drop, and the
bounded render-time fetch that preceded caching) is in `docs/decisions.md` under
`graphics.channel-logo-caching`, #502 and #511.
Note: `ChannelLogoGenerator.GenerateChannelLogoUrl()` hardcodes `localhost` for watermark logo
fetching — see issue #1 for details.
+55
View File
@@ -130,6 +130,7 @@ in-file entries.
- [2026-07-20 — External-URL channel logos pass through to the graphics engine; never `File.Exists`-gated, never ffmpeg-native (#502)](#2026-07-20--external-url-channel-logos-pass-through-to-the-graphics-engine-never-fileexists-gated-never-ffmpeg-native-502)
- [2026-07-20 — Remote graphics-engine images are fetched through a bounded, pooled `IRemoteImageFetcher`; re-fetched per element init, not cached (#511)](#2026-07-20--remote-graphics-engine-images-are-fetched-through-a-bounded-pooled-iremoteimagefetcher-re-fetched-per-element-init-not-cached-511)
- [2026-07-21 — Decision records carry a lifecycle schema, validated by a script; append-only-by-diff is retired (#521)](#2026-07-21--decision-records-carry-a-lifecycle-schema-validated-by-a-script-append-only-by-diff-is-retired-521)
- [2026-07-21 — External channel-logo URLs are downloaded and cached at save time; the render path never fetches a logo (#525)](#2026-07-21--external-channel-logo-urls-are-downloaded-and-cached-at-save-time-the-render-path-never-fetches-a-logo-525)
---
@@ -2810,3 +2811,57 @@ record as its `superseded-by` and this record's `supersedes` pointing back — t
pattern the new rule itself mandates for every future reversal. `docs/decisions/migration-map.md`
tracks the legacy-heading → key → status → location mapping as the rest of the log migrates
(ersatztv#520/#521, ongoing — most existing headings remain `legacy-unmigrated` until touched).
## 2026-07-21 — External channel-logo URLs are downloaded and cached at save time; the render path never fetches a logo (#525)
`key: graphics.channel-logo-caching` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
**Rule:** 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 400.
**Signals:** 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
**Mechanics:** `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 #511 and #502, does not reverse them.** #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; this is a new decision layered on
top, hence `supersedes: none`.
**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.
+1
View File
@@ -12,3 +12,4 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `ci.root-screenshot-guard` | The Husky `pre-commit` hook refuses a staged root-level `*.png` (belt-and-suspenders with the `.gitignore` rule); nested `*.png` real assets are unaffected. | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--root-screenshot-guard-pre-commit-refuses-root-level-png-303-h3) |
| `ci.runner-placement` | No persistent Roslyn compiler server survives a CI build (`UseSharedCompilation=false` etc., runner env + Dockerfile `ENV`); every `services:` container gets its own explicit `--memory`/`--memory-swap`/`--cpus` cap (it does not inherit the job container's). | 2026-07-17 | [link](../decisions.md#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406) |
| `docs.decision-lifecycle` | every decision `##` record (active or archived) carries a 5-field metadata block (`key`, `status`, `since`, `supersedes`, `superseded-by`) checked by `scripts/decisions_validate.py`; a record is never deleted or line-edited to reverse a call — it is moved to `docs/decisions/archive/` with `status: superseded`/`retired` and a reciprocal `superseded-by`/`supersedes` key pair to its replacement. | 2026-07-21 | [link](../decisions.md#2026-07-21--decision-records-carry-a-lifecycle-schema-validated-by-a-script-append-only-by-diff-is-retired-521) |
| `graphics.channel-logo-caching` | 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 400. | 2026-07-21 | [link](../decisions.md#2026-07-21--external-channel-logo-urls-are-downloaded-and-cached-at-save-time-the-render-path-never-fetches-a-logo-525) |