/** * 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 };