---
key: graphics.channel-logo-caching
title: 2026-07-21 — External channel-logo URLs are downloaded and cached at save time; the render path never fetches a logo (#525)
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 422 (BaseError → ValidationProblemDetails).
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 `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.
