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:
parent
bfeb270459
commit
5a990ae346
7 changed files with 826 additions and 23 deletions
|
|
@ -6,7 +6,7 @@
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/app.js",
|
"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:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate deploy",
|
"db:migrate": "prisma migrate deploy",
|
||||||
"db:migrate:dev": "prisma migrate dev",
|
"db:migrate:dev": "prisma migrate dev",
|
||||||
|
|
|
||||||
69
apps/master-api/src/viewer/espn-watch-art.test.ts
Normal file
69
apps/master-api/src/viewer/espn-watch-art.test.ts
Normal 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");
|
||||||
251
apps/master-api/src/viewer/espn-watch-art.ts
Normal file
251
apps/master-api/src/viewer/espn-watch-art.ts
Normal 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 };
|
||||||
|
|
@ -50,6 +50,8 @@ expectBucket({ name: "NBA Today" }, "basketball");
|
||||||
expectBucket({ name: "Ligue 1: Monaco - Lens" }, "football");
|
expectBucket({ name: "Ligue 1: Monaco - Lens" }, "football");
|
||||||
expectBucket({ name: "PDC Darts: World Series of Darts Finals 2026" }, "darts");
|
expectBucket({ name: "PDC Darts: World Series of Darts Finals 2026" }, "darts");
|
||||||
expectBucket({ name: "Some snooker final" }, "snooker");
|
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") {
|
function expectFormat(url: string, format: "hls" | "dash") {
|
||||||
const got = detectLiveManifestFormat(url);
|
const got = detectLiveManifestFormat(url);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import type { Config } from "../config";
|
||||||
import { AppError } from "../security/errors";
|
import { AppError } from "../security/errors";
|
||||||
import { getIptvSettings, normalizeProviderIdList } from "../settings/iptv";
|
import { getIptvSettings, normalizeProviderIdList } from "../settings/iptv";
|
||||||
import { prisma } from "../database/client";
|
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 CACHE_TTL_MS = 60_000;
|
||||||
const FETCH_TIMEOUT_MS = 20_000;
|
const FETCH_TIMEOUT_MS = 20_000;
|
||||||
|
|
@ -118,7 +120,7 @@ const CATEGORY_NOISE = new Set([
|
||||||
|
|
||||||
/** eventType / league → bucket (specific sports before broad "championship") */
|
/** eventType / league → bucket (specific sports before broad "championship") */
|
||||||
const EVENT_TYPE_BUCKET: Array<{ re: RegExp; bucket: SportBucketId }> = [
|
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(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(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" },
|
{ 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 }> = [
|
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(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(tennis|atp|wta|davis\s*cup)\b/i, bucket: "tennis" },
|
||||||
{ re: /\b(golf|pga\b|lpga)\b/i, bucket: "golf" },
|
{ re: /\b(golf|pga\b|lpga)\b/i, bucket: "golf" },
|
||||||
{ re: /\b(darts|pdc)\b/i, bucket: "darts" },
|
{ re: /\b(darts|pdc)\b/i, bucket: "darts" },
|
||||||
|
|
@ -167,12 +169,14 @@ export function resolveSportBucket(input: {
|
||||||
eventType?: string | null;
|
eventType?: string | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
|
description?: string | null;
|
||||||
}): { sportBucket: SportBucketId; sportLabel: string } {
|
}): { sportBucket: SportBucketId; sportLabel: string } {
|
||||||
const category = str(input.category) || null;
|
const category = str(input.category) || null;
|
||||||
const eventType = str(input.eventType) || null;
|
const eventType = str(input.eventType) || null;
|
||||||
const name = str(input.name) || null;
|
const name = str(input.name) || null;
|
||||||
const subtitle = str(input.subtitle) || 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
|
// Explicit non-sport categories
|
||||||
const catLower = (category ?? "").toLowerCase();
|
const catLower = (category ?? "").toLowerCase();
|
||||||
|
|
@ -328,7 +332,24 @@ function isPureLogoUrl(url: string): boolean {
|
||||||
return false;
|
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();
|
const u = url.toLowerCase();
|
||||||
let score = 0;
|
let score = 0;
|
||||||
// Prime/Amazon: card cover = BOXART-16X9 (composite with corner logo overlay is OK)
|
// 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("hero-16x9") || u.includes("hero_16x9")) score += 35;
|
||||||
if (u.includes("boxart-4x3") || u.includes("boxart_4x3")) score -= 25;
|
if (u.includes("boxart-4x3") || u.includes("boxart_4x3")) score -= 25;
|
||||||
// Ziggo Sport FAAB live thumbs (akamaized …/live/…/thumb.jpg?scale=640x360)
|
// Ziggo Sport FAAB live thumbs (akamaized …/live/…/thumb.jpg?scale=640x360)
|
||||||
if (u.includes("thumb")) score += 55;
|
if (opts.allowLiveThumbs !== false) {
|
||||||
if (u.includes("akamaized.net/live")) score += 50;
|
if (u.includes("thumb")) score += 55;
|
||||||
if (u.includes("scale=")) score += 35;
|
if (u.includes("akamaized.net/live")) score += 50;
|
||||||
if (u.includes("640x360") || u.includes("scale=640")) score += 30;
|
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("_l.") || u.includes("_l?")) score += 50;
|
||||||
if (u.includes("width=960") && u.includes("height=540")) score += 40;
|
if (u.includes("width=960") && u.includes("height=540")) score += 40;
|
||||||
if (u.includes("width=1280") || u.includes("height=720")) score += 30;
|
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. */
|
/** 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();
|
const u = url.toLowerCase();
|
||||||
let score = 0;
|
let score = 0;
|
||||||
if (u.includes("hero-16x9") || u.includes("hero_16x9")) score += 95;
|
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-16x9") || u.includes("boxart_16x9")) score += 40;
|
||||||
if (u.includes("boxart-4x3") || u.includes("boxart_4x3")) score -= 20;
|
if (u.includes("boxart-4x3") || u.includes("boxart_4x3")) score -= 20;
|
||||||
if (u.includes("thumb")) score += 45;
|
if (opts.allowLiveThumbs !== false) {
|
||||||
if (u.includes("akamaized.net/live")) score += 40;
|
if (u.includes("thumb")) score += 45;
|
||||||
if (u.includes("scale=")) score += 25;
|
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("_l.") || u.includes("_l?")) score += 40;
|
||||||
if (u.includes("width=1280") || u.includes("height=720") || u.includes("1920")) score += 15;
|
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;
|
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
|
* 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[] {
|
function fillMissingImages(events: ScheduleEventPublic[]): ScheduleEventPublic[] {
|
||||||
const donors = new Map<string, ScheduleEventPublic>();
|
const donors = new Map<string, ScheduleEventPublic>();
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
if (!e.imageLandscape && !e.logoUrl && e.images.length === 0) continue;
|
if (!e.imageLandscape && !e.logoUrl && e.images.length === 0) continue;
|
||||||
const keys = [e.channel, e.network]
|
for (const key of donorImageKeys(e)) {
|
||||||
.map(normalizeChannelKey)
|
|
||||||
.filter((k) => k.length > 0);
|
|
||||||
for (const key of keys) {
|
|
||||||
const existing = donors.get(key);
|
const existing = donors.get(key);
|
||||||
if (!existing || donorImageScore(e) > donorImageScore(existing)) {
|
if (!existing || donorImageScore(e) > donorImageScore(existing)) {
|
||||||
donors.set(key, e);
|
donors.set(key, e);
|
||||||
|
|
@ -460,11 +488,8 @@ function fillMissingImages(events: ScheduleEventPublic[]): ScheduleEventPublic[]
|
||||||
|
|
||||||
return events.map((e) => {
|
return events.map((e) => {
|
||||||
if (e.imageLandscape || e.images.length > 0) return 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;
|
let donor: ScheduleEventPublic | undefined;
|
||||||
for (const key of keys) {
|
for (const key of donorImageKeys(e)) {
|
||||||
const hit = donors.get(key);
|
const hit = donors.get(key);
|
||||||
if (hit && hit.id !== e.id) {
|
if (hit && hit.id !== e.id) {
|
||||||
donor = hit;
|
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 {
|
function bestByScore(urls: string[], scorer: (u: string) => number): string | null {
|
||||||
let best: string | null = null;
|
let best: string | null = null;
|
||||||
let bestScore = -Infinity;
|
let bestScore = -Infinity;
|
||||||
|
|
@ -746,6 +781,8 @@ async function loadRawEvents(config: Config): Promise<CacheEntry> {
|
||||||
|
|
||||||
export function invalidateScheduleEventsCache() {
|
export function invalidateScheduleEventsCache() {
|
||||||
cache = null;
|
cache = null;
|
||||||
|
invalidateZiggoEpgArtCache();
|
||||||
|
invalidateEspnWatchArtCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Distinct providers uit de ruwe feed (incl. ended) — voor admin toggles. */
|
/** 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);
|
return Date.parse(a.start) - Date.parse(b.start);
|
||||||
});
|
});
|
||||||
const disabledGlobal = await globalDisabledProviders(config);
|
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) =>
|
const providers = [...new Set(events.map((e) => e.provider.toLowerCase()))].sort((a, b) =>
|
||||||
a.localeCompare(b, "nl")
|
a.localeCompare(b, "nl")
|
||||||
|
|
|
||||||
76
apps/master-api/src/viewer/ziggo-epg-art.test.ts
Normal file
76
apps/master-api/src/viewer/ziggo-epg-art.test.ts
Normal 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");
|
||||||
365
apps/master-api/src/viewer/ziggo-epg-art.ts
Normal file
365
apps/master-api/src/viewer/ziggo-epg-art.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue