From 5db9658e8491beae625a867a578c83f1d2ca9f93 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 21 Sep 2026 02:24:39 +0200 Subject: [PATCH] Improve Ziggo art matching for highlights and strip live thumbs. Samenvattingen like Moto3 Austria now match via series aliases (Austria/Oostenrijk, Moto3) without strict air-time overlap; unmatched Racing rows drop live channel thumbs so football-on-air never stays as card art. --- .../src/viewer/ziggo-epg-art.test.ts | 59 +++- apps/master-api/src/viewer/ziggo-epg-art.ts | 300 +++++++++++++++--- 2 files changed, 309 insertions(+), 50 deletions(-) diff --git a/apps/master-api/src/viewer/ziggo-epg-art.test.ts b/apps/master-api/src/viewer/ziggo-epg-art.test.ts index be4e015..2bf40e2 100644 --- a/apps/master-api/src/viewer/ziggo-epg-art.test.ts +++ b/apps/master-api/src/viewer/ziggo-epg-art.test.ts @@ -1,11 +1,14 @@ import { artUrlsFromMergedId, isLiveChannelThumb, + isReplayOrHighlightTitle, isZiggoSportChannel, needsZiggoArtEnrichment, normalizeTitleTokens, pickBestZiggoMatch, segmentKeysForRange, + seriesAnchorTokens, + stripLiveThumbs, titleOverlapScore, timesOverlap, type ZiggoEpgEvent, @@ -22,6 +25,7 @@ assert(!isLiveChannelThumb("https://staticqbr-prod-nl.gnp.cloud.ziggogo.tv/image assert(isZiggoSportChannel("Ziggo Sport 1"), "zs1"); assert(isZiggoSportChannel("Ziggo Sport"), "zs"); assert(!isZiggoSportChannel("ESPN"), "espn"); +assert(isReplayOrHighlightTitle("Moto3 BWIN Grand Prix of Austria (samenvatting)"), "samenvatting"); assert( needsZiggoArtEnrichment({ @@ -37,7 +41,9 @@ assert(arts?.landscape.includes("p24692584_bd_h8_aa.jpg"), "landscape url"); assert(arts?.portrait.includes("p24692584_b_v12_aa.jpg"), "portrait url"); assert(titleOverlapScore("GT World Challenge: Nederland Race 2", "GT World Challenge Hoogtepunten Nederland: Race 2") >= 0.5, "overlap"); -assert(normalizeTitleTokens("Ziggo Sport: GT World Challenge").has("world"), "tokens"); +assert(normalizeTitleTokens("Ziggo Sport: GT World Challenge").has("gtworld"), "gtworld alias"); +assert(normalizeTitleTokens("Grand Prix of Austria").has("oostenrijk"), "austria→oostenrijk"); +assert(seriesAnchorTokens("Moto3 BWIN Grand Prix of Austria").has("moto3"), "anchor moto3"); const start = Date.parse("2026-09-20T23:00:00Z"); const end = Date.parse("2026-09-21T00:00:00Z"); @@ -69,6 +75,57 @@ const hit = pickBestZiggoMatch(candidates, { }); assert(hit?.id === "a", "pick GT not football"); +// Moto3 samenvatting days later than live session — must still resolve via loose match +const motoCands: ZiggoEpgEvent[] = [ + { + id: "m3", + channelId: "NL_000094_019321", + title: "Moto3", + seriesName: "GP van Oostenrijk: Kwalificatie 2", + startTime: 1789815900, + endTime: 1789817700, + mergedId: "15289756|nl", + }, + { + id: "foot", + channelId: "NL_000094_019321", + title: "Eredivisie samenvatting", + seriesName: "Ajax - PSV", + startTime: 1789972200, + endTime: 1789974000, + mergedId: "999|nl", + }, +]; +const motoHit = pickBestZiggoMatch(motoCands, { + title: "Moto3 BWIN Grand Prix of Austria (samenvatting)", + startMs: Date.parse("2026-09-21T06:30:00Z"), + endMs: Date.parse("2026-09-21T07:00:00Z"), +}); +assert(motoHit?.id === "m3", `moto3 loose match got ${motoHit?.id}`); +assert(motoHit?.mergedId === "15289756|nl", "moto3 mergedId"); + +const stripped = stripLiveThumbs( + { + id: "x", + name: "Moto3", + channel: "Ziggo Sport 1", + network: "Ziggo Sport 1", + start: "2026-09-21T06:30:00Z", + stop: "2026-09-21T07:00:00Z", + imageLandscape: "https://zst1-akm-cdn-triple.akamaized.net/live/zs1/thumb.jpg?scale=640", + imageBackdrop: null, + imagePortrait: null, + logoUrl: null, + images: [ + "https://go.ziggosporttotaal.nl/assets/channels/light/ziggo_sport_1.png", + "https://zst1-akm-cdn-triple.akamaized.net/live/zs1/thumb.jpg?scale=640", + ], + }, + "https://go.ziggosporttotaal.nl/assets/channels/light/ziggo_sport_1.png" +); +assert(!isLiveChannelThumb(stripped.imageLandscape), "stripped landscape"); +assert(!(stripped.images ?? []).some(isLiveChannelThumb), "no live thumbs left"); + const keys = segmentKeysForRange(start, end); assert(keys.length >= 1, "segment keys"); assert(keys.every((k) => /^\d{14}$/.test(k)), "key format"); diff --git a/apps/master-api/src/viewer/ziggo-epg-art.ts b/apps/master-api/src/viewer/ziggo-epg-art.ts index 277f250..f3816ff 100644 --- a/apps/master-api/src/viewer/ziggo-epg-art.ts +++ b/apps/master-api/src/viewer/ziggo-epg-art.ts @@ -2,8 +2,9 @@ * Enrich Ziggo Sport events with real Gracenote art from Ziggo GO EPG * (epg-service-lite + image-service), instead of live channel thumbs. * - * Match: channel name → Ziggo channelId(s) + time overlap + title tokens. + * Match: channel → Ziggo channelId(s) + (time overlap | series family) + title tokens. * Art: ImagesEPG/EventImages/p{mergedId}_bd_h8_aa.jpg + * Fallback: strip live thumbs (never leave football-on-air as Racing art). */ const CHANNELS_URL = @@ -16,6 +17,7 @@ const IMAGE_BASE = const FETCH_TIMEOUT_MS = 12_000; const CACHE_TTL_MS = 15 * 60_000; const SIX_H_MS = 6 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; export type ZiggoEpgEvent = { id: string; @@ -28,11 +30,16 @@ export type ZiggoEpgEvent = { isSportsSportEvent?: boolean; }; -type ChannelRow = { id?: string; name?: string }; +type ChannelRow = { + id?: string; + name?: string; + logo?: { focused?: string; default?: string }; +}; type CacheState = { at: number; - byChannelName: Map; // normalized name → channelIds + byChannelName: Map; + logoByChannelId: Map; events: ZiggoEpgEvent[]; }; @@ -51,6 +58,17 @@ export function isLiveChannelThumb(url: string | null | undefined): boolean { return false; } +export function isChannelLogoUrl(url: string | null | undefined): boolean { + if (!url) return false; + const u = url.toLowerCase(); + return ( + u.includes("assets/channels") || + u.includes("channellogo") || + u.includes("/imagesepg/eventimages/ziggo_sport") || + /ziggo_sport_\d/i.test(u) + ); +} + export function needsZiggoArtEnrichment(e: { channel?: string | null; network?: string | null; @@ -60,11 +78,8 @@ export function needsZiggoArtEnrichment(e: { if (!isZiggoSportChannel(e.channel) && !isZiggoSportChannel(e.network)) return false; if (!e.imageLandscape && !(e.images?.length)) return true; if (isLiveChannelThumb(e.imageLandscape)) return true; - if ((e.images ?? []).some(isLiveChannelThumb) && isLiveChannelThumb(e.imageLandscape)) return true; - // Landscape is only a channel logo → enrich - if (e.imageLandscape && /assets\/channels|channelLogo|ziggo_sport_\d/i.test(e.imageLandscape)) { - return true; - } + if ((e.images ?? []).some(isLiveChannelThumb)) return true; + if (e.imageLandscape && isChannelLogoUrl(e.imageLandscape)) return true; return false; } @@ -81,29 +96,75 @@ export function normalizeChannelName(name: string | null | undefined): string { .trim(); } -/** Strip channel prefixes / punctuation for fuzzy title match. */ +export function isReplayOrHighlightTitle(title: string): boolean { + return /\b(samenvatting|hoogtepunten|highlights?|herhaling|replay|samenvattingen)\b/i.test( + title + ); +} + +/** Map NL/EN sports place & series aliases onto shared tokens. */ +const TOKEN_ALIASES: Record = { + austria: ["oostenrijk", "austria", "redbullring"], + oostenrijk: ["oostenrijk", "austria", "redbullring"], + austriaans: ["oostenrijk", "austria"], + moto3: ["moto3", "moto"], + moto2: ["moto2", "moto"], + motogp: ["motogp", "moto"], + moto: ["moto"], + gp: ["gp", "grandprix"], + grand: ["gp", "grandprix"], + prix: ["gp", "grandprix"], + grandprix: ["gp", "grandprix"], + bwin: ["bwin"], + imsa: ["imsa"], + nascar: ["nascar"], + indycar: ["indycar", "indy"], + gt: ["gt", "gtworld"], + world: ["gtworld", "world"], + challenge: ["gtworld", "challenge"], + zandvoort: ["zandvoort", "nederland"], + nederland: ["nederland", "netherlands"], + netherlands: ["nederland", "netherlands"], +}; + +const STOP = new Set([ + "de", + "het", + "een", + "van", + "en", + "op", + "the", + "and", + "live", + "vs", + "v", + "samenvatting", + "hoogtepunten", + "highlights", + "highlight", + "herhaling", + "replay", + "race", + "races", +]); + +/** Strip channel prefixes / punctuation; expand aliases for fuzzy match. */ export function normalizeTitleTokens(raw: string | null | undefined): Set { let s = String(raw ?? "").toLowerCase(); s = s.replace(/^ziggo sport(?:\s*\d+)?\s*:\s*/i, ""); - s = s.replace(/^espn\d?\s*:\s*/i, ""); + s = s.replace(/^espn\s*\d?\s*:\s*/i, ""); + s = s.replace(/\bmoto\s*([123])\b/g, "moto$1"); + s = s.replace(/\bgrand\s+prix\b/g, "grandprix"); + s = s.replace(/\bgt\s*world(?:\s*challenge)?\b/g, "gtworld"); s = s.replace(/[^\p{L}\p{N}\s]+/gu, " "); - const stop = new Set([ - "de", - "het", - "een", - "van", - "en", - "op", - "the", - "and", - "live", - "vs", - "v", - ]); const out = new Set(); for (const t of s.split(/\s+/)) { - if (t.length < 3 || stop.has(t)) continue; + if (t.length < 2 || STOP.has(t)) continue; + if (t.length < 3 && !/^moto\d?$|^gp$/.test(t)) continue; out.add(t); + const aliases = TOKEN_ALIASES[t]; + if (aliases) for (const a of aliases) out.add(a); } return out; } @@ -117,6 +178,30 @@ export function titleOverlapScore(a: string, b: string): number { return hit / Math.min(ta.size, tb.size); } +/** Strong series anchors — required for loose (non-time) matching. */ +export function seriesAnchorTokens(title: string): Set { + const t = normalizeTitleTokens(title); + const anchors = [ + "moto3", + "moto2", + "motogp", + "gtworld", + "imsa", + "nascar", + "indycar", + "formule1", + "f1", + "wrc", + "dtm", + "superbike", + "sbk", + ]; + const out = new Set(); + for (const a of anchors) if (t.has(a)) out.add(a); + // also raw moto3 from "Moto 3" category-ish already normalized + return out; +} + export function artUrlsFromMergedId(mergedId: string | null | undefined): { landscape: string; portrait: string; @@ -141,30 +226,86 @@ export function timesOverlap( return aStartMs < b1 && aEndMs > b0; } +function scoreCandidate( + hay: string, + c: ZiggoEpgEvent, + opts: { startMs: number; endMs: number; loose: boolean } +): number { + const zigTitle = [c.title, c.seriesName].filter(Boolean).join(" "); + let score = titleOverlapScore(hay, zigTitle); + const nl = hay.toLowerCase(); + const zl = zigTitle.toLowerCase(); + + if (nl.includes("gt world") && zl.includes("gt world")) score += 0.45; + if (/\bmoto3\b/i.test(nl) && /\bmoto3\b/i.test(zl)) score += 0.55; + if (/\bmoto2\b/i.test(nl) && /\bmoto2\b/i.test(zl)) score += 0.55; + if (/\bmotogp\b/i.test(nl) && /\bmotogp\b/i.test(zl)) score += 0.55; + if (/\bimsa\b/i.test(nl) && /\bimsa\b/i.test(zl)) score += 0.45; + if (/\bnascar\b/i.test(nl) && /\bnascar\b/i.test(zl)) score += 0.45; + + // Place: Austria ↔ Oostenrijk already via aliases; boost if both sides have it + const placeA = normalizeTitleTokens(hay); + const placeB = normalizeTitleTokens(zigTitle); + if (placeA.has("oostenrijk") && placeB.has("oostenrijk")) score += 0.25; + + if (c.isSportsSportEvent) score += 0.05; + if (/\bhoogtepunten\b/i.test(zl) && isReplayOrHighlightTitle(hay)) score += 0.1; + + if (opts.loose) { + // Prefer closer airings even in loose mode + const mid = (opts.startMs + opts.endMs) / 2; + const cMid = ((c.startTime + c.endTime) / 2) * 1000; + const dt = Math.abs(mid - cMid); + if (dt > 4 * DAY_MS) return 0; + score += Math.max(0, 0.2 - (dt / (4 * DAY_MS)) * 0.2); + } + + return score; +} + export function pickBestZiggoMatch( candidates: ZiggoEpgEvent[], opts: { title: string; startMs: number; endMs: number } ): ZiggoEpgEvent | null { + const hay = opts.title; + const highlight = isReplayOrHighlightTitle(hay); + const anchors = seriesAnchorTokens(hay); + + // Pass 1: time overlap (wider slack for summaries) + const slack = highlight ? 6 * 60 * 60_000 : 45 * 60_000; let best: ZiggoEpgEvent | null = null; let bestScore = 0; - const hay = `${opts.title}`; for (const c of candidates) { - if (!timesOverlap(opts.startMs, opts.endMs, c.startTime, c.endTime)) continue; - const zigTitle = [c.title, c.seriesName].filter(Boolean).join(" "); - let score = titleOverlapScore(hay, zigTitle); - // Boost exact-ish contains - const nl = hay.toLowerCase(); - const zl = zigTitle.toLowerCase(); - if (nl.includes("gt world") && zl.includes("gt world")) score += 0.5; - if (nl.includes("motogp") && zl.includes("motogp")) score += 0.5; - if (c.isSportsSportEvent) score += 0.05; + if (!timesOverlap(opts.startMs, opts.endMs, c.startTime, c.endTime, slack)) continue; + const score = scoreCandidate(hay, c, { ...opts, loose: false }); if (score > bestScore) { bestScore = score; best = c; } } - // Require at least one shared token (score > 0) or strong boost - return bestScore >= 0.34 ? best : null; + if (best && bestScore >= 0.3) return best; + + // Pass 2: loose series match within ±3 days — for samenvatting/herhaling or strong anchors + if (!highlight && anchors.size === 0) return bestScore >= 0.34 ? best : null; + + best = null; + bestScore = 0; + for (const c of candidates) { + const zigTitle = [c.title, c.seriesName].filter(Boolean).join(" "); + const cAnchors = seriesAnchorTokens(zigTitle); + // Require shared series anchor when loose + let sharedAnchor = false; + for (const a of anchors) if (cAnchors.has(a)) sharedAnchor = true; + if (anchors.size && !sharedAnchor) continue; + + const score = scoreCandidate(hay, c, { ...opts, loose: true }); + if (score > bestScore) { + bestScore = score; + best = c; + } + } + // Higher bar without time lock + return bestScore >= 0.45 ? best : null; } function segmentKeyUtc(ms: number): string { @@ -213,9 +354,10 @@ async function loadZiggoIndex(fromMs: number, toMs: number): Promise(CHANNELS_URL); - if (!channels?.length) return cache; // keep stale if any + if (!channels?.length) return cache; const byChannelName = new Map(); + const logoByChannelId = new Map(); for (const ch of channels) { const id = String(ch.id ?? "").trim(); const name = normalizeChannelName(ch.name); @@ -223,9 +365,12 @@ async function loadZiggoIndex(fromMs: number, toMs: number): Promise(); await Promise.all( @@ -263,7 +408,7 @@ async function loadZiggoIndex(fromMs: number, toMs: number): Promise isChannelLogoUrl(u) && !isLiveChannelThumb(u)); + if (fromImages) return fromImages; + for (const id of [...channelIdsFor(index, channel), ...channelIdsFor(index, network)]) { + const logo = index.logoByChannelId.get(id); + if (logo) return logo; + } + return null; +} + +/** Never keep a live-on-air thumb as card art (football under racing). */ +export function stripLiveThumbs( + e: T, + fallbackLogo: string | null +): T { + const cleaned = e.images.filter((u) => !isLiveChannelThumb(u)); + const landscape = + (e.imageLandscape && !isLiveChannelThumb(e.imageLandscape) + ? e.imageLandscape + : null) || + cleaned.find((u) => !isChannelLogoUrl(u)) || + fallbackLogo || + cleaned[0] || + null; + const backdrop = + e.imageBackdrop && !isLiveChannelThumb(e.imageBackdrop) + ? e.imageBackdrop + : landscape; + const images = + landscape && !cleaned.includes(landscape) ? [landscape, ...cleaned] : cleaned; + return { + ...e, + imageLandscape: landscape, + imageBackdrop: backdrop, + logoUrl: e.logoUrl || fallbackLogo, + images, + }; +} + export type EnrichableEvent = { id: string; name: string; @@ -300,7 +488,7 @@ export type EnrichableEvent = { /** * Replace live-thumbs / empty art on Ziggo Sport rows with Gracenote EPG art. - * Soft-fails (returns input) if Ziggo GO is unreachable. + * Soft-fails (returns input) if Ziggo GO is unreachable — but still strips live thumbs. */ export async function enrichZiggoGoArt(events: T[]): Promise { const targets = events.filter(needsZiggoArtEnrichment); @@ -317,18 +505,24 @@ export async function enrichZiggoGoArt(events: T[]): if (!Number.isFinite(minMs) || maxMs <= 0) return events; const index = await loadZiggoIndex(minMs, maxMs); - if (!index?.events.length) return events; return events.map((e) => { if (!needsZiggoArtEnrichment(e)) return e; + + // If Ziggo index unavailable: still remove live thumbs + if (!index?.events.length) { + return stripLiveThumbs(e, e.images.find(isChannelLogoUrl) ?? null); + } + const ids = new Set([ ...channelIdsFor(index, e.channel), ...channelIdsFor(index, e.network), ]); - if (!ids.size) return e; const startMs = Date.parse(e.start); const endMs = Date.parse(e.stop); - if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return e; + if (!ids.size || !Number.isFinite(startMs) || !Number.isFinite(endMs)) { + return stripLiveThumbs(e, channelLogoFallback(index, e.channel, e.network, e.images)); + } const candidates = index.events.filter((z) => ids.has(z.channelId)); const match = pickBestZiggoMatch(candidates, { @@ -336,9 +530,15 @@ export async function enrichZiggoGoArt(events: T[]): startMs, endMs, }); - if (!match?.mergedId) return e; + + if (!match?.mergedId) { + return stripLiveThumbs(e, channelLogoFallback(index, e.channel, e.network, e.images)); + } + const arts = artUrlsFromMergedId(match.mergedId); - if (!arts) return e; + if (!arts) { + return stripLiveThumbs(e, channelLogoFallback(index, e.channel, e.network, e.images)); + } const images = [ arts.landscape, @@ -355,10 +555,12 @@ export async function enrichZiggoGoArt(events: T[]): return { ...e, imageLandscape: arts.landscape, - imageBackdrop: e.imageBackdrop && !isLiveChannelThumb(e.imageBackdrop) - ? e.imageBackdrop - : arts.landscape, + imageBackdrop: + e.imageBackdrop && !isLiveChannelThumb(e.imageBackdrop) + ? e.imageBackdrop + : arts.landscape, imagePortrait: arts.portrait, + logoUrl: e.logoUrl || channelLogoFallback(index, e.channel, e.network, e.images), images: deduped, }; });