From bc847b222dee0c2f5da3ce4ea3aa2592ce5fc401 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Thu, 24 Sep 2026 02:02:23 +0200 Subject: [PATCH] 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. --- .../vonas/mediacluster/tv/ui/EventsScreen.kt | 6 +- .../vonas/mediacluster/tv/ui/EventsScreen.kt | 6 +- apps/ios/VMC/UI/EventsScreen.swift | 6 +- .../src/viewer/event-fallbacks.test.ts | 16 ++ apps/master-api/src/viewer/event-fallbacks.ts | 11 +- .../src/viewer/ziggo-epg-art.test.ts | 23 ++- apps/master-api/src/viewer/ziggo-epg-art.ts | 167 ++++++++++++------ 7 files changed, 167 insertions(+), 68 deletions(-) diff --git a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt index 464d7bd..8dd646c 100644 --- a/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt +++ b/apps/android-tv/app-mobile/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt @@ -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 } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt index a9aeef3..17a371e 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt @@ -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 } diff --git a/apps/ios/VMC/UI/EventsScreen.swift b/apps/ios/VMC/UI/EventsScreen.swift index 7ae68a4..623731a 100644 --- a/apps/ios/VMC/UI/EventsScreen.swift +++ b/apps/ios/VMC/UI/EventsScreen.swift @@ -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 diff --git a/apps/master-api/src/viewer/event-fallbacks.test.ts b/apps/master-api/src/viewer/event-fallbacks.test.ts index b8fb010..4b774fa 100644 --- a/apps/master-api/src/viewer/event-fallbacks.test.ts +++ b/apps/master-api/src/viewer/event-fallbacks.test.ts @@ -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", diff --git a/apps/master-api/src/viewer/event-fallbacks.ts b/apps/master-api/src/viewer/event-fallbacks.ts index 3d20950..54af850 100644 --- a/apps/master-api/src/viewer/event-fallbacks.ts +++ b/apps/master-api/src/viewer/event-fallbacks.ts @@ -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 diff --git a/apps/master-api/src/viewer/ziggo-epg-art.test.ts b/apps/master-api/src/viewer/ziggo-epg-art.test.ts index 2bf40e2..78ab00c 100644 --- a/apps/master-api/src/viewer/ziggo-epg-art.test.ts +++ b/apps/master-api/src/viewer/ziggo-epg-art.test.ts @@ -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"); diff --git a/apps/master-api/src/viewer/ziggo-epg-art.ts b/apps/master-api/src/viewer/ziggo-epg-art.ts index f3816ff..da52872 100644 --- a/apps/master-api/src/viewer/ziggo-epg-art.ts +++ b/apps/master-api/src/viewer/ziggo-epg-art.ts @@ -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(url: string): Promise { } } +const ART_PROBE_TIMEOUT_MS = 4_000; +const artUrlCache = new Map(); + +/** Gracenote EventImages often 404 for old/merged ids — never replace FAAB thumbs with dead URLs. */ +export async function artUrlExists(url: string): Promise { + 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 { 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(events: T[]): Promise { const targets = events.filter(needsZiggoArtEnrichment); @@ -506,62 +548,71 @@ export async function enrichZiggoGoArt(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(); - 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(); + 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, + }; + }) + ); }