12 KiB
External channel-logo URLs become download-on-save
Date: 2026-07-21
Status: design, awaiting approval
Relates to: #502 (external URL logos reach the graphics engine), #511 / PR #518 (bounded
render-time fetch), #1 (generated-initials localhost URL), #510 (deco path)
Problem
A channel logo set as an external URL is stored raw in Artwork.Path and passed through to every
consumer. The render path therefore has to fetch it over HTTP during stream startup, once per
playout item, while ffmpeg waits on the pipe. #511 bounded that fetch (10s deadline, 10 MiB wire cap,
3 redirects, decode budgets) but did not remove it.
Bounding the fetch treats the symptom. The fetch itself is the problem:
- Failure is invisible and late. A dead, slow, oversized or non-image URL surfaces as a log line at render time. The operator who typed the URL is long gone.
- No preview. The editor cannot show the bug for an external URL, so the operator cannot tell whether it will work until a stream runs.
- Repeated work. The same image is re-fetched on every playout item transition.
- Third-party dependency inside stream startup. A logo host having a bad day degrades tuning.
Goal
An external logo URL becomes an input method, not a storage format. Entering a URL downloads the image once, at save time, into the existing artwork cache — after which it is indistinguishable from an uploaded logo. Nothing downstream knows the logo ever came from a URL.
To refresh a changed image, the operator re-enters the URL. There is no refresh button and no staleness tracking; that is a deliberate simplification, not an oversight.
Non-goals
- No refresh button, no TTL, no ETag/Last-Modified tracking. Re-add the URL.
- No change to
ImageGraphicsElement(operator-authored YAMLimage:), which may still point at a URL and still fetches at render time through the hardenedIRemoteImageFetcher. Removing that is an unrelated feature removal. - No change to the generated-initials fallback (#1) or the deco path (#510).
- No new artwork storage mechanism. Reuses
IImageCacheexactly as the upload path does.
Design
Save path
All three handlers that persist a channel logo share one code path today and will share the new one:
| Handler | Current logo logic |
|---|---|
UpdateChannelHandler.ApplyUpdateRequest |
UpdateChannelHandler.cs:79-123 |
CreateChannelHandler |
CreateChannelHandler.cs:63-65 |
CreateChannelFromLineupHandler |
CreateChannelFromLineupHandler.cs:360-362 |
New behavior when the incoming logo path is an absolute http(s) URL:
- Fetch it with
IRemoteImageFetcher— the primitive #511 already built and hardened (bounded deadline covering headers and body, 10 MiB wire cap, 3 redirects, content-type check, pooled client). - Validate the decode budgets against the downloaded bytes (see Decode validation below).
IImageCache.SaveArtworkToCache(stream, ArtworkKind.Logo)→ an opaque content-hash name.- Store that name in
Artwork.Path, stampDateAdded/DateUpdated, exactly as the upload path does.
Any failure rejects the save with a validation error naming the cause. The channel is not persisted and the field stays editable.
Naming: content hash, not GUID
The request was "a random name/guid". This design uses the existing content hash that
SaveArtworkToCache already returns (MD5 of the bytes, stored as {hash} with the file at
{LogoCacheFolder}/{hash[..2]}/{hash}).
Rationale — it satisfies the intent (opaque, generated, not the URL) while being strictly better than a GUID here:
- It is byte-for-byte the same mechanism as an uploaded logo, so there is one storage convention rather than two.
- Re-adding an unchanged URL is a natural no-op (same bytes → same hash → same file).
- Re-adding a changed URL naturally produces a new name, which is exactly the refresh semantic.
A GUID would deviate from the established convention for no benefit, which the deviation policy in
docs/contributing.md §10 asks us not to do.
Downstream consumers: no code change
Because Artwork.Path now holds a cache name, every consumer already does the right thing:
| Consumer | Result |
|---|---|
M3U (ChannelPlaylist.cs:63-70) |
{scheme}://{host}{baseUrl}/iptv/logos/{hash}.jpg |
XMLTV (RefreshChannelListHandler.cs:85-95, _channel.sbntxt:29-35) |
{RequestBase}/iptv/logos/{hash}.jpg |
SPA/API mapper (Channels/Mapper.cs:129-166) |
iptv/logos/{hash} |
Render (WatermarkSelector.cs:301-325) |
resolves via imageCache.GetPathForImage, existence-checked |
This is the intended outcome: clients stop depending on the third-party host, and the
IsExternalUrl branches in those consumers become unreachable for channel logos. Those branches
are left in place — Artwork is shared with other artwork kinds and with rows that failed
migration.
Render path
WatermarkSelector.ChannelLogoWatermarkOptions stops treating a URL as renderable. For a logo path
that is still a URL (only possible for a row that failed migration), it logs a warning naming the
channel and returns None — no fetch, no bug, stream unaffected.
ImageElementBase.LoadImage keeps its remote branch for ImageGraphicsElement. #511's fetch and
decode budgets stay exactly as merged.
Decode validation (important)
Today ImageElementBase exempts local images from the decode budgets, on the reasoning that a
local file is something an operator put on disk rather than bytes an arbitrary host returned. This
design invalidates that reasoning for logos: a downloaded URL becomes a local file, so without a
check at save time the decode bomb simply relocates from the render path to the cache.
Therefore the save path must validate before caching:
- Reuse #511's budgets — dimensions,
width × height × frames ≤ 50 MP,≤ 600frames — enforced the same way (DecoderOptions.MaxFrames+ post-decode re-verification against the decoded image, because header frame counts lie). - To share them, extract the budget helpers currently on
ImageElementBase(EnsureDimensionsAffordable,AffordableFrames,EnsureDecodeAffordable) into a single reusable component. Proposed:ErsatzTV.Core/Images/RemoteImageDecodeBudget.cs, withImageElementBaseand the save path both calling it. The retention budget (EnsureScaledFramesAffordable) stays inImageElementBase— it depends on render-time scale and has no meaning at save time.
Uploads are budget-checked too (decided: fold in). Direct uploads (UploadArtworkHandler)
are not budget-checked today. Once URL logos become uploads, they inherit that gap on any subsequent
re-upload — an inconsistency this change would create (same bytes, same cache, enforcement
depending only on arrival path). The RemoteImageDecodeBudget component is being built regardless, so
UploadArtworkHandler calls it too. One consistent rule: anything entering the logo cache is
budget-checked, however it arrived. A budget failure returns a 400 from the upload endpoint the
same way it does from the channel save. (Risk is admin-only, like #511's SSRF stance, but the failure
mode — cache succeeds, render OOMs concurrent streams later — is exactly the fail-late pattern this
redesign exists to kill, so it is closed here rather than deferred.)
Migration of existing rows
A one-time migration walks Artwork rows whose Path is an absolute http(s) URL and whose kind is
Logo:
- fetch through the same hardened fetcher → validate →
SaveArtworkToCache→ rewritePath, bumpDateUpdated; - on failure, leave the row untouched and log a warning naming the channel and the reason, so the operator gets an actionable list rather than silent breakage.
Run as a startup task, not an EF migration: it performs network I/O and must be resilient and
restartable, which does not belong in a schema migration (and would have to be written twice for
SQLite and MySql). It follows the existing precedent of LocalFolderScanner.RefreshArtwork
(LocalFolderScanner.cs:132-200), which already does fetch → *ArtworkToCache → persist.
Idempotent by construction: after a successful pass the row's Path is no longer a URL, so it is not
selected again.
Editor UX
- Save is synchronous: the
PUTperforms the download and returns400with a specific message on failure (e.g. "Could not download logo: host did not respond within 10s", "Logo is 41 MB; the limit is 10 MB", "URL returned text/html, not an image"). Worst case latency is the fetch deadline. - On success the response carries a normal cached logo, so the preview works with no special
casing — the
&& !externalUrlLogosuppression atChannelEditScreen.tsx:817is deleted, as is the help text claiming external URLs cannot drive the bug. - The external-URL field is an input: after a successful save it clears and the uploaded-logo
preview shows the cached image. The existing mutual-exclusion logic
(
ChannelEditScreen.tsx:119-123) is simplified accordingly — the two fields can no longer disagree because only one storage form now exists. - Help text states that changing the remote image requires re-entering the URL.
Error handling
| Case | Behavior |
|---|---|
| Host unreachable / times out | Save rejected, message names the timeout |
| Non-2xx | Save rejected, message names the status |
| Not an image content type | Save rejected |
| Over the wire cap | Save rejected, message names actual vs limit |
| Over a decode budget | Save rejected, message names dimensions/frames vs limit |
| Cache write fails | Save rejected, BaseError surfaced |
| Migration failure | Row untouched, warning logged, channel keeps rendering without a bug |
Testing
- Handler tests (
ErsatzTV.Tests): URL → fetch → cache →Artwork.Pathis the hash; each failure mode rejects the save and persists nothing; a non-URL path is unchanged; re-adding identical bytes is a no-op. - Decode budget tests: move/extend the existing
RemoteImageDecodeLimitTests, keeping the APNG regression coverage (a defaultIdentifythrows on most APNGs; header frame counts lie). - Migration tests: URL row is converted; failing row is left intact and warned about; a second run is a no-op.
WatermarkSelectortests: extendWatermarkSelectorChannelLogoTests— a cached path renders; a leftover URL path returnsNonewith a warning and never fetches.- SPA tests:
ChannelEditScreen.test.tsx— preview renders after a URL save; the removed suppression is not reintroduced; error surfaces inline on a rejected save. - Golden nets:
ChannelPlaylistGoldenTests/ChannelGuideGoldenTestsshould be unchanged for uploaded logos, and a channel whose logo came from a URL should now emit an/iptv/logos/URL.
Docs to update in the same PR
docs/decisions.md— new entry; explicitly supersedes the "not cached, re-fetched per element init" paragraph of the #511 entry and narrows #502's "external artwork passes through" to the client-facing consumers it still describes.docs/channels.md— replace the stale limitation text (this supersedes PR #522, which should be closed unmerged).docs/api-conventions.md—PUT /api/v1/channels/{id}can now fail on logo download; note the new 400 cases. Regeneratev1.json+endpoint-index.mdif any response shape changes.
Out of scope / follow-ups
ArtworkController.RedirectArtwork(ArtworkController.cs:37-57) builds"/iptv/logos/" + Pathunconditionally, producing a malformed redirect whenPathis a URL. Pre-existing, unrelated to this change, and largely mooted by it for logos — file separately.- #1 (generated-initials
localhost) and #510 (deco path) remain untouched.
Resolved decisions
UploadArtworkHandlerdecode validation is folded into this PR, not deferred — see Decode validation. The component exists either way and the inconsistency is created by this change.IRemoteImageFetcherstays inCore/Interfaces/Streaming/. It is still used by the streaming path (YAML image elements), and a namespace move is churn againstgit blamefor weak naming-accuracy benefit. Trivial standalone rename if ever wanted.