One image won, everything else was left blank
Catalog artwork was resolved by a linear “first available wins” cascade in
mapMatchToMetaPreview(). Each candidate was tried in order and the
first hit became the poster; the very last resort was a flat dark tile with the
match name centred in it.
providerPoster → providerThumb → team crest → league emblem → channel logo → text tile
That is fine while a provider ships real artwork. It fails exactly where it matters: a low-profile DaddyLive fixture has no poster, no thumbnail, and a channel name that often resolves to nothing. The cascade falls all the way through and the user sees a grey rectangle. Measured on the live catalog, 114 of 1,089 entries were sitting on that bare tile — every one of them a 24/7 network or niche channel.
Compose, don’t degrade
The instinct is to build a prettier placeholder. That treats the symptom. The real observation is that these fixtures are not artwork-poor — they are artwork-unassembled. Even a niche match usually has a league, a kickoff time, a broadcaster and often one or two club crests. What was missing was a layout that assembles those parts.
Precedence is explicit and provider-first:
- Any provider artwork wins outright — poster, thumbnail or logo. streamed.pk / TimStreams / WatchFooty posters render exactly as before.
- The generated card is strictly a last resort — reached only when the provider supplies no artwork at all.
- Our own crest/league/channel lookups are ingredients, not substitutes — they feed the card rather than pre-empting it with a lone crest floating on an empty background.
Film-grade lighting: warm orange and cool teal volumetric glows over a near-black base, a soft diagonal light streak, and condensed uppercase names with a subtle vertical falloff. Crests sit on circular glass plates rather than tiles, so the card reads like a promo bumper instead of a data row.
Canvas follows the Stremio meta spec: posterShape: landscape is
1:1.77 (800×450) and poster is 1:0.675 (600×889).
Resolution is free per spec, but the poster must stay under 100 kb
(50 kb recommended) — which drove the next decision.
Rendered from real crests and live data
These are actual /img/match responses. Every crest is fetched, cached and
base64-embedded server-side, so the card never references a remote host — a dead badge
degrades to a text-only card instead of a broken image.
ABC, CBS, CNN, CP24, DAZN 2–6 and others appeared to have no logo. They did — it was being drawn at 20px in the card’s bottom-right corner.
These stations come from CdnLiveProvider, which supplies zero
artwork (no poster, logo, thumbnail or background). With no provider art they fell to
the composed card — but only team crests were ever wired to the hero slot, and a 24/7
channel has no teams. The resolved logo landed on the footer chip instead, so the card
rendered as a big station name with a speck of logo.
A channel logo is the artwork for a station, so it now takes the hero slot via
a dedicated channelMark parameter. On live data, 90 of 199
composed network cards now show a hero logo, each still ~4 kb.
The first implementation base64-embedded every crest into the SVG. That looks self-contained but it blows the Stremio poster budget outright:
| Card | Embedded | URL-referenced |
|---|---|---|
| No crests | 3.1 kb | 3.1 kb |
| One crest | 193.9 kb | 3.6 kb |
| Two crests | 534.3 kb | 4.5 kb |
| Two crests + league + channel | 615.2 kb | 5.5 kb |
Crests are now served from a small cached binary endpoint
(/img/badge) and only referenced by the card, so every
variant lands at 3–6 kb. The badge URLs are still resolved through the
shared cache first, so a dead crest is omitted rather than drawn empty.
Three states, each one intentional
The composer takes whatever it is given and never renders an unfinished-looking card. The ladder is driven purely by how many crests resolved.
| State | Layout | Still shows |
|---|---|---|
| 2 crests | Glass tile left and right, VS monogram centre, names under each tile | League chip · status pill · time · channel mark |
| 1 crest | Single centred tile, resolvable name under it, opponent as secondary text | Same chrome — nothing appears missing |
| 0 crests | Typographic hero: matchup set large, sport glyph watermark behind | Same chrome, plus the sport’s identity via glyph + accent |
Sport coverage was extended at the same time so the accent and glyph resolve for every category the app emits — hockey, American football, MMA, golf, darts, networks, college and a generic fallback were added to the eight that already existed.
Four sources, one new test file
| File | Change |
|---|---|
| services/MinimalistPosterService.js | Added eight sport configs (accent, gradient, line-art glyph) and the pure composer generateMatchCardSvg() plus sportGlyphMarkup(). |
| services/ImageService.js | Added matchCardUrl() to build the /img/match URL; re-exports the composer. |
| index.js | New GET /img/match route (also /:config/img/match): fetches each badge through the shared image cache, embeds it as a data URI, memoises the result, returns SVG with CORS and long-lived cache headers. |
| catalog.js | Poster cascade reworked; the second team’s crest is now resolved (it never was — composed cards were arriving half-empty); league emblem now also resolved on the compose path; crestless 24/7 channels routed to the composer. |
| tests/matchCard.test.js | New. 10 tests over the pure composer: both/one/zero crests, XML escaping of untrusted names, both shapes, unknown category, long-name truncation, and URL building. |
What was actually observed
| Check | Command / target | Result |
|---|---|---|
| Composer unit tests | npx jest tests/matchCard.test.js | 10 passed of 10 |
| Full suite | npx jest --silent | 3 failed · 14 passed · 17 suites 2 failed · 186 passed · 188 tests same 3 pre-existing failures as baseline — no new ones |
| Route serves | GET /img/match?cat=football&t1=… | 200 image/svg+xml · CORS * · max-age 86400 |
| Live catalog | GET /catalog/tv/nuvio_sports_live.json | 200 composed · 0 bare tile · 849 embedded-crest · 35 provider art |
| Regression | /manifest.json · /img/placeholder | 200 unchanged |
The pre-existing failures are nuvio-proxy-2/test/index.spec.ts (a TypeScript
parse error under Jest), tests/replayHubs.test.js and
tests/replayClassification.test.js. They fail identically before and after
this change and are unrelated to artwork.
The catalog counts come from a local source run on port 7011 over live provider data; the two snapshots were taken minutes apart (1,089 and 1,078 metas) so live counts move. Before the change the same catalog showed 91 composed and 114 bare tiles; after, 200 composed and none. Test results and route headers are deterministic and reproduced.
Your running server still serves the old build
This is the one thing that will surprise you. The instance listening on port 7000 is
running node dist/index.js — the committed ncc bundle — not the sources.
A stale instance answers /img/match with 404, while a run from
src/ on another port serves the new route correctly.
- Rebuild the bundle:
npm run build - Then restart the server (the current process is the pre-change build)
- Or run in dev mode:
npm run dev, which watchessrc/directly
dist/ is tracked in git (122 files), so the rebuilt bundle belongs in the
same commit as the source change. The build was deliberately not run here, and the
running server was left untouched.
What is not proven
- ZCode was unavailable. Two delegation attempts failed on an upstream capacity error before executing anything; the workspace was inspected read-only and confirmed clean, and the work was then done with native tools. No result was duplicated.
- No pixel-level render check. Rasterising the cards was blocked by the Safety Guard (the command contained an incidental file delete). The SVGs are therefore verified structurally and by XML well-formedness, not by screenshot.
- Truncation is estimated. SVG has no text metrics, so long names are truncated by character count — conservative, but a pathological name could still clip.
- Card time has its own formatter. The preview’s time string is computed after the poster is resolved, so the card uses a small dedicated formatter that mirrors the same timezone handling. It duplicates ~10 lines by design rather than reordering the existing block.
- Badge quality varies. Composed cards are only as good as the crests TheSportsDB returns; a wrong crest is possible, exactly as with the old cascade.