Enrich Ziggo/ESPN live-event art in master-api from official EPG/Watch APIs.

Replace FAAB live-channel thumbs with Gracenote (Ziggo GO) and ESPN artwork so all clients get correct Racing/ESPN images without app changes.
This commit is contained in:
Jos Vooges | STH 2026-09-21 02:07:17 +02:00
parent bfeb270459
commit 5a990ae346
7 changed files with 826 additions and 23 deletions

View file

@ -6,7 +6,7 @@
"dev": "tsx watch src/app.ts",
"build": "tsc",
"start": "node dist/app.js",
"test": "tsx src/metadata/filename-parser.test.ts && tsx src/metadata/title-locale.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts && tsx src/viewer/schedule-events-sport.test.ts",
"test": "tsx src/metadata/filename-parser.test.ts && tsx src/metadata/title-locale.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts && tsx src/viewer/schedule-events-sport.test.ts && tsx src/viewer/ziggo-epg-art.test.ts && tsx src/viewer/espn-watch-art.test.ts",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",

View file

@ -0,0 +1,69 @@
import {
isEspnRelated,
needsEspnArtEnrichment,
normalizeEspnArtUrl,
normalizeEspnChannel,
parseEspnChannelHint,
pickBestEspnMatch,
type EspnWatchEvent,
} from "./espn-watch-art";
function assert(cond: unknown, msg: string) {
if (!cond) throw new Error(msg);
}
assert(isEspnRelated({ provider: "espn" }), "provider espn");
assert(isEspnRelated({ provider: "odido", name: "ESPN4: NFL Blitz", channel: "ESPN4" }), "odido espn");
assert(!isEspnRelated({ provider: "ziggosport", name: "GT World", channel: "Ziggo Sport 1" }), "not ziggo");
assert(
needsEspnArtEnrichment({
provider: "odido",
channel: "ESPN",
name: "ESPN: Dit was het Weekend",
imageLandscape: null,
images: [],
}),
"needs empty"
);
assert(parseEspnChannelHint("ESPN2 • Eurojackpot Vrouwen Eredivisie") === "ESPN2", "hint espn2");
assert(normalizeEspnChannel("ESPN4") === "ESPN4", "ch espn4");
assert(normalizeEspnChannel("ESPN") === "ESPN", "ch espn");
const art = normalizeEspnArtUrl(
"https://artwork.api.espn.com/artwork/collections/airings/abc/default?width=640&apikey=1ngjw23osgcis1i1vbj96lmfqs"
);
assert(art.includes("width=960"), "widen");
const candidates: EspnWatchEvent[] = [
{
id: "1",
name: "FC Dordrecht - Almere City FC",
subtitle: "ESPN4 • Herhaling • Keuken Kampioen Divisie",
catalog: "Soccer,Dutch Keuken Kampioen Divisie",
utcMs: Date.parse("2026-09-26T03:55:00+02:00"),
imageHref:
"https://artwork.api.espn.com/artwork/collections/airings/955dd918/default?width=640&apikey=x",
channelHint: "ESPN4",
},
{
id: "2",
name: "Jong Oranje - Jong Noorwegen",
subtitle: "ESPN • Voetbal",
catalog: "Soccer",
utcMs: Date.parse("2026-09-25T18:30:00+02:00"),
imageHref: "https://artwork.api.espn.com/artwork/collections/airings/other/default?width=640",
channelHint: "ESPN",
},
];
const hit = pickBestEspnMatch(candidates, {
title: "ESPN4: Keuken Kampioen Divisie Samenvatting",
startMs: Date.parse("2026-09-26T01:55:00Z"),
endMs: Date.parse("2026-09-26T02:55:00Z"),
channel: "ESPN4",
});
assert(hit?.id === "1", "pick KKD on ESPN4");
console.log("espn-watch-art tests ok");

View file

@ -0,0 +1,251 @@
/**
* Enrich ESPN / Odido-ESPN events with artwork from ESPN Watch NL product API.
* Match: ESPN•channel hint + time proximity + title/subtitle tokens.
*/
import {
normalizeTitleTokens,
titleOverlapScore,
type EnrichableEvent,
} from "./ziggo-epg-art";
const BUCKET_IDS = ["39622"]; // Komende Wedstrijden (NL watch)
const API_KEY = "1ngjw23osgcis1i1vbj96lmfqs"; // public web apikey from espn.nl
const FETCH_TIMEOUT_MS = 12_000;
const CACHE_TTL_MS = 15 * 60_000;
export type EspnWatchEvent = {
id: string;
name: string;
subtitle: string;
catalog: string;
utcMs: number;
imageHref: string;
channelHint: string | null; // ESPN / ESPN2 / …
};
type CacheState = {
at: number;
events: EspnWatchEvent[];
};
let cache: CacheState | null = null;
export function invalidateEspnWatchArtCache() {
cache = null;
}
export function isEspnRelated(e: {
provider?: string;
channel?: string | null;
network?: string | null;
name?: string;
}): boolean {
const p = (e.provider ?? "").toLowerCase();
if (p === "espn") return true;
const blob = `${e.channel ?? ""} ${e.network ?? ""} ${e.name ?? ""}`.toLowerCase();
return /\bespn\s*\d?\b/.test(blob);
}
export function needsEspnArtEnrichment(e: {
provider?: string;
channel?: string | null;
network?: string | null;
name?: string;
imageLandscape?: string | null;
images?: string[];
}): boolean {
if (!isEspnRelated(e)) return false;
if (!e.imageLandscape && !(e.images?.length)) return true;
// Replace weak logos if present without real art
if (e.imageLandscape && /logo|assets\/channels/i.test(e.imageLandscape) && !(e.images ?? []).some((u) => /artwork\.api\.espn|artwork\.espncdn/i.test(u))) {
return true;
}
return false;
}
export function parseEspnChannelHint(subtitle: string | null | undefined): string | null {
const s = String(subtitle ?? "");
const m = s.match(/\b(ESPN\s*\d?)\b/i);
if (!m) return null;
return m[1].replace(/\s+/g, "").toUpperCase().replace(/ESPN$/, "ESPN");
}
export function normalizeEspnChannel(name: string | null | undefined): string | null {
const s = String(name ?? "").trim();
if (!s) return null;
const m = s.match(/\b(ESPN\s*\d?)\b/i);
if (!m) return null;
return m[1].replace(/\s+/g, "").toUpperCase().replace(/ESPN$/, "ESPN");
}
/** Widen imageHref to a useful card width. */
export function normalizeEspnArtUrl(href: string): string {
try {
const u = new URL(href);
if (!u.searchParams.has("width")) u.searchParams.set("width", "960");
else u.searchParams.set("width", "960");
if (!u.searchParams.has("apikey") && /artwork\.api\.espn\.com/i.test(u.host)) {
u.searchParams.set("apikey", API_KEY);
}
return u.toString();
} catch {
return href;
}
}
export function pickBestEspnMatch(
candidates: EspnWatchEvent[],
opts: {
title: string;
startMs: number;
endMs: number;
channel?: string | null;
network?: string | null;
}
): EspnWatchEvent | null {
const wantCh =
normalizeEspnChannel(opts.channel) ||
normalizeEspnChannel(opts.network) ||
normalizeEspnChannel(opts.title);
let best: EspnWatchEvent | null = null;
let bestScore = 0;
for (const c of candidates) {
if (!c.imageHref) continue;
// Time: prefer start within window; summaries may be hours offset — allow 12h
const mid = (opts.startMs + opts.endMs) / 2;
const dt = Math.abs(c.utcMs - mid);
if (dt > 12 * 60 * 60 * 1000) continue;
const hay = `${opts.title}`;
const zig = `${c.name} ${c.subtitle} ${c.catalog}`;
let score = titleOverlapScore(hay, zig);
if (wantCh && c.channelHint) {
if (wantCh === c.channelHint) score += 0.25;
else score -= 0.15;
}
// Time proximity bonus (closer = better)
score += Math.max(0, 0.3 - dt / (12 * 60 * 60 * 1000) * 0.3);
if (score > bestScore) {
bestScore = score;
best = c;
}
}
return bestScore >= 0.28 ? best : null;
}
async function fetchJson<T>(url: string): Promise<T | null> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: ac.signal,
headers: {
Accept: "application/json",
"User-Agent": "VMC-master/1.0",
},
});
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
function walkEvents(node: unknown, out: EspnWatchEvent[]) {
if (!node || typeof node !== "object") return;
if (Array.isArray(node)) {
node.forEach((x) => walkEvents(x, out));
return;
}
const o = node as Record<string, unknown>;
if (o.isEvent === true && o.name && o.imageHref) {
const utcRaw = String(o.utc ?? o.date ?? "");
const utcMs = Date.parse(utcRaw);
if (Number.isFinite(utcMs)) {
const catalog = Array.isArray(o.catalog)
? (o.catalog as Array<{ name?: string }>)
.map((c) => c.name ?? "")
.filter(Boolean)
.join(", ")
: "";
out.push({
id: String(o.id ?? ""),
name: String(o.name),
subtitle: String(o.subtitle ?? ""),
catalog,
utcMs,
imageHref: String(o.imageHref),
channelHint: parseEspnChannelHint(String(o.subtitle ?? "")),
});
}
}
for (const v of Object.values(o)) walkEvents(v, out);
}
async function loadEspnIndex(): Promise<CacheState | null> {
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache;
const events: EspnWatchEvent[] = [];
const seen = new Set<string>();
await Promise.all(
BUCKET_IDS.map(async (bucketId) => {
const url =
`https://watch.product.api.espn.com/api/product/v3/watchespn/web/bucket` +
`?lang=nl&deviceBrand=web&bucketId=${bucketId}&countryCode=NL&tz=UTC%2B0200&features=pbov7`;
const j = await fetchJson<unknown>(url);
if (!j) return;
const batch: EspnWatchEvent[] = [];
walkEvents(j, batch);
for (const e of batch) {
if (!e.id || seen.has(e.id)) continue;
seen.add(e.id);
events.push(e);
}
})
);
if (!events.length) return cache;
cache = { at: Date.now(), events };
return cache;
}
export async function enrichEspnWatchArt<T extends EnrichableEvent & { provider?: string }>(
events: T[]
): Promise<T[]> {
const targets = events.filter(needsEspnArtEnrichment);
if (!targets.length) return events;
const index = await loadEspnIndex();
if (!index?.events.length) return events;
return events.map((e) => {
if (!needsEspnArtEnrichment(e)) return e;
const startMs = Date.parse(e.start);
const endMs = Date.parse(e.stop);
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return e;
const match = pickBestEspnMatch(index.events, {
title: e.name,
startMs,
endMs,
channel: e.channel,
network: e.network,
});
if (!match) return e;
const art = normalizeEspnArtUrl(match.imageHref);
const images = [art, ...e.images.filter((u) => u !== art)];
return {
...e,
imageLandscape: art,
imageBackdrop: e.imageBackdrop || art,
images,
};
});
}
// re-export for tests that want token helpers
export { normalizeTitleTokens, titleOverlapScore };

View file

@ -50,6 +50,8 @@ expectBucket({ name: "NBA Today" }, "basketball");
expectBucket({ name: "Ligue 1: Monaco - Lens" }, "football");
expectBucket({ name: "PDC Darts: World Series of Darts Finals 2026" }, "darts");
expectBucket({ name: "Some snooker final" }, "snooker");
expectBucket({ name: "Ziggo Sport: GT World Challenge" }, "racing");
expectBucket({ category: "Live", name: "GT World Challenge: Nederland Race 1 (hoogtepunten)" }, "racing");
function expectFormat(url: string, format: "hls" | "dash") {
const got = detectLiveManifestFormat(url);

View file

@ -2,6 +2,8 @@ import type { Config } from "../config";
import { AppError } from "../security/errors";
import { getIptvSettings, normalizeProviderIdList } from "../settings/iptv";
import { prisma } from "../database/client";
import { enrichZiggoGoArt, invalidateZiggoEpgArtCache } from "./ziggo-epg-art";
import { enrichEspnWatchArt, invalidateEspnWatchArtCache } from "./espn-watch-art";
const CACHE_TTL_MS = 60_000;
const FETCH_TIMEOUT_MS = 20_000;
@ -118,7 +120,7 @@ const CATEGORY_NOISE = new Set([
/** eventType / league → bucket (specific sports before broad "championship") */
const EVENT_TYPE_BUCKET: Array<{ re: RegExp; bucket: SportBucketId }> = [
{ re: /\b(formula\s*[1-4e]|f1|f2|f3|f4|motogp|indycar|imsa|nascar|wec|dtm|rally|ferrari\s*challenge|goodwood|festival\s*of\s*speed)\b/i, bucket: "racing" },
{ re: /\b(formula\s*[1-4e]|f1|f2|f3|f4|motogp|indycar|imsa|nascar|wec|dtm|rally|ferrari\s*challenge|goodwood|festival\s*of\s*speed|gt\s*world|world\s*challenge|superbike|sbk)\b/i, bucket: "racing" },
{ re: /\b(atp|wta|davis\s*cup|billie\s*jean|grand\s*slam|wimbledon|roland\s*garros|us\s*open|australian\s*open)\b/i, bucket: "tennis" },
{ re: /\b(pga|lpga|european\s*(pga\s*)?tour|masters|ryder\s*cup|dp\s*world|liv\s*golf)\b/i, bucket: "golf" },
{ re: /\b(pdc|darts|world\s*matchplay|uk\s*open)\b/i, bucket: "darts" },
@ -133,7 +135,7 @@ const EVENT_TYPE_BUCKET: Array<{ re: RegExp; bucket: SportBucketId }> = [
const NAME_KEYWORD_BUCKET: Array<{ re: RegExp; bucket: SportBucketId }> = [
{ re: /\b(voetbal|football|soccer|eredivisie|premier\s*league|bundesliga|champions\s*league|europa\s*league)\b/i, bucket: "football" },
{ re: /\b(formule\s*1|formula\s*[1-4]|f1\b|motogp|nascar|indycar|imsa|race\s*caf|racing|motorsport|grand\s*prix)\b/i, bucket: "racing" },
{ re: /\b(formule\s*1|formula\s*[1-4]|f1\b|motogp|nascar|indycar|imsa|race\s*caf|racing|motorsport|grand\s*prix|gt\s*world|world\s*challenge|superbike|sbk)\b/i, bucket: "racing" },
{ re: /\b(tennis|atp|wta|davis\s*cup)\b/i, bucket: "tennis" },
{ re: /\b(golf|pga\b|lpga)\b/i, bucket: "golf" },
{ re: /\b(darts|pdc)\b/i, bucket: "darts" },
@ -167,12 +169,14 @@ export function resolveSportBucket(input: {
eventType?: string | null;
name?: string | null;
subtitle?: string | null;
description?: string | null;
}): { sportBucket: SportBucketId; sportLabel: string } {
const category = str(input.category) || null;
const eventType = str(input.eventType) || null;
const name = str(input.name) || null;
const subtitle = str(input.subtitle) || null;
const hayName = [name, subtitle].filter(Boolean).join(" ");
const description = str(input.description) || null;
const hayName = [name, subtitle, description].filter(Boolean).join(" ");
// Explicit non-sport categories
const catLower = (category ?? "").toLowerCase();
@ -328,7 +332,24 @@ function isPureLogoUrl(url: string): boolean {
return false;
}
function scoreLandscape(url: string): number {
/** Ziggo live channel snapshot — shows whatever is on-air now, not event poster art. */
function isLiveChannelThumb(url: string): boolean {
const u = url.toLowerCase();
return u.includes("akamaized.net/live") && u.includes("thumb");
}
function isReplayOrHighlightTitle(name: string, category?: string | null): boolean {
return /\b(hoogtepunten|highlights?|samenvatting|samenvattingen|herhaling|replay)\b/i.test(
`${name} ${category ?? ""}`
);
}
type ImageScoreOpts = {
/** Only live broadcasts may use generic channel live-thumbs as card art. */
allowLiveThumbs?: boolean;
};
function scoreLandscape(url: string, opts: ImageScoreOpts = {}): number {
const u = url.toLowerCase();
let score = 0;
// Prime/Amazon: card cover = BOXART-16X9 (composite with corner logo overlay is OK)
@ -336,10 +357,15 @@ function scoreLandscape(url: string): number {
if (u.includes("hero-16x9") || u.includes("hero_16x9")) score += 35;
if (u.includes("boxart-4x3") || u.includes("boxart_4x3")) score -= 25;
// Ziggo Sport FAAB live thumbs (akamaized …/live/…/thumb.jpg?scale=640x360)
if (u.includes("thumb")) score += 55;
if (u.includes("akamaized.net/live")) score += 50;
if (u.includes("scale=")) score += 35;
if (u.includes("640x360") || u.includes("scale=640")) score += 30;
if (opts.allowLiveThumbs !== false) {
if (u.includes("thumb")) score += 55;
if (u.includes("akamaized.net/live")) score += 50;
if (u.includes("scale=")) score += 35;
if (u.includes("640x360") || u.includes("scale=640")) score += 30;
} else if (isLiveChannelThumb(url) || (u.includes("thumb") && u.includes("akamaized"))) {
// Highlights/replays must not inherit the current on-air football snap
score -= 150;
}
if (u.includes("_l.") || u.includes("_l?")) score += 50;
if (u.includes("width=960") && u.includes("height=540")) score += 40;
if (u.includes("width=1280") || u.includes("height=720")) score += 30;
@ -358,15 +384,19 @@ function scoreLandscape(url: string): number {
}
/** Detail/hero full-bleed: prefer clean HERO-16X9 over card BOXART. */
function scoreBackdrop(url: string): number {
function scoreBackdrop(url: string, opts: ImageScoreOpts = {}): number {
const u = url.toLowerCase();
let score = 0;
if (u.includes("hero-16x9") || u.includes("hero_16x9")) score += 95;
if (u.includes("boxart-16x9") || u.includes("boxart_16x9")) score += 40;
if (u.includes("boxart-4x3") || u.includes("boxart_4x3")) score -= 20;
if (u.includes("thumb")) score += 45;
if (u.includes("akamaized.net/live")) score += 40;
if (u.includes("scale=")) score += 25;
if (opts.allowLiveThumbs !== false) {
if (u.includes("thumb")) score += 45;
if (u.includes("akamaized.net/live")) score += 40;
if (u.includes("scale=")) score += 25;
} else if (isLiveChannelThumb(url) || (u.includes("thumb") && u.includes("akamaized"))) {
score -= 150;
}
if (u.includes("_l.") || u.includes("_l?")) score += 40;
if (u.includes("width=1280") || u.includes("height=720") || u.includes("1920")) score += 15;
if (u.includes(".jpg") || u.includes(".jpeg") || u.includes(".webp")) score += 5;
@ -441,16 +471,14 @@ function donorImageScore(e: ScheduleEventPublic): number {
/**
* Odido (and others) often publish the same channel without images while
* ziggosport/FAAB has thumbs for that channel. Fill gaps by channel/network key.
* ziggosport/FAAB has thumbs for that channel. Fill gaps by sport|channel —
* never copy football art onto racing (same Ziggo channel).
*/
function fillMissingImages(events: ScheduleEventPublic[]): ScheduleEventPublic[] {
const donors = new Map<string, ScheduleEventPublic>();
for (const e of events) {
if (!e.imageLandscape && !e.logoUrl && e.images.length === 0) continue;
const keys = [e.channel, e.network]
.map(normalizeChannelKey)
.filter((k) => k.length > 0);
for (const key of keys) {
for (const key of donorImageKeys(e)) {
const existing = donors.get(key);
if (!existing || donorImageScore(e) > donorImageScore(existing)) {
donors.set(key, e);
@ -460,11 +488,8 @@ function fillMissingImages(events: ScheduleEventPublic[]): ScheduleEventPublic[]
return events.map((e) => {
if (e.imageLandscape || e.images.length > 0) return e;
const keys = [e.channel, e.network]
.map(normalizeChannelKey)
.filter((k) => k.length > 0);
let donor: ScheduleEventPublic | undefined;
for (const key of keys) {
for (const key of donorImageKeys(e)) {
const hit = donors.get(key);
if (hit && hit.id !== e.id) {
donor = hit;
@ -483,6 +508,16 @@ function fillMissingImages(events: ScheduleEventPublic[]): ScheduleEventPublic[]
});
}
function donorImageKeys(e: ScheduleEventPublic): string[] {
const sport = (e.sportBucket || "other").toLowerCase();
const keys: string[] = [];
for (const raw of [e.channel, e.network]) {
const ch = normalizeChannelKey(raw);
if (ch) keys.push(`${sport}|${ch}`);
}
return keys;
}
function bestByScore(urls: string[], scorer: (u: string) => number): string | null {
let best: string | null = null;
let bestScore = -Infinity;
@ -746,6 +781,8 @@ async function loadRawEvents(config: Config): Promise<CacheEntry> {
export function invalidateScheduleEventsCache() {
cache = null;
invalidateZiggoEpgArtCache();
invalidateEspnWatchArtCache();
}
/** Distinct providers uit de ruwe feed (incl. ended) — voor admin toggles. */
@ -787,7 +824,10 @@ export async function listScheduleEvents(
return Date.parse(a.start) - Date.parse(b.start);
});
const disabledGlobal = await globalDisabledProviders(config);
const events = filterByAccess(fillMissingImages(mapped), disabledGlobal, access);
// Ziggo Gracenote + ESPN Watch art, then sport|channel gap-fill
const enriched = await enrichEspnWatchArt(await enrichZiggoGoArt(mapped));
const withArt = fillMissingImages(enriched);
const events = filterByAccess(withArt, disabledGlobal, access);
const providers = [...new Set(events.map((e) => e.provider.toLowerCase()))].sort((a, b) =>
a.localeCompare(b, "nl")

View file

@ -0,0 +1,76 @@
import {
artUrlsFromMergedId,
isLiveChannelThumb,
isZiggoSportChannel,
needsZiggoArtEnrichment,
normalizeTitleTokens,
pickBestZiggoMatch,
segmentKeysForRange,
titleOverlapScore,
timesOverlap,
type ZiggoEpgEvent,
} from "./ziggo-epg-art";
function assert(cond: unknown, msg: string) {
if (!cond) throw new Error(msg);
}
assert(isLiveChannelThumb("https://zst1-akm-cdn-triple.akamaized.net/live/zs1/thumb.jpg?scale=640x360"), "akamai live");
assert(isLiveChannelThumb("https://staticqbr-prod-nl.gnp.cloud.ziggogo.tv/image-service/latest/NL_000094_019321"), "latest");
assert(!isLiveChannelThumb("https://staticqbr-prod-nl.gnp.cloud.ziggogo.tv/image-service/ImagesEPG/EventImages/p24692584_bd_h8_aa.jpg"), "gracenote ok");
assert(isZiggoSportChannel("Ziggo Sport 1"), "zs1");
assert(isZiggoSportChannel("Ziggo Sport"), "zs");
assert(!isZiggoSportChannel("ESPN"), "espn");
assert(
needsZiggoArtEnrichment({
channel: "Ziggo Sport 1",
imageLandscape: "https://zst1-akm-cdn-triple.akamaized.net/live/zs1/thumb.jpg?scale=640",
images: [],
}),
"needs enrich live thumb"
);
const arts = artUrlsFromMergedId("24692584|nl");
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");
const start = Date.parse("2026-09-20T23:00:00Z");
const end = Date.parse("2026-09-21T00:00:00Z");
assert(timesOverlap(start, end, 1789945200, 1789948800), "time overlap");
const candidates: ZiggoEpgEvent[] = [
{
id: "a",
channelId: "NL_000094_019321",
title: "GT World Challenge Hoogtepunten",
seriesName: "Nederland: Race 2",
startTime: 1789945200,
endTime: 1789948800,
mergedId: "24692584|nl",
},
{
id: "b",
channelId: "NL_000094_019321",
title: "Voetbalavond",
startTime: 1789945200,
endTime: 1789948800,
mergedId: "1|nl",
},
];
const hit = pickBestZiggoMatch(candidates, {
title: "GT World Challenge: Nederland Race 2 (hoogtepunten)",
startMs: start,
endMs: end,
});
assert(hit?.id === "a", "pick GT not football");
const keys = segmentKeysForRange(start, end);
assert(keys.length >= 1, "segment keys");
assert(keys.every((k) => /^\d{14}$/.test(k)), "key format");
console.log("ziggo-epg-art tests ok");

View file

@ -0,0 +1,365 @@
/**
* 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.
* Art: ImagesEPG/EventImages/p{mergedId}_bd_h8_aa.jpg
*/
const CHANNELS_URL =
"https://spark-prod-nl.gnp.cloud.ziggogo.tv/nld/web/linear-service/v2/channels?cityId=65535&language=nl&productClass=Orion-DASH&platform=web";
const EPG_SEGMENT_BASE =
"https://staticqbr-prod-nl.gnp.cloud.ziggogo.tv/nld/web/epg-service-lite/nl/nl/events/segments";
const IMAGE_BASE =
"https://staticqbr-prod-nl.gnp.cloud.ziggogo.tv/image-service/ImagesEPG/EventImages";
const FETCH_TIMEOUT_MS = 12_000;
const CACHE_TTL_MS = 15 * 60_000;
const SIX_H_MS = 6 * 60 * 60 * 1000;
export type ZiggoEpgEvent = {
id: string;
channelId: string;
title: string;
seriesName?: string;
startTime: number; // unix sec
endTime: number;
mergedId?: string;
isSportsSportEvent?: boolean;
};
type ChannelRow = { id?: string; name?: string };
type CacheState = {
at: number;
byChannelName: Map<string, string[]>; // normalized name → channelIds
events: ZiggoEpgEvent[];
};
let cache: CacheState | null = null;
export function invalidateZiggoEpgArtCache() {
cache = null;
}
export function isLiveChannelThumb(url: string | null | undefined): boolean {
if (!url) return false;
const u = url.toLowerCase();
if (u.includes("akamaized.net/live")) return true;
if (u.includes("/image-service/latest/")) return true;
if (u.includes("thumb.jpg") && (u.includes("scale=") || u.includes("/live/"))) return true;
return false;
}
export function needsZiggoArtEnrichment(e: {
channel?: string | null;
network?: string | null;
imageLandscape?: string | null;
images?: string[];
}): boolean {
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;
}
return false;
}
export function isZiggoSportChannel(name: string | null | undefined): boolean {
const n = normalizeChannelName(name);
if (!n) return false;
return n === "ziggo sport" || n.startsWith("ziggo sport ");
}
export function normalizeChannelName(name: string | null | undefined): string {
return String(name ?? "")
.toLowerCase()
.replace(/\s+/g, " ")
.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([
"de",
"het",
"een",
"van",
"en",
"op",
"the",
"and",
"live",
"vs",
"v",
]);
const out = new Set<string>();
for (const t of s.split(/\s+/)) {
if (t.length < 3 || stop.has(t)) continue;
out.add(t);
}
return out;
}
export function titleOverlapScore(a: string, b: string): number {
const ta = normalizeTitleTokens(a);
const tb = normalizeTitleTokens(b);
if (!ta.size || !tb.size) return 0;
let hit = 0;
for (const t of ta) if (tb.has(t)) hit++;
return hit / Math.min(ta.size, tb.size);
}
export function artUrlsFromMergedId(mergedId: string | null | undefined): {
landscape: string;
portrait: string;
} | null {
const num = String(mergedId ?? "").split("|")[0]?.trim();
if (!/^\d+$/.test(num)) return null;
return {
landscape: `${IMAGE_BASE}/p${num}_bd_h8_aa.jpg?w=960`,
portrait: `${IMAGE_BASE}/p${num}_b_v12_aa.jpg?w=400`,
};
}
export function timesOverlap(
aStartMs: number,
aEndMs: number,
bStartSec: number,
bEndSec: number,
slackMs = 30 * 60_000
): boolean {
const b0 = bStartSec * 1000 - slackMs;
const b1 = bEndSec * 1000 + slackMs;
return aStartMs < b1 && aEndMs > b0;
}
export function pickBestZiggoMatch(
candidates: ZiggoEpgEvent[],
opts: { title: string; startMs: number; endMs: number }
): ZiggoEpgEvent | null {
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 (score > bestScore) {
bestScore = score;
best = c;
}
}
// Require at least one shared token (score > 0) or strong boost
return bestScore >= 0.34 ? best : null;
}
function segmentKeyUtc(ms: number): string {
const d = new Date(Math.floor(ms / SIX_H_MS) * SIX_H_MS);
const p = (n: number, w = 2) => String(n).padStart(w, "0");
return (
`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
`${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
);
}
export function segmentKeysForRange(fromMs: number, toMs: number): string[] {
const keys: string[] = [];
const seen = new Set<string>();
let t = Math.floor(fromMs / SIX_H_MS) * SIX_H_MS - SIX_H_MS;
const end = toMs + SIX_H_MS;
while (t <= end) {
const k = segmentKeyUtc(t);
if (!seen.has(k)) {
seen.add(k);
keys.push(k);
}
t += SIX_H_MS;
}
return keys;
}
async function fetchJson<T>(url: string): Promise<T | null> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: ac.signal,
headers: { Accept: "application/json", "User-Agent": "VMC-master/1.0" },
});
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
async function loadZiggoIndex(fromMs: number, toMs: number): Promise<CacheState | null> {
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
const byChannelName = new Map<string, string[]>();
for (const ch of channels) {
const id = String(ch.id ?? "").trim();
const name = normalizeChannelName(ch.name);
if (!id || !name) continue;
const list = byChannelName.get(name) ?? [];
if (!list.includes(id)) list.push(id);
byChannelName.set(name, list);
}
const keys = segmentKeysForRange(fromMs, toMs);
const events: ZiggoEpgEvent[] = [];
const seen = new Set<string>();
await Promise.all(
keys.map(async (key) => {
const seg = await fetchJson<{
entries?: Array<{
channelId?: string;
events?: Array<Record<string, unknown>>;
}>;
}>(`${EPG_SEGMENT_BASE}/${key}`);
if (!seg?.entries) return;
for (const entry of seg.entries) {
const channelId = String(entry.channelId ?? "");
for (const raw of entry.events ?? []) {
const id = String(raw.id ?? "");
const title = String(raw.title ?? "");
const startTime = Number(raw.startTime);
const endTime = Number(raw.endTime);
if (!id || !title || !Number.isFinite(startTime) || !Number.isFinite(endTime)) continue;
const dedupe = `${channelId}|${id}`;
if (seen.has(dedupe)) continue;
seen.add(dedupe);
events.push({
id,
channelId,
title,
seriesName: raw.seriesName ? String(raw.seriesName) : undefined,
startTime,
endTime,
mergedId: raw.mergedId ? String(raw.mergedId) : undefined,
isSportsSportEvent: raw.isSportsSportEvent === true,
});
}
}
})
);
cache = { at: Date.now(), byChannelName, events };
return cache;
}
function channelIdsFor(index: CacheState, channel: string | null | undefined): string[] {
const n = normalizeChannelName(channel);
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) {
if (name === "ziggo sport" || /^ziggo sport \d+$/.test(name)) all.push(...ids);
}
return [...new Set(all)];
}
return [];
}
export type EnrichableEvent = {
id: string;
name: string;
channel: string | null;
network: string | null;
start: string;
stop: string;
imageLandscape: string | null;
imageBackdrop: string | null;
imagePortrait: string | null;
logoUrl: string | null;
images: string[];
sportBucket?: string;
};
/**
* Replace live-thumbs / empty art on Ziggo Sport rows with Gracenote EPG art.
* Soft-fails (returns input) if Ziggo GO is unreachable.
*/
export async function enrichZiggoGoArt<T extends EnrichableEvent>(events: T[]): Promise<T[]> {
const targets = events.filter(needsZiggoArtEnrichment);
if (!targets.length) return events;
let minMs = Infinity;
let maxMs = 0;
for (const e of targets) {
const a = Date.parse(e.start);
const b = Date.parse(e.stop);
if (Number.isFinite(a)) minMs = Math.min(minMs, a);
if (Number.isFinite(b)) maxMs = Math.max(maxMs, b);
}
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;
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;
const candidates = index.events.filter((z) => ids.has(z.channelId));
const match = pickBestZiggoMatch(candidates, {
title: e.name,
startMs,
endMs,
});
if (!match?.mergedId) return e;
const arts = artUrlsFromMergedId(match.mergedId);
if (!arts) return e;
const images = [
arts.landscape,
arts.portrait,
...e.images.filter((u) => !isLiveChannelThumb(u)),
];
const seen = new Set<string>();
const deduped = images.filter((u) => {
if (seen.has(u)) return false;
seen.add(u);
return true;
});
return {
...e,
imageLandscape: arts.landscape,
imageBackdrop: e.imageBackdrop && !isLiveChannelThumb(e.imageBackdrop)
? e.imageBackdrop
: arts.landscape,
imagePortrait: arts.portrait,
images: deduped,
};
});
}