stremio/apps/master-api/src/viewer/espn-watch-art.ts
Jos Vooges | STH 5a990ae346 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.
2026-09-21 02:07:17 +02:00

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