Improve Ziggo art matching for highlights and strip live thumbs.
Samenvattingen like Moto3 Austria now match via series aliases (Austria/Oostenrijk, Moto3) without strict air-time overlap; unmatched Racing rows drop live channel thumbs so football-on-air never stays as card art.
This commit is contained in:
parent
5a990ae346
commit
5db9658e84
2 changed files with 309 additions and 50 deletions
|
|
@ -1,11 +1,14 @@
|
||||||
import {
|
import {
|
||||||
artUrlsFromMergedId,
|
artUrlsFromMergedId,
|
||||||
isLiveChannelThumb,
|
isLiveChannelThumb,
|
||||||
|
isReplayOrHighlightTitle,
|
||||||
isZiggoSportChannel,
|
isZiggoSportChannel,
|
||||||
needsZiggoArtEnrichment,
|
needsZiggoArtEnrichment,
|
||||||
normalizeTitleTokens,
|
normalizeTitleTokens,
|
||||||
pickBestZiggoMatch,
|
pickBestZiggoMatch,
|
||||||
segmentKeysForRange,
|
segmentKeysForRange,
|
||||||
|
seriesAnchorTokens,
|
||||||
|
stripLiveThumbs,
|
||||||
titleOverlapScore,
|
titleOverlapScore,
|
||||||
timesOverlap,
|
timesOverlap,
|
||||||
type ZiggoEpgEvent,
|
type ZiggoEpgEvent,
|
||||||
|
|
@ -22,6 +25,7 @@ assert(!isLiveChannelThumb("https://staticqbr-prod-nl.gnp.cloud.ziggogo.tv/image
|
||||||
assert(isZiggoSportChannel("Ziggo Sport 1"), "zs1");
|
assert(isZiggoSportChannel("Ziggo Sport 1"), "zs1");
|
||||||
assert(isZiggoSportChannel("Ziggo Sport"), "zs");
|
assert(isZiggoSportChannel("Ziggo Sport"), "zs");
|
||||||
assert(!isZiggoSportChannel("ESPN"), "espn");
|
assert(!isZiggoSportChannel("ESPN"), "espn");
|
||||||
|
assert(isReplayOrHighlightTitle("Moto3 BWIN Grand Prix of Austria (samenvatting)"), "samenvatting");
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
needsZiggoArtEnrichment({
|
needsZiggoArtEnrichment({
|
||||||
|
|
@ -37,7 +41,9 @@ assert(arts?.landscape.includes("p24692584_bd_h8_aa.jpg"), "landscape url");
|
||||||
assert(arts?.portrait.includes("p24692584_b_v12_aa.jpg"), "portrait 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(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");
|
assert(normalizeTitleTokens("Ziggo Sport: GT World Challenge").has("gtworld"), "gtworld alias");
|
||||||
|
assert(normalizeTitleTokens("Grand Prix of Austria").has("oostenrijk"), "austria→oostenrijk");
|
||||||
|
assert(seriesAnchorTokens("Moto3 BWIN Grand Prix of Austria").has("moto3"), "anchor moto3");
|
||||||
|
|
||||||
const start = Date.parse("2026-09-20T23:00:00Z");
|
const start = Date.parse("2026-09-20T23:00:00Z");
|
||||||
const end = Date.parse("2026-09-21T00:00:00Z");
|
const end = Date.parse("2026-09-21T00:00:00Z");
|
||||||
|
|
@ -69,6 +75,57 @@ const hit = pickBestZiggoMatch(candidates, {
|
||||||
});
|
});
|
||||||
assert(hit?.id === "a", "pick GT not football");
|
assert(hit?.id === "a", "pick GT not football");
|
||||||
|
|
||||||
|
// Moto3 samenvatting days later than live session — must still resolve via loose match
|
||||||
|
const motoCands: ZiggoEpgEvent[] = [
|
||||||
|
{
|
||||||
|
id: "m3",
|
||||||
|
channelId: "NL_000094_019321",
|
||||||
|
title: "Moto3",
|
||||||
|
seriesName: "GP van Oostenrijk: Kwalificatie 2",
|
||||||
|
startTime: 1789815900,
|
||||||
|
endTime: 1789817700,
|
||||||
|
mergedId: "15289756|nl",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "foot",
|
||||||
|
channelId: "NL_000094_019321",
|
||||||
|
title: "Eredivisie samenvatting",
|
||||||
|
seriesName: "Ajax - PSV",
|
||||||
|
startTime: 1789972200,
|
||||||
|
endTime: 1789974000,
|
||||||
|
mergedId: "999|nl",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const motoHit = pickBestZiggoMatch(motoCands, {
|
||||||
|
title: "Moto3 BWIN Grand Prix of Austria (samenvatting)",
|
||||||
|
startMs: Date.parse("2026-09-21T06:30:00Z"),
|
||||||
|
endMs: Date.parse("2026-09-21T07:00:00Z"),
|
||||||
|
});
|
||||||
|
assert(motoHit?.id === "m3", `moto3 loose match got ${motoHit?.id}`);
|
||||||
|
assert(motoHit?.mergedId === "15289756|nl", "moto3 mergedId");
|
||||||
|
|
||||||
|
const stripped = stripLiveThumbs(
|
||||||
|
{
|
||||||
|
id: "x",
|
||||||
|
name: "Moto3",
|
||||||
|
channel: "Ziggo Sport 1",
|
||||||
|
network: "Ziggo Sport 1",
|
||||||
|
start: "2026-09-21T06:30:00Z",
|
||||||
|
stop: "2026-09-21T07:00:00Z",
|
||||||
|
imageLandscape: "https://zst1-akm-cdn-triple.akamaized.net/live/zs1/thumb.jpg?scale=640",
|
||||||
|
imageBackdrop: null,
|
||||||
|
imagePortrait: null,
|
||||||
|
logoUrl: null,
|
||||||
|
images: [
|
||||||
|
"https://go.ziggosporttotaal.nl/assets/channels/light/ziggo_sport_1.png",
|
||||||
|
"https://zst1-akm-cdn-triple.akamaized.net/live/zs1/thumb.jpg?scale=640",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"https://go.ziggosporttotaal.nl/assets/channels/light/ziggo_sport_1.png"
|
||||||
|
);
|
||||||
|
assert(!isLiveChannelThumb(stripped.imageLandscape), "stripped landscape");
|
||||||
|
assert(!(stripped.images ?? []).some(isLiveChannelThumb), "no live thumbs left");
|
||||||
|
|
||||||
const keys = segmentKeysForRange(start, end);
|
const keys = segmentKeysForRange(start, end);
|
||||||
assert(keys.length >= 1, "segment keys");
|
assert(keys.length >= 1, "segment keys");
|
||||||
assert(keys.every((k) => /^\d{14}$/.test(k)), "key format");
|
assert(keys.every((k) => /^\d{14}$/.test(k)), "key format");
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
* Enrich Ziggo Sport events with real Gracenote art from Ziggo GO EPG
|
* Enrich Ziggo Sport events with real Gracenote art from Ziggo GO EPG
|
||||||
* (epg-service-lite + image-service), instead of live channel thumbs.
|
* (epg-service-lite + image-service), instead of live channel thumbs.
|
||||||
*
|
*
|
||||||
* Match: channel name → Ziggo channelId(s) + time overlap + title tokens.
|
* Match: channel → Ziggo channelId(s) + (time overlap | series family) + title tokens.
|
||||||
* Art: ImagesEPG/EventImages/p{mergedId}_bd_h8_aa.jpg
|
* Art: ImagesEPG/EventImages/p{mergedId}_bd_h8_aa.jpg
|
||||||
|
* Fallback: strip live thumbs (never leave football-on-air as Racing art).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CHANNELS_URL =
|
const CHANNELS_URL =
|
||||||
|
|
@ -16,6 +17,7 @@ const IMAGE_BASE =
|
||||||
const FETCH_TIMEOUT_MS = 12_000;
|
const FETCH_TIMEOUT_MS = 12_000;
|
||||||
const CACHE_TTL_MS = 15 * 60_000;
|
const CACHE_TTL_MS = 15 * 60_000;
|
||||||
const SIX_H_MS = 6 * 60 * 60 * 1000;
|
const SIX_H_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export type ZiggoEpgEvent = {
|
export type ZiggoEpgEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -28,11 +30,16 @@ export type ZiggoEpgEvent = {
|
||||||
isSportsSportEvent?: boolean;
|
isSportsSportEvent?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChannelRow = { id?: string; name?: string };
|
type ChannelRow = {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
logo?: { focused?: string; default?: string };
|
||||||
|
};
|
||||||
|
|
||||||
type CacheState = {
|
type CacheState = {
|
||||||
at: number;
|
at: number;
|
||||||
byChannelName: Map<string, string[]>; // normalized name → channelIds
|
byChannelName: Map<string, string[]>;
|
||||||
|
logoByChannelId: Map<string, string>;
|
||||||
events: ZiggoEpgEvent[];
|
events: ZiggoEpgEvent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -51,6 +58,17 @@ export function isLiveChannelThumb(url: string | null | undefined): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isChannelLogoUrl(url: string | null | undefined): boolean {
|
||||||
|
if (!url) return false;
|
||||||
|
const u = url.toLowerCase();
|
||||||
|
return (
|
||||||
|
u.includes("assets/channels") ||
|
||||||
|
u.includes("channellogo") ||
|
||||||
|
u.includes("/imagesepg/eventimages/ziggo_sport") ||
|
||||||
|
/ziggo_sport_\d/i.test(u)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function needsZiggoArtEnrichment(e: {
|
export function needsZiggoArtEnrichment(e: {
|
||||||
channel?: string | null;
|
channel?: string | null;
|
||||||
network?: string | null;
|
network?: string | null;
|
||||||
|
|
@ -60,11 +78,8 @@ export function needsZiggoArtEnrichment(e: {
|
||||||
if (!isZiggoSportChannel(e.channel) && !isZiggoSportChannel(e.network)) return false;
|
if (!isZiggoSportChannel(e.channel) && !isZiggoSportChannel(e.network)) return false;
|
||||||
if (!e.imageLandscape && !(e.images?.length)) return true;
|
if (!e.imageLandscape && !(e.images?.length)) return true;
|
||||||
if (isLiveChannelThumb(e.imageLandscape)) return true;
|
if (isLiveChannelThumb(e.imageLandscape)) return true;
|
||||||
if ((e.images ?? []).some(isLiveChannelThumb) && isLiveChannelThumb(e.imageLandscape)) return true;
|
if ((e.images ?? []).some(isLiveChannelThumb)) return true;
|
||||||
// Landscape is only a channel logo → enrich
|
if (e.imageLandscape && isChannelLogoUrl(e.imageLandscape)) return true;
|
||||||
if (e.imageLandscape && /assets\/channels|channelLogo|ziggo_sport_\d/i.test(e.imageLandscape)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,13 +96,38 @@ export function normalizeChannelName(name: string | null | undefined): string {
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip channel prefixes / punctuation for fuzzy title match. */
|
export function isReplayOrHighlightTitle(title: string): boolean {
|
||||||
export function normalizeTitleTokens(raw: string | null | undefined): Set<string> {
|
return /\b(samenvatting|hoogtepunten|highlights?|herhaling|replay|samenvattingen)\b/i.test(
|
||||||
let s = String(raw ?? "").toLowerCase();
|
title
|
||||||
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([
|
/** Map NL/EN sports place & series aliases onto shared tokens. */
|
||||||
|
const TOKEN_ALIASES: Record<string, string[]> = {
|
||||||
|
austria: ["oostenrijk", "austria", "redbullring"],
|
||||||
|
oostenrijk: ["oostenrijk", "austria", "redbullring"],
|
||||||
|
austriaans: ["oostenrijk", "austria"],
|
||||||
|
moto3: ["moto3", "moto"],
|
||||||
|
moto2: ["moto2", "moto"],
|
||||||
|
motogp: ["motogp", "moto"],
|
||||||
|
moto: ["moto"],
|
||||||
|
gp: ["gp", "grandprix"],
|
||||||
|
grand: ["gp", "grandprix"],
|
||||||
|
prix: ["gp", "grandprix"],
|
||||||
|
grandprix: ["gp", "grandprix"],
|
||||||
|
bwin: ["bwin"],
|
||||||
|
imsa: ["imsa"],
|
||||||
|
nascar: ["nascar"],
|
||||||
|
indycar: ["indycar", "indy"],
|
||||||
|
gt: ["gt", "gtworld"],
|
||||||
|
world: ["gtworld", "world"],
|
||||||
|
challenge: ["gtworld", "challenge"],
|
||||||
|
zandvoort: ["zandvoort", "nederland"],
|
||||||
|
nederland: ["nederland", "netherlands"],
|
||||||
|
netherlands: ["nederland", "netherlands"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const STOP = new Set([
|
||||||
"de",
|
"de",
|
||||||
"het",
|
"het",
|
||||||
"een",
|
"een",
|
||||||
|
|
@ -99,11 +139,32 @@ export function normalizeTitleTokens(raw: string | null | undefined): Set<string
|
||||||
"live",
|
"live",
|
||||||
"vs",
|
"vs",
|
||||||
"v",
|
"v",
|
||||||
|
"samenvatting",
|
||||||
|
"hoogtepunten",
|
||||||
|
"highlights",
|
||||||
|
"highlight",
|
||||||
|
"herhaling",
|
||||||
|
"replay",
|
||||||
|
"race",
|
||||||
|
"races",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** Strip channel prefixes / punctuation; expand aliases for fuzzy 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\s*\d?\s*:\s*/i, "");
|
||||||
|
s = s.replace(/\bmoto\s*([123])\b/g, "moto$1");
|
||||||
|
s = s.replace(/\bgrand\s+prix\b/g, "grandprix");
|
||||||
|
s = s.replace(/\bgt\s*world(?:\s*challenge)?\b/g, "gtworld");
|
||||||
|
s = s.replace(/[^\p{L}\p{N}\s]+/gu, " ");
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
for (const t of s.split(/\s+/)) {
|
for (const t of s.split(/\s+/)) {
|
||||||
if (t.length < 3 || stop.has(t)) continue;
|
if (t.length < 2 || STOP.has(t)) continue;
|
||||||
|
if (t.length < 3 && !/^moto\d?$|^gp$/.test(t)) continue;
|
||||||
out.add(t);
|
out.add(t);
|
||||||
|
const aliases = TOKEN_ALIASES[t];
|
||||||
|
if (aliases) for (const a of aliases) out.add(a);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +178,30 @@ export function titleOverlapScore(a: string, b: string): number {
|
||||||
return hit / Math.min(ta.size, tb.size);
|
return hit / Math.min(ta.size, tb.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Strong series anchors — required for loose (non-time) matching. */
|
||||||
|
export function seriesAnchorTokens(title: string): Set<string> {
|
||||||
|
const t = normalizeTitleTokens(title);
|
||||||
|
const anchors = [
|
||||||
|
"moto3",
|
||||||
|
"moto2",
|
||||||
|
"motogp",
|
||||||
|
"gtworld",
|
||||||
|
"imsa",
|
||||||
|
"nascar",
|
||||||
|
"indycar",
|
||||||
|
"formule1",
|
||||||
|
"f1",
|
||||||
|
"wrc",
|
||||||
|
"dtm",
|
||||||
|
"superbike",
|
||||||
|
"sbk",
|
||||||
|
];
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const a of anchors) if (t.has(a)) out.add(a);
|
||||||
|
// also raw moto3 from "Moto 3" category-ish already normalized
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function artUrlsFromMergedId(mergedId: string | null | undefined): {
|
export function artUrlsFromMergedId(mergedId: string | null | undefined): {
|
||||||
landscape: string;
|
landscape: string;
|
||||||
portrait: string;
|
portrait: string;
|
||||||
|
|
@ -141,30 +226,86 @@ export function timesOverlap(
|
||||||
return aStartMs < b1 && aEndMs > b0;
|
return aStartMs < b1 && aEndMs > b0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scoreCandidate(
|
||||||
|
hay: string,
|
||||||
|
c: ZiggoEpgEvent,
|
||||||
|
opts: { startMs: number; endMs: number; loose: boolean }
|
||||||
|
): number {
|
||||||
|
const zigTitle = [c.title, c.seriesName].filter(Boolean).join(" ");
|
||||||
|
let score = titleOverlapScore(hay, zigTitle);
|
||||||
|
const nl = hay.toLowerCase();
|
||||||
|
const zl = zigTitle.toLowerCase();
|
||||||
|
|
||||||
|
if (nl.includes("gt world") && zl.includes("gt world")) score += 0.45;
|
||||||
|
if (/\bmoto3\b/i.test(nl) && /\bmoto3\b/i.test(zl)) score += 0.55;
|
||||||
|
if (/\bmoto2\b/i.test(nl) && /\bmoto2\b/i.test(zl)) score += 0.55;
|
||||||
|
if (/\bmotogp\b/i.test(nl) && /\bmotogp\b/i.test(zl)) score += 0.55;
|
||||||
|
if (/\bimsa\b/i.test(nl) && /\bimsa\b/i.test(zl)) score += 0.45;
|
||||||
|
if (/\bnascar\b/i.test(nl) && /\bnascar\b/i.test(zl)) score += 0.45;
|
||||||
|
|
||||||
|
// Place: Austria ↔ Oostenrijk already via aliases; boost if both sides have it
|
||||||
|
const placeA = normalizeTitleTokens(hay);
|
||||||
|
const placeB = normalizeTitleTokens(zigTitle);
|
||||||
|
if (placeA.has("oostenrijk") && placeB.has("oostenrijk")) score += 0.25;
|
||||||
|
|
||||||
|
if (c.isSportsSportEvent) score += 0.05;
|
||||||
|
if (/\bhoogtepunten\b/i.test(zl) && isReplayOrHighlightTitle(hay)) score += 0.1;
|
||||||
|
|
||||||
|
if (opts.loose) {
|
||||||
|
// Prefer closer airings even in loose mode
|
||||||
|
const mid = (opts.startMs + opts.endMs) / 2;
|
||||||
|
const cMid = ((c.startTime + c.endTime) / 2) * 1000;
|
||||||
|
const dt = Math.abs(mid - cMid);
|
||||||
|
if (dt > 4 * DAY_MS) return 0;
|
||||||
|
score += Math.max(0, 0.2 - (dt / (4 * DAY_MS)) * 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
export function pickBestZiggoMatch(
|
export function pickBestZiggoMatch(
|
||||||
candidates: ZiggoEpgEvent[],
|
candidates: ZiggoEpgEvent[],
|
||||||
opts: { title: string; startMs: number; endMs: number }
|
opts: { title: string; startMs: number; endMs: number }
|
||||||
): ZiggoEpgEvent | null {
|
): ZiggoEpgEvent | null {
|
||||||
|
const hay = opts.title;
|
||||||
|
const highlight = isReplayOrHighlightTitle(hay);
|
||||||
|
const anchors = seriesAnchorTokens(hay);
|
||||||
|
|
||||||
|
// Pass 1: time overlap (wider slack for summaries)
|
||||||
|
const slack = highlight ? 6 * 60 * 60_000 : 45 * 60_000;
|
||||||
let best: ZiggoEpgEvent | null = null;
|
let best: ZiggoEpgEvent | null = null;
|
||||||
let bestScore = 0;
|
let bestScore = 0;
|
||||||
const hay = `${opts.title}`;
|
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
if (!timesOverlap(opts.startMs, opts.endMs, c.startTime, c.endTime)) continue;
|
if (!timesOverlap(opts.startMs, opts.endMs, c.startTime, c.endTime, slack)) continue;
|
||||||
const zigTitle = [c.title, c.seriesName].filter(Boolean).join(" ");
|
const score = scoreCandidate(hay, c, { ...opts, loose: false });
|
||||||
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) {
|
if (score > bestScore) {
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
best = c;
|
best = c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Require at least one shared token (score > 0) or strong boost
|
if (best && bestScore >= 0.3) return best;
|
||||||
return bestScore >= 0.34 ? best : null;
|
|
||||||
|
// Pass 2: loose series match within ±3 days — for samenvatting/herhaling or strong anchors
|
||||||
|
if (!highlight && anchors.size === 0) return bestScore >= 0.34 ? best : null;
|
||||||
|
|
||||||
|
best = null;
|
||||||
|
bestScore = 0;
|
||||||
|
for (const c of candidates) {
|
||||||
|
const zigTitle = [c.title, c.seriesName].filter(Boolean).join(" ");
|
||||||
|
const cAnchors = seriesAnchorTokens(zigTitle);
|
||||||
|
// Require shared series anchor when loose
|
||||||
|
let sharedAnchor = false;
|
||||||
|
for (const a of anchors) if (cAnchors.has(a)) sharedAnchor = true;
|
||||||
|
if (anchors.size && !sharedAnchor) continue;
|
||||||
|
|
||||||
|
const score = scoreCandidate(hay, c, { ...opts, loose: true });
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Higher bar without time lock
|
||||||
|
return bestScore >= 0.45 ? best : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function segmentKeyUtc(ms: number): string {
|
function segmentKeyUtc(ms: number): string {
|
||||||
|
|
@ -213,9 +354,10 @@ async function loadZiggoIndex(fromMs: number, toMs: number): Promise<CacheState
|
||||||
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache;
|
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache;
|
||||||
|
|
||||||
const channels = await fetchJson<ChannelRow[]>(CHANNELS_URL);
|
const channels = await fetchJson<ChannelRow[]>(CHANNELS_URL);
|
||||||
if (!channels?.length) return cache; // keep stale if any
|
if (!channels?.length) return cache;
|
||||||
|
|
||||||
const byChannelName = new Map<string, string[]>();
|
const byChannelName = new Map<string, string[]>();
|
||||||
|
const logoByChannelId = new Map<string, string>();
|
||||||
for (const ch of channels) {
|
for (const ch of channels) {
|
||||||
const id = String(ch.id ?? "").trim();
|
const id = String(ch.id ?? "").trim();
|
||||||
const name = normalizeChannelName(ch.name);
|
const name = normalizeChannelName(ch.name);
|
||||||
|
|
@ -223,9 +365,12 @@ async function loadZiggoIndex(fromMs: number, toMs: number): Promise<CacheState
|
||||||
const list = byChannelName.get(name) ?? [];
|
const list = byChannelName.get(name) ?? [];
|
||||||
if (!list.includes(id)) list.push(id);
|
if (!list.includes(id)) list.push(id);
|
||||||
byChannelName.set(name, list);
|
byChannelName.set(name, list);
|
||||||
|
const logo = ch.logo?.focused || ch.logo?.default;
|
||||||
|
if (logo) logoByChannelId.set(id, logo);
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = segmentKeysForRange(fromMs, toMs);
|
// Extra ±3 days so samenvatting can resolve earlier race airings
|
||||||
|
const keys = segmentKeysForRange(fromMs - 3 * DAY_MS, toMs + DAY_MS);
|
||||||
const events: ZiggoEpgEvent[] = [];
|
const events: ZiggoEpgEvent[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
|
@ -263,7 +408,7 @@ async function loadZiggoIndex(fromMs: number, toMs: number): Promise<CacheState
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
cache = { at: Date.now(), byChannelName, events };
|
cache = { at: Date.now(), byChannelName, logoByChannelId, events };
|
||||||
return cache;
|
return cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,7 +417,6 @@ function channelIdsFor(index: CacheState, channel: string | null | undefined): s
|
||||||
if (!n) return [];
|
if (!n) return [];
|
||||||
const exact = index.byChannelName.get(n);
|
const exact = index.byChannelName.get(n);
|
||||||
if (exact?.length) return exact;
|
if (exact?.length) return exact;
|
||||||
// "Ziggo Sport" without number → all Ziggo Sport*
|
|
||||||
if (n === "ziggo sport") {
|
if (n === "ziggo sport") {
|
||||||
const all: string[] = [];
|
const all: string[] = [];
|
||||||
for (const [name, ids] of index.byChannelName) {
|
for (const [name, ids] of index.byChannelName) {
|
||||||
|
|
@ -283,6 +427,50 @@ function channelIdsFor(index: CacheState, channel: string | null | undefined): s
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function channelLogoFallback(
|
||||||
|
index: CacheState,
|
||||||
|
channel: string | null | undefined,
|
||||||
|
network: string | null | undefined,
|
||||||
|
existingImages: string[]
|
||||||
|
): string | null {
|
||||||
|
const fromImages = existingImages.find((u) => isChannelLogoUrl(u) && !isLiveChannelThumb(u));
|
||||||
|
if (fromImages) return fromImages;
|
||||||
|
for (const id of [...channelIdsFor(index, channel), ...channelIdsFor(index, network)]) {
|
||||||
|
const logo = index.logoByChannelId.get(id);
|
||||||
|
if (logo) return logo;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never keep a live-on-air thumb as card art (football under racing). */
|
||||||
|
export function stripLiveThumbs<T extends EnrichableEvent>(
|
||||||
|
e: T,
|
||||||
|
fallbackLogo: string | null
|
||||||
|
): T {
|
||||||
|
const cleaned = e.images.filter((u) => !isLiveChannelThumb(u));
|
||||||
|
const landscape =
|
||||||
|
(e.imageLandscape && !isLiveChannelThumb(e.imageLandscape)
|
||||||
|
? e.imageLandscape
|
||||||
|
: null) ||
|
||||||
|
cleaned.find((u) => !isChannelLogoUrl(u)) ||
|
||||||
|
fallbackLogo ||
|
||||||
|
cleaned[0] ||
|
||||||
|
null;
|
||||||
|
const backdrop =
|
||||||
|
e.imageBackdrop && !isLiveChannelThumb(e.imageBackdrop)
|
||||||
|
? e.imageBackdrop
|
||||||
|
: landscape;
|
||||||
|
const images =
|
||||||
|
landscape && !cleaned.includes(landscape) ? [landscape, ...cleaned] : cleaned;
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
imageLandscape: landscape,
|
||||||
|
imageBackdrop: backdrop,
|
||||||
|
logoUrl: e.logoUrl || fallbackLogo,
|
||||||
|
images,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type EnrichableEvent = {
|
export type EnrichableEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -300,7 +488,7 @@ export type EnrichableEvent = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace live-thumbs / empty art on Ziggo Sport rows with Gracenote EPG art.
|
* Replace live-thumbs / empty art on Ziggo Sport rows with Gracenote EPG art.
|
||||||
* Soft-fails (returns input) if Ziggo GO is unreachable.
|
* Soft-fails (returns input) if Ziggo GO is unreachable — but still strips live thumbs.
|
||||||
*/
|
*/
|
||||||
export async function enrichZiggoGoArt<T extends EnrichableEvent>(events: T[]): Promise<T[]> {
|
export async function enrichZiggoGoArt<T extends EnrichableEvent>(events: T[]): Promise<T[]> {
|
||||||
const targets = events.filter(needsZiggoArtEnrichment);
|
const targets = events.filter(needsZiggoArtEnrichment);
|
||||||
|
|
@ -317,18 +505,24 @@ export async function enrichZiggoGoArt<T extends EnrichableEvent>(events: T[]):
|
||||||
if (!Number.isFinite(minMs) || maxMs <= 0) return events;
|
if (!Number.isFinite(minMs) || maxMs <= 0) return events;
|
||||||
|
|
||||||
const index = await loadZiggoIndex(minMs, maxMs);
|
const index = await loadZiggoIndex(minMs, maxMs);
|
||||||
if (!index?.events.length) return events;
|
|
||||||
|
|
||||||
return events.map((e) => {
|
return events.map((e) => {
|
||||||
if (!needsZiggoArtEnrichment(e)) return e;
|
if (!needsZiggoArtEnrichment(e)) return e;
|
||||||
|
|
||||||
|
// If Ziggo index unavailable: still remove live thumbs
|
||||||
|
if (!index?.events.length) {
|
||||||
|
return stripLiveThumbs(e, e.images.find(isChannelLogoUrl) ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
const ids = new Set([
|
const ids = new Set([
|
||||||
...channelIdsFor(index, e.channel),
|
...channelIdsFor(index, e.channel),
|
||||||
...channelIdsFor(index, e.network),
|
...channelIdsFor(index, e.network),
|
||||||
]);
|
]);
|
||||||
if (!ids.size) return e;
|
|
||||||
const startMs = Date.parse(e.start);
|
const startMs = Date.parse(e.start);
|
||||||
const endMs = Date.parse(e.stop);
|
const endMs = Date.parse(e.stop);
|
||||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return e;
|
if (!ids.size || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
|
||||||
|
return stripLiveThumbs(e, channelLogoFallback(index, e.channel, e.network, e.images));
|
||||||
|
}
|
||||||
|
|
||||||
const candidates = index.events.filter((z) => ids.has(z.channelId));
|
const candidates = index.events.filter((z) => ids.has(z.channelId));
|
||||||
const match = pickBestZiggoMatch(candidates, {
|
const match = pickBestZiggoMatch(candidates, {
|
||||||
|
|
@ -336,9 +530,15 @@ export async function enrichZiggoGoArt<T extends EnrichableEvent>(events: T[]):
|
||||||
startMs,
|
startMs,
|
||||||
endMs,
|
endMs,
|
||||||
});
|
});
|
||||||
if (!match?.mergedId) return e;
|
|
||||||
|
if (!match?.mergedId) {
|
||||||
|
return stripLiveThumbs(e, channelLogoFallback(index, e.channel, e.network, e.images));
|
||||||
|
}
|
||||||
|
|
||||||
const arts = artUrlsFromMergedId(match.mergedId);
|
const arts = artUrlsFromMergedId(match.mergedId);
|
||||||
if (!arts) return e;
|
if (!arts) {
|
||||||
|
return stripLiveThumbs(e, channelLogoFallback(index, e.channel, e.network, e.images));
|
||||||
|
}
|
||||||
|
|
||||||
const images = [
|
const images = [
|
||||||
arts.landscape,
|
arts.landscape,
|
||||||
|
|
@ -355,10 +555,12 @@ export async function enrichZiggoGoArt<T extends EnrichableEvent>(events: T[]):
|
||||||
return {
|
return {
|
||||||
...e,
|
...e,
|
||||||
imageLandscape: arts.landscape,
|
imageLandscape: arts.landscape,
|
||||||
imageBackdrop: e.imageBackdrop && !isLiveChannelThumb(e.imageBackdrop)
|
imageBackdrop:
|
||||||
|
e.imageBackdrop && !isLiveChannelThumb(e.imageBackdrop)
|
||||||
? e.imageBackdrop
|
? e.imageBackdrop
|
||||||
: arts.landscape,
|
: arts.landscape,
|
||||||
imagePortrait: arts.portrait,
|
imagePortrait: arts.portrait,
|
||||||
|
logoUrl: e.logoUrl || channelLogoFallback(index, e.channel, e.network, e.images),
|
||||||
images: deduped,
|
images: deduped,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue