Replace FAAB live-channel thumbs with Gracenote (Ziggo GO) and ESPN artwork so all clients get correct Racing/ESPN images without app changes.
251 lines
7.1 KiB
TypeScript
251 lines
7.1 KiB
TypeScript
/**
|
|
* 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 };
|