Fix empty Ziggo Sport event covers by keeping FAAB live thumbs.

Stop replacing working FAAB thumbs with Gracenote URLs that 404, and only enrich highlights after verifying EventImages exist.
This commit is contained in:
Jos Vooges | STH 2026-09-24 02:02:23 +02:00
parent 89c88abc31
commit bc847b222d
7 changed files with 167 additions and 68 deletions

View file

@ -740,10 +740,10 @@ private fun detailMetaLine(event: ScheduleEvent): String =
).distinct().joinToString(" · ")
private fun eventArtUrl(event: ScheduleEvent): String? =
event.imageLandscape?.takeIf { it.isNotBlank() }
event.imageLandscape?.takeIf { it.isNotBlank() && looksLikeArtwork(it) }
?: event.images.firstOrNull { looksLikeArtwork(it) && !it.contains("hero-16x9", ignoreCase = true) }
?: event.images.firstOrNull { looksLikeArtwork(it) }
?: event.imagePortrait?.takeIf { it.isNotBlank() }
?: event.imagePortrait?.takeIf { it.isNotBlank() && looksLikeArtwork(it) }
?: event.images.firstOrNull { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) }
private fun eventBackdropUrl(event: ScheduleEvent): String? =
@ -758,6 +758,8 @@ private fun looksLikeArtwork(url: String): Boolean {
if (lower.contains("assets/channels")) return false
if (lower.contains("channels-logo")) return false
if (lower.contains("/logos/") && lower.endsWith(".png")) return false
if (lower.contains("channellogo")) return false
if (Regex("""ziggo_sport_\d""", RegexOption.IGNORE_CASE).containsMatchIn(lower)) return false
if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")) {
if (!lower.contains("boxart") && !lower.contains("hero-")) return false
}

View file

@ -2042,10 +2042,10 @@ private fun detailMetaLine(event: ScheduleEvent): String =
/** Card/rail cover art — prefer API imageLandscape (BOXART-16X9). */
private fun eventArtUrl(event: ScheduleEvent): String? =
event.imageLandscape?.takeIf { it.isNotBlank() }
event.imageLandscape?.takeIf { it.isNotBlank() && looksLikeArtwork(it) }
?: event.images.firstOrNull { looksLikeArtwork(it) && !it.contains("hero-16x9", ignoreCase = true) }
?: event.images.firstOrNull { looksLikeArtwork(it) }
?: event.imagePortrait?.takeIf { it.isNotBlank() }
?: event.imagePortrait?.takeIf { it.isNotBlank() && looksLikeArtwork(it) }
?: event.images.firstOrNull { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) }
/** Detail full-bleed — prefer HERO backdrop, then card landscape. */
@ -2061,6 +2061,8 @@ private fun looksLikeArtwork(url: String): Boolean {
if (lower.contains("assets/channels")) return false
if (lower.contains("channels-logo")) return false
if (lower.contains("/logos/") && lower.endsWith(".png")) return false
if (lower.contains("channellogo")) return false
if (Regex("""ziggo_sport_\d""", RegexOption.IGNORE_CASE).containsMatchIn(lower)) return false
if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")) {
if (!lower.contains("boxart") && !lower.contains("hero-")) return false
}

View file

@ -1153,13 +1153,13 @@ enum EventHelpers {
}
static func artUrl(_ event: ScheduleEvent) -> String? {
if let u = event.imageLandscape?.nilIfBlank { return u }
if let u = event.imageLandscape?.nilIfBlank, looksLikeArtwork(u) { return u }
let imgs = event.images ?? []
if let u = imgs.first(where: { looksLikeArtwork($0) && !$0.localizedCaseInsensitiveContains("hero-16x9") }) {
return u
}
if let u = imgs.first(where: { looksLikeArtwork($0) }) { return u }
if let u = event.imagePortrait?.nilIfBlank { return u }
if let u = event.imagePortrait?.nilIfBlank, looksLikeArtwork(u) { return u }
return imgs.first { !$0.isEmpty && !$0.localizedCaseInsensitiveContains("assets/channels") }
}
@ -1317,6 +1317,8 @@ enum EventHelpers {
if lower.contains("assets/channels") { return false }
if lower.contains("channels-logo") { return false }
if lower.contains("/logos/") && lower.hasSuffix(".png") { return false }
if lower.contains("channellogo") { return false }
if lower.range(of: #"ziggo_sport_\d"#, options: .regularExpression) != nil { return false }
if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")),
!lower.contains("boxart"), !lower.contains("hero-") {
return false

View file

@ -26,6 +26,22 @@ assert(
}),
"logo needs fallback"
);
assert(
!needsCategoryFallback({
name: "ATP 250: Chengdu Open",
imageLandscape: "https://zst1-akm-cdn-triple.akamaized.net/live/zs1/thumb.jpg?scale=640x360",
images: [],
}),
"FAAB live thumb is usable art"
);
assert(
needsCategoryFallback({
name: "Moto3 (samenvatting)",
imageLandscape: "https://zst1-akm-cdn-triple.akamaized.net/live/zs1/thumb.jpg?scale=640x360",
images: [],
}),
"highlight live snap needs fallback"
);
assert(
!needsCategoryFallback({
imageLandscape: "https://cdn.example/real-art.jpg",

View file

@ -35,14 +35,21 @@ export function isCategoryFallbackUrl(url: string | null | undefined): boolean {
return /\/assets\/event-fallbacks\/[a-z]+\.jpg(\?|$)/i.test(url);
}
/** True when landscape is missing or only a logo / live thumb (not usable cover). */
/** True when landscape is missing or only a channel logo (not usable cover). */
export function needsCategoryFallback(e: {
name?: string | null;
imageLandscape?: string | null;
images?: string[];
}): boolean {
const land = e.imageLandscape;
if (!land) return true;
if (isLiveChannelThumb(land)) return true;
// FAAB live channel thumbs are real card art — keep them.
// Highlights/replays with a live snap are wrong sport; treat as weak.
if (isLiveChannelThumb(land)) {
return /\b(samenvatting|hoogtepunten|highlights?|herhaling|replay)\b/i.test(
e.name ?? ""
);
}
if (isChannelLogoUrl(land)) return true;
if (isCategoryFallbackUrl(land)) return false;
// Already has real art

View file

@ -28,12 +28,31 @@ assert(!isZiggoSportChannel("ESPN"), "espn");
assert(isReplayOrHighlightTitle("Moto3 BWIN Grand Prix of Austria (samenvatting)"), "samenvatting");
assert(
needsZiggoArtEnrichment({
!needsZiggoArtEnrichment({
name: "ATP 250: Chengdu Open",
channel: "Ziggo Sport 1",
imageLandscape: "https://zst1-akm-cdn-triple.akamaized.net/live/zs1/thumb.jpg?scale=640",
images: [],
}),
"needs enrich live thumb"
"keep FAAB live thumb for normal events"
);
assert(
needsZiggoArtEnrichment({
name: "Moto3 BWIN Grand Prix of Austria (samenvatting)",
channel: "Ziggo Sport 1",
imageLandscape: "https://zst1-akm-cdn-triple.akamaized.net/live/zs1/thumb.jpg?scale=640",
images: [],
}),
"enrich highlight live thumb"
);
assert(
needsZiggoArtEnrichment({
name: "ATP 250",
channel: "Ziggo Sport 1",
imageLandscape: "https://go.ziggosporttotaal.nl/assets/channels/light/ziggo_sport_1.png",
images: [],
}),
"enrich logo-only"
);
const arts = artUrlsFromMergedId("24692584|nl");

View file

@ -1,10 +1,11 @@
/**
* 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) when FAAB only has logos or a misleading
* live snap on highlights/replays.
*
* Match: channel → Ziggo channelId(s) + (time overlap | series family) + title tokens.
* Art: ImagesEPG/EventImages/p{mergedId}_bd_h8_aa.jpg
* Fallback: strip live thumbs (never leave football-on-air as Racing art).
* Art: ImagesEPG/EventImages/p{mergedId}_bd_h8_aa.jpg (probed for HTTP 200).
* Keep FAAB live thumbs for normal live/upcoming cards.
*/
const CHANNELS_URL =
@ -70,6 +71,7 @@ export function isChannelLogoUrl(url: string | null | undefined): boolean {
}
export function needsZiggoArtEnrichment(e: {
name?: string | null;
channel?: string | null;
network?: string | null;
imageLandscape?: string | null;
@ -77,9 +79,12 @@ export function needsZiggoArtEnrichment(e: {
}): 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)) return true;
if (e.imageLandscape && isChannelLogoUrl(e.imageLandscape)) return true;
// FAAB live thumbs are usable card art for live/upcoming — keep them.
// Only replace for highlights/replays (on-air snap ≠ programme art).
const hasLiveThumb =
isLiveChannelThumb(e.imageLandscape) || (e.images ?? []).some(isLiveChannelThumb);
if (hasLiveThumb) return isReplayOrHighlightTitle(e.name ?? "");
return false;
}
@ -350,6 +355,42 @@ async function fetchJson<T>(url: string): Promise<T | null> {
}
}
const ART_PROBE_TIMEOUT_MS = 4_000;
const artUrlCache = new Map<string, { ok: boolean; at: number }>();
/** Gracenote EventImages often 404 for old/merged ids — never replace FAAB thumbs with dead URLs. */
export async function artUrlExists(url: string): Promise<boolean> {
const cached = artUrlCache.get(url);
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.ok;
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), ART_PROBE_TIMEOUT_MS);
let ok = false;
try {
const head = await fetch(url, {
method: "HEAD",
signal: ac.signal,
headers: { "User-Agent": "VMC-master/1.0" },
});
if (head.ok) {
ok = true;
} else if (head.status === 405 || head.status === 501) {
const get = await fetch(url, {
method: "GET",
signal: ac.signal,
headers: { Range: "bytes=0-0", "User-Agent": "VMC-master/1.0" },
});
ok = get.ok || get.status === 206;
}
} catch {
ok = false;
} finally {
clearTimeout(timer);
}
artUrlCache.set(url, { ok, at: Date.now() });
return ok;
}
async function loadZiggoIndex(fromMs: number, toMs: number): Promise<CacheState | null> {
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache;
@ -487,8 +528,9 @@ export type EnrichableEvent = {
};
/**
* Replace live-thumbs / empty art on Ziggo Sport rows with Gracenote EPG art.
* Soft-fails (returns input) if Ziggo GO is unreachable — but still strips live thumbs.
* Enrich Ziggo Sport rows that lack usable FAAB art (logos / highlight live-snaps)
* with Gracenote EPG art. Live FAAB thumbs are left alone so cards stay filled.
* Soft-fails keep FAAB images when Ziggo GO is down or EventImages 404.
*/
export async function enrichZiggoGoArt<T extends EnrichableEvent>(events: T[]): Promise<T[]> {
const targets = events.filter(needsZiggoArtEnrichment);
@ -506,62 +548,71 @@ export async function enrichZiggoGoArt<T extends EnrichableEvent>(events: T[]):
const index = await loadZiggoIndex(minMs, maxMs);
return events.map((e) => {
if (!needsZiggoArtEnrichment(e)) return e;
/** On miss: strip misleading live snaps for highlights; otherwise keep FAAB as-is. */
function fallbackArt(e: T): T {
const highlight = isReplayOrHighlightTitle(e.name);
if (!highlight) return e;
const logo =
(index ? channelLogoFallback(index, e.channel, e.network, e.images) : null) ||
e.images.find(isChannelLogoUrl) ||
null;
return stripLiveThumbs(e, logo);
}
// If Ziggo index unavailable: still remove live thumbs
if (!index?.events.length) {
return stripLiveThumbs(e, e.images.find(isChannelLogoUrl) ?? null);
}
return Promise.all(
events.map(async (e) => {
if (!needsZiggoArtEnrichment(e)) return e;
const ids = new Set([
...channelIdsFor(index, e.channel),
...channelIdsFor(index, e.network),
]);
const startMs = Date.parse(e.start);
const endMs = Date.parse(e.stop);
if (!ids.size || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
return stripLiveThumbs(e, channelLogoFallback(index, e.channel, e.network, e.images));
}
if (!index?.events.length) return fallbackArt(e);
const candidates = index.events.filter((z) => ids.has(z.channelId));
const match = pickBestZiggoMatch(candidates, {
title: e.name,
startMs,
endMs,
});
const ids = new Set([
...channelIdsFor(index, e.channel),
...channelIdsFor(index, e.network),
]);
const startMs = Date.parse(e.start);
const endMs = Date.parse(e.stop);
if (!ids.size || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
return fallbackArt(e);
}
if (!match?.mergedId) {
return stripLiveThumbs(e, channelLogoFallback(index, e.channel, e.network, e.images));
}
const candidates = index.events.filter((z) => ids.has(z.channelId));
const match = pickBestZiggoMatch(candidates, {
title: e.name,
startMs,
endMs,
});
const arts = artUrlsFromMergedId(match.mergedId);
if (!arts) {
return stripLiveThumbs(e, channelLogoFallback(index, e.channel, e.network, e.images));
}
if (!match?.mergedId) return fallbackArt(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;
});
const arts = artUrlsFromMergedId(match.mergedId);
if (!arts) return fallbackArt(e);
return {
...e,
imageLandscape: arts.landscape,
imageBackdrop:
e.imageBackdrop && !isLiveChannelThumb(e.imageBackdrop)
? e.imageBackdrop
: arts.landscape,
imagePortrait: arts.portrait,
logoUrl: e.logoUrl || channelLogoFallback(index, e.channel, e.network, e.images),
images: deduped,
};
});
if (!(await artUrlExists(arts.landscape))) return fallbackArt(e);
const images = [
arts.landscape,
arts.portrait,
// Keep FAAB live thumbs as client-side fallback (Gracenote may 404 on device)
...e.images,
];
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,
logoUrl: e.logoUrl || channelLogoFallback(index, e.channel, e.network, e.images),
images: deduped,
};
})
);
}