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.
This commit is contained in:
Jos Vooges | STH 2026-09-21 02:24:39 +02:00
parent 5a990ae346
commit 5db9658e84
2 changed files with 309 additions and 50 deletions

View file

@ -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");

View file

@ -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<string, string[]>; // normalized name → channelIds
byChannelName: Map<string, string[]>;
logoByChannelId: Map<string, string>;
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,13 +96,38 @@ export function normalizeChannelName(name: string | null | undefined): string {
.trim();
}
/** Strip channel prefixes / punctuation for fuzzy title match. */
export function normalizeTitleTokens(raw: string | null | undefined): Set<string> {
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(/[^\p{L}\p{N}\s]+/gu, " ");
const stop = new Set([
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<string, string[]> = {
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",
@ -99,11 +139,32 @@ export function normalizeTitleTokens(raw: string | null | undefined): Set<string
"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<string> {
let s = String(raw ?? "").toLowerCase();
s = s.replace(/^ziggo sport(?:\s*\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 out = new Set<string>();
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<string> {
const t = normalizeTitleTokens(title);
const anchors = [
"moto3",
"moto2",
"motogp",
"gtworld",
"imsa",
"nascar",
"indycar",
"formule1",
"f1",
"wrc",
"dtm",
"superbike",
"sbk",
];
const out = new Set<string>();
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<CacheState
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache;
const channels = await fetchJson<ChannelRow[]>(CHANNELS_URL);
if (!channels?.length) return cache; // keep stale if any
if (!channels?.length) return cache;
const byChannelName = new Map<string, string[]>();
const logoByChannelId = new Map<string, string>();
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<CacheState
const list = byChannelName.get(name) ?? [];
if (!list.includes(id)) list.push(id);
byChannelName.set(name, list);
const logo = ch.logo?.focused || ch.logo?.default;
if (logo) logoByChannelId.set(id, logo);
}
const keys = segmentKeysForRange(fromMs, toMs);
// Extra ±3 days so samenvatting can resolve earlier race airings
const keys = segmentKeysForRange(fromMs - 3 * DAY_MS, toMs + DAY_MS);
const events: ZiggoEpgEvent[] = [];
const seen = new Set<string>();
await Promise.all(
@ -263,7 +408,7 @@ async function loadZiggoIndex(fromMs: number, toMs: number): Promise<CacheState
})
);
cache = { at: Date.now(), byChannelName, events };
cache = { at: Date.now(), byChannelName, logoByChannelId, events };
return cache;
}
@ -272,7 +417,6 @@ function channelIdsFor(index: CacheState, channel: string | null | undefined): s
if (!n) return [];
const exact = index.byChannelName.get(n);
if (exact?.length) return exact;
// "Ziggo Sport" without number → all Ziggo Sport*
if (n === "ziggo sport") {
const all: string[] = [];
for (const [name, ids] of index.byChannelName) {
@ -283,6 +427,50 @@ function channelIdsFor(index: CacheState, channel: string | null | undefined): s
return [];
}
function channelLogoFallback(
index: CacheState,
channel: string | null | undefined,
network: string | null | undefined,
existingImages: string[]
): string | null {
const fromImages = existingImages.find((u) => 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<T extends EnrichableEvent>(
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<T extends EnrichableEvent>(events: T[]): Promise<T[]> {
const targets = events.filter(needsZiggoArtEnrichment);
@ -317,18 +505,24 @@ export async function enrichZiggoGoArt<T extends EnrichableEvent>(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<T extends EnrichableEvent>(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<T extends EnrichableEvent>(events: T[]):
return {
...e,
imageLandscape: arts.landscape,
imageBackdrop: e.imageBackdrop && !isLiveChannelThumb(e.imageBackdrop)
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,
};
});