An external http(s) channel-logo URL is now downloaded, decode-validated, and cached at save time — becoming byte-identical to an uploaded logo — instead of being fetched over HTTP at render time (the #511 behavior). A bad URL fails the save with a 400 in the editor, not a silent render-time drop at 3am. Follow-up to #511 (PR #518) and #502; supersedes the closed PR #522.
Decode + budget-validate; Core interface throws-on-invalid (no ImageSharp type crosses Core), render path uses a static method returning the Image
IRemoteLogoCacher (Infra)
fetch (IRemoteImageFetcher, #511) → validate → cache (IImageCache) → content-hash name, or a BaseError
UpdateChannelHandler + both create handlers
Resolve a URL logo → cache name before persist; a cacher Left fails the save, nothing persisted
UploadArtworkHandler
Same decode budget applied — closes the pre-existing unchecked-upload gap (one rule: anything entering the logo cache is budget-checked)
WatermarkSelector
A leftover URL logo → warning + no on-screen bug, never a render-time fetch
ExternalLogoMigratorService (startup)
One-time, fail-open conversion of existing URL rows; failures leave the row + warn; idempotent; all-or-nothing on cancel
SPA ChannelEditScreen
Preview works for a saved logo; stale "URL can't drive the bug" copy removed; save 400 shown inline
Docs
decisions.md lifecycle record graphics.channel-logo-caching; channels.md; api-conventions.md
Downstream (M3U/XMLTV/SPA mapper/render) needs no change — a saved logo is a cache name, so all consumers resolve /iptv/logos/… exactly as for an upload.
Why not a formal supersession of #511/#502
graphics.channel-logo-caching is a new active record with supersedes: none: it narrows#511 (whose bounded-fetch primitive stays active for YAML image: graphics elements) and #502 (whose "pass-through" still describes client consumers), rather than reversing them. Content-hash naming (not a GUID) reuses the existing cache convention and makes re-adding an unchanged URL a natural no-op.
Deliberately deferred (in the decision record)
No refresh button / staleness tracking — re-enter the URL.
SSRF unchanged — admin-supplied URLs, redirect cap 3, no host denylist (inherits #511's stance). The trigger narrows: render-time (any tuning client) → admin-save + startup, strictly fewer moments.
Review
10 tasks, each independently reviewed clean (cold, fresh-context per task).
Whole-branch final review on Fable: MERGEABLE @ 6d5f6b24. No Blocker/High. Two flagged items fixed in 5530d91a: a migration host-crash guard (a mid-migration DbUpdateConcurrencyException no longer trips StopHost) and a multi-error-400 regression (errors.Join() restored, + regression test). Three carried Minors adjudicated acceptable-defer.
Verification
Full solution 4,079 tests + web 891 green; Architecture 5/5 (Core stays ImageSharp-free); build 0 warnings.
Negative-controlled the decode caps and the upload budget (bomb test pins the budget message, not the content-type sniff); the APNG header-lie regression is preserved.
Format gate + BOM clean across 25 changed .cs; OpenAPI unchanged (no controllers touched); decisions validator OK + catalog regenerated.
⚠️ Not run: local live-E2E of the download write-path
Per the write-path convention I'd normally run scripts/e2e-local.sh and exercise a real channel save with an external logo URL end-to-end (download → preview → M3U emits /iptv/logos/, and a bad URL 400s). I did not run that local live-E2E. The behavior is covered by unit + integration tests, the golden nets, and CI's functional-E2E (curl contracts), but not by an end-to-end run against a live remote host. Flagging it honestly as the one recommended manual check before merge.
Done-when
URL logo → fetched, decode-validated, cached; Artwork.Path = hash; bad URL rejects the save (400) with a specific reason
Shared RemoteImageDecodeBudget extracted; applied to the save path andUploadArtworkHandler
fixes #525
## What
An external `http(s)` channel-logo URL is now **downloaded, decode-validated, and cached at save time** — becoming byte-identical to an uploaded logo — instead of being fetched over HTTP at render time (the #511 behavior). A bad URL fails the save with a **400** in the editor, not a silent render-time drop at 3am. Follow-up to #511 (PR #518) and #502; supersedes the closed PR #522.
## How (10 tasks, TDD, no schema change)
| Layer | Change |
|---|---|
| `RemoteImageDecodeBudget` (Core, pure) | Extracted #511's decode budget (product `w×h×frames ≤ 50 MP`, `≤ 600` frames) so it's shared |
| `IRemoteImageValidator` / `RemoteImageValidator` (Infra) | Decode + budget-validate; Core interface throws-on-invalid (no ImageSharp type crosses Core), render path uses a static method returning the `Image` |
| `IRemoteLogoCacher` (Infra) | fetch (`IRemoteImageFetcher`, #511) → validate → cache (`IImageCache`) → content-hash name, or a `BaseError` |
| `UpdateChannelHandler` + both create handlers | Resolve a URL logo → cache name before persist; a cacher `Left` fails the save, nothing persisted |
| `UploadArtworkHandler` | Same decode budget applied — closes the pre-existing unchecked-upload gap (one rule: anything entering the logo cache is budget-checked) |
| `WatermarkSelector` | A leftover URL logo → warning + no on-screen bug, never a render-time fetch |
| `ExternalLogoMigratorService` (startup) | One-time, fail-open conversion of existing URL rows; failures leave the row + warn; idempotent; all-or-nothing on cancel |
| SPA `ChannelEditScreen` | Preview works for a saved logo; stale "URL can't drive the bug" copy removed; save 400 shown inline |
| Docs | `decisions.md` lifecycle record `graphics.channel-logo-caching`; `channels.md`; `api-conventions.md` |
Downstream (M3U/XMLTV/SPA mapper/render) needs **no change** — a saved logo is a cache name, so all consumers resolve `/iptv/logos/…` exactly as for an upload.
## Why not a formal supersession of #511/#502
`graphics.channel-logo-caching` is a new active record with `supersedes: none`: it **narrows** #511 (whose bounded-fetch primitive stays active for YAML `image:` graphics elements) and #502 (whose "pass-through" still describes client consumers), rather than reversing them. Content-hash naming (not a GUID) reuses the existing cache convention and makes re-adding an unchanged URL a natural no-op.
## Deliberately deferred (in the decision record)
- **No refresh button / staleness tracking** — re-enter the URL.
- **SSRF unchanged** — admin-supplied URLs, redirect cap 3, no host denylist (inherits #511's stance). The trigger *narrows*: render-time (any tuning client) → admin-save + startup, strictly fewer moments.
## Review
- **10 tasks, each independently reviewed clean** (cold, fresh-context per task).
- **Whole-branch final review on Fable: `MERGEABLE @ 6d5f6b24`.** No Blocker/High. Two flagged items **fixed in `5530d91a`**: a migration host-crash guard (a mid-migration `DbUpdateConcurrencyException` no longer trips `StopHost`) and a multi-error-400 regression (`errors.Join()` restored, + regression test). Three carried Minors adjudicated acceptable-defer.
## Verification
- Full solution **4,079 tests** + web **891** green; Architecture 5/5 (Core stays ImageSharp-free); build 0 warnings.
- Negative-controlled the decode caps and the upload budget (bomb test pins the *budget* message, not the content-type sniff); the APNG header-lie regression is preserved.
- Format gate + BOM clean across 25 changed `.cs`; OpenAPI unchanged (no controllers touched); decisions validator OK + catalog regenerated.
## ⚠️ Not run: local live-E2E of the download write-path
Per the write-path convention I'd normally run `scripts/e2e-local.sh` and exercise a real channel save with an external logo URL end-to-end (download → preview → M3U emits `/iptv/logos/`, and a bad URL 400s). **I did not run that local live-E2E.** The behavior is covered by unit + integration tests, the golden nets, and CI's functional-E2E (curl contracts), but not by an end-to-end run against a live remote host. Flagging it honestly as the one recommended manual check before merge.
## Done-when
- [x] URL logo → fetched, decode-validated, cached; `Artwork.Path` = hash; bad URL rejects the save (400) with a specific reason
- [x] Shared `RemoteImageDecodeBudget` extracted; applied to the save path **and** `UploadArtworkHandler`
- [x] Startup migration converts existing URL rows; failures left intact + warned; idempotent
- [x] `WatermarkSelector` no longer treats a URL as renderable (→ warning, no fetch, no bug)
- [x] SPA: preview works for a saved logo; stale copy removed; inline error on rejected save
- [x] Docs updated in-PR (decisions lifecycle record, channels, api-conventions)
- [x] Adversarial review passed (final whole-branch review MERGEABLE; fixes folded in)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Design for reversing #511's render-time fetch: an external logo URL is
downloaded once at save time into the artwork cache (content-hash name,
same as an upload), validated against #511's decode budgets, and stored
so nothing downstream knows it came from a URL. Bad URL fails in the
editor, not at 3am. No refresh button — re-add the URL. One-time startup
migration converts existing URL rows; failures leave the row and warn.
refs #511#502
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.
- Medium-1: wrap ExternalLogoMigratorService.ExecuteAsync in try/catch — a DB
exception (e.g. a channel deleted mid-migration -> DbUpdateConcurrencyException)
no longer trips BackgroundServiceExceptionBehavior.StopHost and kills the app;
it logs and self-heals on the next boot. Caller-cancel path handled separately.
- Low-2: CreateChannelHandler/UpdateChannelHandler validation failure now returns
errors.Join() (all accumulated errors) not errors.Head (first only), restoring
the repo-wide convention; regression test added.
- Low-4: corrected the Startup registration comment (migrator self-awaits
WaitForDatabase; order is not load-bearing).
Final whole-branch review: MERGEABLE @ 6d5f6b24 (fable). Carried Minors adjudicated
acceptable-defer.
Whole-branch final review ran on Fable against 6d5f6b24 and returned MERGEABLE — no Blocker/High. It flagged two pre-merge polish items, both folded into 5530d91a (the current head):
Medium — ExternalLogoMigratorService.ExecuteAsync could crash the whole host: a mid-migration DbUpdateConcurrencyException (e.g. a channel deleted while its logo is downloading) escaped ExecuteAsync, and the default BackgroundServiceExceptionBehavior.StopHost would exit the process. Now wrapped in try/catch — it logs and self-heals on the next boot; caller-cancel handled separately (no partial persist).
Low — my handler refactor had regressed multi-error validation 400s to first-error-only (errors.Head); restored errors.Join() (the repo-wide convention) + added a regression test.
Since 5530d91a is new code the final reviewer didn't see, I re-verified it directly rather than re-running the full cold review for two review-directed changes: build clean, 138/138 on the affected suites (incl. the new multi-error test), format/BOM clean. The three carried Minors were adjudicated acceptable-defer by the final review (persistence structurally impossible on the fail path; no-op SaveChanges + bounded table load; transitional SPA preview cosmetic — the last is recorded as the accepted residual in the decision).
Per the process: 10 tasks, each independently reviewed clean in fresh context, then this whole-branch capstone. The one gap I'm not papering over is the local live-E2E of the download write-path (see the PR body) — flagged as the recommended manual check before merge.
## Independent review
`Review-verdict: MERGEABLE @ 5530d91add9e18c8dd5d56c352fb1fdd5095516c`
Whole-branch final review ran on Fable against `6d5f6b24` and returned **MERGEABLE** — no Blocker/High. It flagged two pre-merge polish items, both **folded into `5530d91a`** (the current head):
- **Medium** — `ExternalLogoMigratorService.ExecuteAsync` could crash the whole host: a mid-migration `DbUpdateConcurrencyException` (e.g. a channel deleted while its logo is downloading) escaped `ExecuteAsync`, and the default `BackgroundServiceExceptionBehavior.StopHost` would exit the process. Now wrapped in try/catch — it logs and self-heals on the next boot; caller-cancel handled separately (no partial persist).
- **Low** — my handler refactor had regressed multi-error validation 400s to first-error-only (`errors.Head`); restored `errors.Join()` (the repo-wide convention) + added a regression test.
Since `5530d91a` is new code the final reviewer didn't see, I re-verified it directly rather than re-running the full cold review for two review-directed changes: build clean, 138/138 on the affected suites (incl. the new multi-error test), format/BOM clean. The three carried Minors were adjudicated **acceptable-defer** by the final review (persistence structurally impossible on the fail path; no-op `SaveChanges` + bounded table load; transitional SPA preview cosmetic — the last is recorded as the accepted residual in the decision).
Per the process: 10 tasks, each independently reviewed clean in fresh context, then this whole-branch capstone. The one gap I'm **not** papering over is the local live-E2E of the download write-path (see the PR body) — flagged as the recommended manual check before merge.
The write-path live-E2E I'd flagged as skipped is now done (scripts/e2e-local.sh, fresh instance, a locally-served PNG as the "good" external URL, verified via curl — never a browser tab):
emits …/iptv/logos/<hash> — the cached URL, not the raw external one
Serve the cached logo back
200, real PNG bytes
Unreachable URL (127.0.0.1:9)
422"Could not download logo … Connection refused"
Non-image URL (text/html)
422"Remote image … returned content type 'text/html'"
Channel logo after a rejected save
unchanged — no partial mutation
The catch: these reject as 422 (BaseError → ValidationProblemDetails via ToErrorResult()), not the 400 I'd written in the docs/PR (400 here still means model-binding failure). Corrected channels.md, api-conventions.md, and the graphics.channel-logo-caching decision record + catalog in c7949c01. This is exactly the class of thing only a live run surfaces — glad the "finish the plan" instruction sent me to run it.
c7949c01 is a docs-only correction (422 wording) on top of the reviewed 5530d91a; no code changed, so the whole-branch verdict carries forward. CI re-running on the new head.
## Live-E2E ran — and caught a doc bug
The write-path live-E2E I'd flagged as skipped is now **done** (`scripts/e2e-local.sh`, fresh instance, a locally-served PNG as the "good" external URL, verified via curl — never a browser tab):
| Check | Result |
|---|---|
| `PUT` channel with external logo URL | 200; `Artwork.Path` → `iptv/logos/7B8A70C6…` (a cache hash), `isExternalUrl=false` — **downloaded + cached** |
| M3U output | emits `…/iptv/logos/<hash>` — the **cached** URL, not the raw external one |
| Serve the cached logo back | 200, real PNG bytes |
| Unreachable URL (`127.0.0.1:9`) | **422** `"Could not download logo … Connection refused"` |
| Non-image URL (text/html) | **422** `"Remote image … returned content type 'text/html'"` |
| Channel logo after a rejected save | **unchanged** — no partial mutation |
**The catch:** these reject as **422** (`BaseError` → `ValidationProblemDetails` via `ToErrorResult()`), not the **400** I'd written in the docs/PR (400 here still means model-binding failure). Corrected `channels.md`, `api-conventions.md`, and the `graphics.channel-logo-caching` decision record + catalog in `c7949c01`. This is exactly the class of thing only a live run surfaces — glad the "finish the plan" instruction sent me to run it.
`Review-verdict: MERGEABLE @ c7949c01ca99ae32f6dd814b7d593371d3570405`
`c7949c01` is a docs-only correction (422 wording) on top of the reviewed `5530d91a`; no code changed, so the whole-branch verdict carries forward. CI re-running on the new head.
Review-verdict: MERGEABLE @ aa769948 (carries forward the whole-branch review; the rebase is docs-only integration, no code change).
Main advanced under this PR (the full decision-corpus lifecycle migration + startup rewrite landed, #527), so the merge went mergeable:false and I rebased. What the rebase touched:
decisions.md / catalog conflict — both my graphics.channel-logo-caching record and main's new startup.parallel-orientation record appended at the same spot. Resolved by keeping both; regenerated docs/decisions/README.md from source (never hand-merged). Cross-referenced the now-keyed siblings in the narrowing prose — ffmpeg.remote-image-fetcher-bounded (#511) and ffmpeg.external-logo-graphics-engine (#502) — and confirmed supersedes: none is still the right call (both stay active; #525 narrows, doesn't reverse).
No code conflicts — the 13 replayed commits applied cleanly; the c7949c01 422 fix re-applied on top.
dotnet format (folder) clean, no BOMs across 25 .cs.
decisions-validate: OK and kickoff-guard: OK — the two new #527 CI checks pass (my docs don't reintroduce a #237-as-live-state phrasing).
Live-E2E remains done (see the prior comment): good URL → cached /iptv/logos/<hash>, M3U emits the cached URL, unreachable/non-image → 422. CI re-running on aa769948; holding the merge for explicit go-ahead rather than auto-firing.
## Rebased onto the #520/#521/#527 main
`Review-verdict: MERGEABLE @ aa769948` (carries forward the whole-branch review; the rebase is docs-only integration, no code change).
Main advanced under this PR (the full decision-corpus lifecycle migration + startup rewrite landed, #527), so the merge went `mergeable:false` and I rebased. What the rebase touched:
- **`decisions.md` / catalog conflict** — both my `graphics.channel-logo-caching` record and main's new `startup.parallel-orientation` record appended at the same spot. Resolved by keeping **both**; regenerated `docs/decisions/README.md` from source (never hand-merged). Cross-referenced the now-keyed siblings in the narrowing prose — `ffmpeg.remote-image-fetcher-bounded` (#511) and `ffmpeg.external-logo-graphics-engine` (#502) — and confirmed `supersedes: none` is still the right call (both stay `active`; #525 narrows, doesn't reverse).
- **No code conflicts** — the 13 replayed commits applied cleanly; the `c7949c01` 422 fix re-applied on top.
Re-verified on the rebased head `aa769948`:
- Full solution **4,080** tests + Architecture 5/5 green; build 0 warnings.
- `dotnet format` (folder) clean, no BOMs across 25 `.cs`.
- **`decisions-validate: OK`** and **`kickoff-guard: OK`** — the two new #527 CI checks pass (my docs don't reintroduce a #237-as-live-state phrasing).
Live-E2E remains done (see the prior comment): good URL → cached `/iptv/logos/<hash>`, M3U emits the cached URL, unreachable/non-image → **422**. CI re-running on `aa769948`; holding the merge for explicit go-ahead rather than auto-firing.
timothy
merged commit 6b1bd9cf4b into main2026-07-21 15:36:16 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
fixes #525
What
An external
http(s)channel-logo URL is now downloaded, decode-validated, and cached at save time — becoming byte-identical to an uploaded logo — instead of being fetched over HTTP at render time (the #511 behavior). A bad URL fails the save with a 400 in the editor, not a silent render-time drop at 3am. Follow-up to #511 (PR #518) and #502; supersedes the closed PR #522.How (10 tasks, TDD, no schema change)
RemoteImageDecodeBudget(Core, pure)w×h×frames ≤ 50 MP,≤ 600frames) so it's sharedIRemoteImageValidator/RemoteImageValidator(Infra)ImageIRemoteLogoCacher(Infra)IRemoteImageFetcher, #511) → validate → cache (IImageCache) → content-hash name, or aBaseErrorUpdateChannelHandler+ both create handlersLeftfails the save, nothing persistedUploadArtworkHandlerWatermarkSelectorExternalLogoMigratorService(startup)ChannelEditScreendecisions.mdlifecycle recordgraphics.channel-logo-caching;channels.md;api-conventions.mdDownstream (M3U/XMLTV/SPA mapper/render) needs no change — a saved logo is a cache name, so all consumers resolve
/iptv/logos/…exactly as for an upload.Why not a formal supersession of #511/#502
graphics.channel-logo-cachingis a new active record withsupersedes: none: it narrows #511 (whose bounded-fetch primitive stays active for YAMLimage:graphics elements) and #502 (whose "pass-through" still describes client consumers), rather than reversing them. Content-hash naming (not a GUID) reuses the existing cache convention and makes re-adding an unchanged URL a natural no-op.Deliberately deferred (in the decision record)
Review
MERGEABLE @ 6d5f6b24. No Blocker/High. Two flagged items fixed in5530d91a: a migration host-crash guard (a mid-migrationDbUpdateConcurrencyExceptionno longer tripsStopHost) and a multi-error-400 regression (errors.Join()restored, + regression test). Three carried Minors adjudicated acceptable-defer.Verification
.cs; OpenAPI unchanged (no controllers touched); decisions validator OK + catalog regenerated.⚠️ Not run: local live-E2E of the download write-path
Per the write-path convention I'd normally run
scripts/e2e-local.shand exercise a real channel save with an external logo URL end-to-end (download → preview → M3U emits/iptv/logos/, and a bad URL 400s). I did not run that local live-E2E. The behavior is covered by unit + integration tests, the golden nets, and CI's functional-E2E (curl contracts), but not by an end-to-end run against a live remote host. Flagging it honestly as the one recommended manual check before merge.Done-when
Artwork.Path= hash; bad URL rejects the save (400) with a specific reasonRemoteImageDecodeBudgetextracted; applied to the save path andUploadArtworkHandlerWatermarkSelectorno longer treats a URL as renderable (→ warning, no fetch, no bug)🤖 Generated with Claude Code
Independent review
Review-verdict: MERGEABLE @ 5530d91add9e18c8dd5d56c352fb1fdd5095516cWhole-branch final review ran on Fable against
6d5f6b24and returned MERGEABLE — no Blocker/High. It flagged two pre-merge polish items, both folded into5530d91a(the current head):ExternalLogoMigratorService.ExecuteAsynccould crash the whole host: a mid-migrationDbUpdateConcurrencyException(e.g. a channel deleted while its logo is downloading) escapedExecuteAsync, and the defaultBackgroundServiceExceptionBehavior.StopHostwould exit the process. Now wrapped in try/catch — it logs and self-heals on the next boot; caller-cancel handled separately (no partial persist).errors.Head); restorederrors.Join()(the repo-wide convention) + added a regression test.Since
5530d91ais new code the final reviewer didn't see, I re-verified it directly rather than re-running the full cold review for two review-directed changes: build clean, 138/138 on the affected suites (incl. the new multi-error test), format/BOM clean. The three carried Minors were adjudicated acceptable-defer by the final review (persistence structurally impossible on the fail path; no-opSaveChanges+ bounded table load; transitional SPA preview cosmetic — the last is recorded as the accepted residual in the decision).Per the process: 10 tasks, each independently reviewed clean in fresh context, then this whole-branch capstone. The one gap I'm not papering over is the local live-E2E of the download write-path (see the PR body) — flagged as the recommended manual check before merge.
Live-E2E ran — and caught a doc bug
The write-path live-E2E I'd flagged as skipped is now done (
scripts/e2e-local.sh, fresh instance, a locally-served PNG as the "good" external URL, verified via curl — never a browser tab):PUTchannel with external logo URLArtwork.Path→iptv/logos/7B8A70C6…(a cache hash),isExternalUrl=false— downloaded + cached…/iptv/logos/<hash>— the cached URL, not the raw external one127.0.0.1:9)"Could not download logo … Connection refused""Remote image … returned content type 'text/html'"The catch: these reject as 422 (
BaseError→ValidationProblemDetailsviaToErrorResult()), not the 400 I'd written in the docs/PR (400 here still means model-binding failure). Correctedchannels.md,api-conventions.md, and thegraphics.channel-logo-cachingdecision record + catalog inc7949c01. This is exactly the class of thing only a live run surfaces — glad the "finish the plan" instruction sent me to run it.Review-verdict: MERGEABLE @ c7949c01ca99ae32f6dd814b7d593371d3570405c7949c01is a docs-only correction (422 wording) on top of the reviewed5530d91a; no code changed, so the whole-branch verdict carries forward. CI re-running on the new head.c7949c01catoaa76994825Rebased onto the #520/#521/#527 main
Review-verdict: MERGEABLE @ aa769948(carries forward the whole-branch review; the rebase is docs-only integration, no code change).Main advanced under this PR (the full decision-corpus lifecycle migration + startup rewrite landed, #527), so the merge went
mergeable:falseand I rebased. What the rebase touched:decisions.md/ catalog conflict — both mygraphics.channel-logo-cachingrecord and main's newstartup.parallel-orientationrecord appended at the same spot. Resolved by keeping both; regenerateddocs/decisions/README.mdfrom source (never hand-merged). Cross-referenced the now-keyed siblings in the narrowing prose —ffmpeg.remote-image-fetcher-bounded(#511) andffmpeg.external-logo-graphics-engine(#502) — and confirmedsupersedes: noneis still the right call (both stayactive; #525 narrows, doesn't reverse).c7949c01422 fix re-applied on top.Re-verified on the rebased head
aa769948:dotnet format(folder) clean, no BOMs across 25.cs.decisions-validate: OKandkickoff-guard: OK— the two new #527 CI checks pass (my docs don't reintroduce a #237-as-live-state phrasing).Live-E2E remains done (see the prior comment): good URL → cached
/iptv/logos/<hash>, M3U emits the cached URL, unreachable/non-image → 422. CI re-running onaa769948; holding the merge for explicit go-ahead rather than auto-firing.