Nuvio Live Sports Plugin · artwork rework

Composed match cards for the artwork gap

Official streamed.pk and TimStreams posters still win outright — the generated card is a strict last resort. But when a niche DaddyLive fixture has no artwork at all, the old pipeline collapsed to a grey tile with centred text. It now composes a cinematic broadcast card from the crests, league emblem and channel mark it can actually resolve.

Date
2026-09-21
Workspace
C:\Users\odeda\Desktop\Projects\Nuvio Live Sports Plugin
Executor
native (ZCode unavailable — see §08)
Tests
186 passed · 2 failed (both pre-existing)
01 · Problem

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.

02 · Decision

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.
Design intent — Cinematic Broadcast

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.

03 · The card

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.

The channel-logo bug (found after the first release)

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.

Size budget — the hidden constraint

The first implementation base64-embedded every crest into the SVG. That looks self-contained but it blows the Stremio poster budget outright:

CardEmbeddedURL-referenced
No crests3.1 kb3.1 kb
One crest193.9 kb3.6 kb
Two crests534.3 kb4.5 kb
Two crests + league + channel615.2 kb5.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.

04 · Degradation ladder

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.

Card states
StateLayoutStill 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.

05 · What changed

Four sources, one new test file

Changed files
FileChange
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.
06 · Verification

What was actually observed

200
matches on composed cards
0
bare tiles remaining
10 / 10
new composer tests
0
new failing suites
Evidence
CheckCommand / targetResult
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.

Scope of evidence

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.

07 · Delivery

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.

Required to see it live
  • 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 watches src/ 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.

08 · Open items

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.