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(" · ") ).distinct().joinToString(" · ")
private fun eventArtUrl(event: ScheduleEvent): String? = 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) && !it.contains("hero-16x9", ignoreCase = true) }
?: event.images.firstOrNull { looksLikeArtwork(it) } ?: 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) } ?: event.images.firstOrNull { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) }
private fun eventBackdropUrl(event: ScheduleEvent): String? = 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("assets/channels")) return false
if (lower.contains("channels-logo")) return false if (lower.contains("channels-logo")) return false
if (lower.contains("/logos/") && lower.endsWith(".png")) 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("/logo") || lower.contains("logo.") || lower.contains("logo_")) {
if (!lower.contains("boxart") && !lower.contains("hero-")) return false 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). */ /** Card/rail cover art — prefer API imageLandscape (BOXART-16X9). */
private fun eventArtUrl(event: ScheduleEvent): String? = 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) && !it.contains("hero-16x9", ignoreCase = true) }
?: event.images.firstOrNull { looksLikeArtwork(it) } ?: 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) } ?: event.images.firstOrNull { it.isNotBlank() && !it.contains("assets/channels", ignoreCase = true) }
/** Detail full-bleed — prefer HERO backdrop, then card landscape. */ /** 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("assets/channels")) return false
if (lower.contains("channels-logo")) return false if (lower.contains("channels-logo")) return false
if (lower.contains("/logos/") && lower.endsWith(".png")) 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("/logo") || lower.contains("logo.") || lower.contains("logo_")) {
if (!lower.contains("boxart") && !lower.contains("hero-")) return false if (!lower.contains("boxart") && !lower.contains("hero-")) return false
} }

View file

@ -1153,13 +1153,13 @@ enum EventHelpers {
} }
static func artUrl(_ event: ScheduleEvent) -> String? { 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 ?? [] let imgs = event.images ?? []
if let u = imgs.first(where: { looksLikeArtwork($0) && !$0.localizedCaseInsensitiveContains("hero-16x9") }) { if let u = imgs.first(where: { looksLikeArtwork($0) && !$0.localizedCaseInsensitiveContains("hero-16x9") }) {
return u return u
} }
if let u = imgs.first(where: { looksLikeArtwork($0) }) { 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") } 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("assets/channels") { return false }
if lower.contains("channels-logo") { return false } if lower.contains("channels-logo") { return false }
if lower.contains("/logos/") && lower.hasSuffix(".png") { 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_")), if (lower.contains("/logo") || lower.contains("logo.") || lower.contains("logo_")),
!lower.contains("boxart"), !lower.contains("hero-") { !lower.contains("boxart"), !lower.contains("hero-") {
return false return false

View file

@ -26,6 +26,22 @@ assert(
}), }),
"logo needs fallback" "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( assert(
!needsCategoryFallback({ !needsCategoryFallback({
imageLandscape: "https://cdn.example/real-art.jpg", 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); 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: { export function needsCategoryFallback(e: {
name?: string | null;
imageLandscape?: string | null; imageLandscape?: string | null;
images?: string[]; images?: string[];
}): boolean { }): boolean {
const land = e.imageLandscape; const land = e.imageLandscape;
if (!land) return true; 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 (isChannelLogoUrl(land)) return true;
if (isCategoryFallbackUrl(land)) return false; if (isCategoryFallbackUrl(land)) return false;
// Already has real art // 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(isReplayOrHighlightTitle("Moto3 BWIN Grand Prix of Austria (samenvatting)"), "samenvatting");
assert( assert(
needsZiggoArtEnrichment({ !needsZiggoArtEnrichment({
name: "ATP 250: Chengdu Open",
channel: "Ziggo Sport 1", channel: "Ziggo Sport 1",
imageLandscape: "https://zst1-akm-cdn-triple.akamaized.net/live/zs1/thumb.jpg?scale=640", imageLandscape: "https://zst1-akm-cdn-triple.akamaized.net/live/zs1/thumb.jpg?scale=640",
images: [], 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"); const arts = artUrlsFromMergedId("24692584|nl");

View file

@ -1,10 +1,11 @@
/** /**
* 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) when FAAB only has logos or a misleading
* live snap on highlights/replays.
* *
* Match: channel → Ziggo channelId(s) + (time overlap | series family) + 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 (probed for HTTP 200).
* Fallback: strip live thumbs (never leave football-on-air as Racing art). * Keep FAAB live thumbs for normal live/upcoming cards.
*/ */
const CHANNELS_URL = const CHANNELS_URL =
@ -70,6 +71,7 @@ export function isChannelLogoUrl(url: string | null | undefined): boolean {
} }
export function needsZiggoArtEnrichment(e: { export function needsZiggoArtEnrichment(e: {
name?: string | null;
channel?: string | null; channel?: string | null;
network?: string | null; network?: string | null;
imageLandscape?: string | null; imageLandscape?: string | null;
@ -77,9 +79,12 @@ export function needsZiggoArtEnrichment(e: {
}): boolean { }): boolean {
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 ((e.images ?? []).some(isLiveChannelThumb)) return true;
if (e.imageLandscape && isChannelLogoUrl(e.imageLandscape)) 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; 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> { async function loadZiggoIndex(fromMs: number, toMs: number): Promise<CacheState | null> {
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache; 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. * Enrich Ziggo Sport rows that lack usable FAAB art (logos / highlight live-snaps)
* Soft-fails (returns input) if Ziggo GO is unreachable — but still strips live thumbs. * 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[]> { export async function enrichZiggoGoArt<T extends EnrichableEvent>(events: T[]): Promise<T[]> {
const targets = events.filter(needsZiggoArtEnrichment); const targets = events.filter(needsZiggoArtEnrichment);
@ -506,13 +548,22 @@ export async function enrichZiggoGoArt<T extends EnrichableEvent>(events: T[]):
const index = await loadZiggoIndex(minMs, maxMs); const index = await loadZiggoIndex(minMs, maxMs);
return events.map((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);
}
return Promise.all(
events.map(async (e) => {
if (!needsZiggoArtEnrichment(e)) return e; if (!needsZiggoArtEnrichment(e)) return e;
// If Ziggo index unavailable: still remove live thumbs if (!index?.events.length) return fallbackArt(e);
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),
@ -521,7 +572,7 @@ export async function enrichZiggoGoArt<T extends EnrichableEvent>(events: T[]):
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 (!ids.size || !Number.isFinite(startMs) || !Number.isFinite(endMs)) { if (!ids.size || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
return stripLiveThumbs(e, channelLogoFallback(index, e.channel, e.network, e.images)); return fallbackArt(e);
} }
const candidates = index.events.filter((z) => ids.has(z.channelId)); const candidates = index.events.filter((z) => ids.has(z.channelId));
@ -531,19 +582,18 @@ export async function enrichZiggoGoArt<T extends EnrichableEvent>(events: T[]):
endMs, endMs,
}); });
if (!match?.mergedId) { if (!match?.mergedId) return fallbackArt(e);
return stripLiveThumbs(e, channelLogoFallback(index, e.channel, e.network, e.images));
}
const arts = artUrlsFromMergedId(match.mergedId); const arts = artUrlsFromMergedId(match.mergedId);
if (!arts) { if (!arts) return fallbackArt(e);
return stripLiveThumbs(e, channelLogoFallback(index, e.channel, e.network, e.images));
} if (!(await artUrlExists(arts.landscape))) return fallbackArt(e);
const images = [ const images = [
arts.landscape, arts.landscape,
arts.portrait, arts.portrait,
...e.images.filter((u) => !isLiveChannelThumb(u)), // Keep FAAB live thumbs as client-side fallback (Gracenote may 404 on device)
...e.images,
]; ];
const seen = new Set<string>(); const seen = new Set<string>();
const deduped = images.filter((u) => { const deduped = images.filter((u) => {
@ -563,5 +613,6 @@ export async function enrichZiggoGoArt<T extends EnrichableEvent>(events: T[]):
logoUrl: e.logoUrl || channelLogoFallback(index, e.channel, e.network, e.images), logoUrl: e.logoUrl || channelLogoFallback(index, e.channel, e.network, e.images),
images: deduped, images: deduped,
}; };
}); })
);
} }