diff --git a/apps/master-api/package.json b/apps/master-api/package.json index ce6b678..d30008b 100644 --- a/apps/master-api/package.json +++ b/apps/master-api/package.json @@ -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", diff --git a/apps/master-api/src/viewer/espn-watch-art.test.ts b/apps/master-api/src/viewer/espn-watch-art.test.ts new file mode 100644 index 0000000..4a50841 --- /dev/null +++ b/apps/master-api/src/viewer/espn-watch-art.test.ts @@ -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"); diff --git a/apps/master-api/src/viewer/espn-watch-art.ts b/apps/master-api/src/viewer/espn-watch-art.ts new file mode 100644 index 0000000..a1f5f26 --- /dev/null +++ b/apps/master-api/src/viewer/espn-watch-art.ts @@ -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(url: string): Promise { + 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; + 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 { + if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache; + + const events: EspnWatchEvent[] = []; + const seen = new Set(); + 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(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( + events: T[] +): Promise { + 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 }; diff --git a/apps/master-api/src/viewer/schedule-events-sport.test.ts b/apps/master-api/src/viewer/schedule-events-sport.test.ts index 7978774..bc58944 100644 --- a/apps/master-api/src/viewer/schedule-events-sport.test.ts +++ b/apps/master-api/src/viewer/schedule-events-sport.test.ts @@ -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); diff --git a/apps/master-api/src/viewer/schedule-events.ts b/apps/master-api/src/viewer/schedule-events.ts index 8f31013..9f5feb2 100644 --- a/apps/master-api/src/viewer/schedule-events.ts +++ b/apps/master-api/src/viewer/schedule-events.ts @@ -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(); 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 { 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") diff --git a/apps/master-api/src/viewer/ziggo-epg-art.test.ts b/apps/master-api/src/viewer/ziggo-epg-art.test.ts new file mode 100644 index 0000000..be4e015 --- /dev/null +++ b/apps/master-api/src/viewer/ziggo-epg-art.test.ts @@ -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"); diff --git a/apps/master-api/src/viewer/ziggo-epg-art.ts b/apps/master-api/src/viewer/ziggo-epg-art.ts new file mode 100644 index 0000000..277f250 --- /dev/null +++ b/apps/master-api/src/viewer/ziggo-epg-art.ts @@ -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; // 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 { + 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(); + 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(); + 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(url: string): Promise { + 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 { + if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache; + + const channels = await fetchJson(CHANNELS_URL); + if (!channels?.length) return cache; // keep stale if any + + const byChannelName = new Map(); + 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(); + await Promise.all( + keys.map(async (key) => { + const seg = await fetchJson<{ + entries?: Array<{ + channelId?: string; + events?: Array>; + }>; + }>(`${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(events: T[]): Promise { + 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(); + 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, + }; + }); +}