From 84657acbc99185a9fde83ba189d864d13f755347 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 29 Sep 2026 01:12:42 +0200 Subject: [PATCH] Limit Viaplay hub catch-up to F1 clips; skip ended EPG play. --- .../mediacluster/tv/ui/ViaplayHubScreen.kt | 49 ++++++------- apps/ios/VMC/UI/EventHubsScreens.swift | 20 ++++-- apps/master-api/src/viaplay/events.ts | 72 +++++++++++++------ apps/master-api/src/viaplay/play.ts | 4 +- 4 files changed, 90 insertions(+), 55 deletions(-) diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ViaplayHubScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ViaplayHubScreen.kt index a040613..ee2c62e 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ViaplayHubScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ViaplayHubScreen.kt @@ -178,11 +178,13 @@ fun ViaplayHubScreen( val today = payload.today.sportFiltered() val weekend = payload.weekend.sportFiltered() val later = payload.later.sportFiltered() - val catchup = payload.catchup.sportFiltered() val sports = payload.sports - val rails = payload.rails + val rails = payload.rails.filter { it.items.isNotEmpty() }.filter { rail -> + val f = sportFilter + f == null || rail.id != "f1-replay" || f.equals("racing", ignoreCase = true) + } val hasSchedule = live.isNotEmpty() || today.isNotEmpty() || - weekend.isNotEmpty() || later.isNotEmpty() || catchup.isNotEmpty() + weekend.isNotEmpty() || later.isNotEmpty() val hasAny = hasSchedule || rails.isNotEmpty() LazyColumn( @@ -207,7 +209,7 @@ fun ViaplayHubScreen( fontWeight = FontWeight.Black, ) Text( - "Live · agenda · samenvattingen · docs", + "Live · agenda · Formule 1", color = VpLook.Mist, fontSize = 14.sp, ) @@ -305,23 +307,12 @@ fun ViaplayHubScreen( ) } } - if (catchup.isNotEmpty()) { - item(key = "catchup") { - VpEventRail( - title = "Terugkijken", - accent = VpLook.Accent, - events = catchup, - nowMs = nowMs, - onOpen = onOpenEvent, - ) - } - } - // Officiële sport-sectie planken (HAR: /sport) rails.forEach { rail -> item(key = "rail-${rail.id}") { VpCatalogRail( title = rail.title, + subtitle = if (rail.id == "f1-replay") "Races, kwalificatie en hoogtepunten" else null, accent = VpLook.Accent, items = rail.items, onOpen = ::onItem, @@ -367,19 +358,29 @@ private fun VpEventRail( @Composable private fun VpCatalogRail( title: String, + subtitle: String? = null, accent: Color, items: List, onOpen: (ViaplayHomeItem) -> Unit, ) { Column(Modifier.padding(top = 18.dp, bottom = 6.dp)) { - Text( - title.uppercase(Locale("nl", "NL")), - color = accent, - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.2.sp, - modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset, vertical = 6.dp), - ) + Column(Modifier.padding(horizontal = HomeChrome.EdgeInset, vertical = 6.dp)) { + Text( + title.uppercase(Locale("nl", "NL")), + color = accent, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.2.sp, + ) + if (!subtitle.isNullOrBlank()) { + Text( + subtitle, + color = VpLook.Mist, + fontSize = 12.sp, + modifier = Modifier.padding(top = 2.dp), + ) + } + } LazyRow( contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), horizontalArrangement = Arrangement.spacedBy(12.dp), diff --git a/apps/ios/VMC/UI/EventHubsScreens.swift b/apps/ios/VMC/UI/EventHubsScreens.swift index 2c502b3..96a9bbc 100644 --- a/apps/ios/VMC/UI/EventHubsScreens.swift +++ b/apps/ios/VMC/UI/EventHubsScreens.swift @@ -568,9 +568,14 @@ struct ViaplayHubScreen: View { let today = filtered(p.today) let weekend = filtered(p.weekend) let later = filtered(p.later) - let catchup = filtered(p.catchup) - let rails = p.rails.filter { !$0.items.isEmpty } - let noEvents = live.isEmpty && today.isEmpty && weekend.isEmpty && later.isEmpty && catchup.isEmpty + let rails = p.rails.filter { rail in + guard !rail.items.isEmpty else { return false } + if rail.id == "f1-replay", let sport { + return sport.caseInsensitiveCompare("racing") == .orderedSame + } + return true + } + let noEvents = live.isEmpty && today.isEmpty && weekend.isEmpty && later.isEmpty && rails.isEmpty VStack(alignment: .leading, spacing: 28) { hero(featured: live.first ?? today.first ?? weekend.first ?? later.first, layout) @@ -595,10 +600,13 @@ struct ViaplayHubScreen: View { eventRail("Vandaag", today, width: 240) eventRail("Dit weekend", weekend, width: 240) eventRail("Binnenkort", later, width: 240) - eventRail("Terugkijken", catchup, width: 240) ForEach(rails) { rail in - HubSection(title: rail.title) { + HubSection( + title: rail.title, + subtitle: rail.id == "f1-replay" ? "Races, kwalificatie en hoogtepunten" : nil, + count: rail.items.count + ) { HubRail(items: rail.items) { item in itemTap(item) { HubContentCard(item: item, theme: theme, width: 240) @@ -642,7 +650,7 @@ struct ViaplayHubScreen: View { eyebrow: "Viaplay", isLive: false, title: "Viaplay Sport", - subtitle: "Live sport, samenvattingen en meer" + subtitle: "Live, agenda en Formule 1 terugkijken" ) { EmptyView() } } } diff --git a/apps/master-api/src/viaplay/events.ts b/apps/master-api/src/viaplay/events.ts index 8d5807f..2dbd89a 100644 --- a/apps/master-api/src/viaplay/events.ts +++ b/apps/master-api/src/viaplay/events.ts @@ -10,9 +10,6 @@ import { import type { ViaplayEventSnapshot } from "./tweaks"; import { toMediaGuid } from "./accounts"; -/** Officieel Viaplay-terugkijken venster (~48u na start/einde). */ -export const VIAPLAY_CATCHUP_MS = 48 * 60 * 60_000; - function phaseFor(startMs: number, endMs: number, nowMs: number): "live" | "upcoming" | "ended" { if (nowMs < startMs) return "upcoming"; if (nowMs > endMs) return "ended"; @@ -33,7 +30,6 @@ export function eventToPublic( nowMs: number ): ScheduleEventPublic { const phase = phaseFor(event.startMs, event.endMs, nowMs); - const catchupOk = phase === "ended" && nowMs <= event.endMs + VIAPLAY_CATCHUP_MS; return { id: toEventId(event.mediaGuid), name: event.title, @@ -46,7 +42,7 @@ export function eventToPublic( network: "Viaplay", isLive: phase === "live", phase, - playable: phase === "live" || catchupOk, + playable: phase === "live", status: phase, start: new Date(event.startMs).toISOString(), stop: new Date(event.endMs).toISOString(), @@ -72,7 +68,7 @@ export async function listViaplayScheduleEvents( const horizon = nowMs + aheadMs; const out: ScheduleEventPublic[] = []; for (const event of config.events) { - // Hub/Events: live + upcoming; catch-up zit in Viaplay-hub rails / detail play. + // Hub/Events: live + upcoming. Terugkijken alleen via F1-clips in de hub. if (event.endMs < nowMs) continue; if (event.startMs > horizon) continue; const pub = eventToPublic(event, nowMs); @@ -139,7 +135,7 @@ export type ViaplayHomePayload = { weekend: ScheduleEventPublic[]; /** Upcoming daarna binnen sync-horizon */ later: ScheduleEventPublic[]; - /** Recent afgelopen binnen catch-up venster */ + /** Leeg: generic EPG-catch-up is uit. F1-terugkijken zit in `rails`. */ catchup: ScheduleEventPublic[]; /** Sport-chips aanwezig in de payload */ sports: ViaplaySportChip[]; @@ -184,10 +180,7 @@ function isWeekendLocal(ms: number): boolean { } function railItemToHome(item: ViaplaySportRailItem, nowMs: number): ViaplayHomeItem { - const epg = /^S\d+/i.test(String(item.guid || "").replace(/-NL$/i, "")); - const isEvent = - (item.kind === "sport" && item.playMode === "live") || - (item.kind === "sport" && epg); + const isEvent = item.kind === "sport" && item.playMode === "live"; const openAs: ViaplayHomeItem["openAs"] = isEvent ? "event" : "content"; const id = openAs === "event" ? toEventId(toMediaGuid(item.guid)) : `viaplay-content:${item.guid}`; @@ -227,6 +220,46 @@ function mapSportRails(rails: ViaplaySportRail[], nowMs: number): ViaplayHomeRai })); } +const F1_RE = + /formule[\s-]*1|formula[\s-]*1|\bformula1\b|\bf1\b|\/formule-1|\/formula-1|series-formula1/i; +const EPG_GUID_RE = /^S\d{8,}$/i; + +function isF1ReplayItem(item: ViaplayHomeItem, railTitle: string): boolean { + if (item.openAs === "event") return false; + if (item.kind === "series") return false; + if (EPG_GUID_RE.test(String(item.guid || "").replace(/-NL$/i, ""))) return false; + return F1_RE.test(`${railTitle} ${item.title} ${item.subtitle || ""} ${item.pageUrl || ""}`); +} + +function f1ReplayBadge(item: ViaplayHomeItem): string | null { + const t = item.title.toLowerCase(); + if (t.includes("kwalificatie")) return "Kwalificatie"; + if (t.includes("sprintrace") || t.includes("sprintkwali") || /\bsprint\b/.test(t)) { + return "Sprint"; + } + if (/^race\b/.test(t) || t.includes("race |") || t.includes("race –") || t.includes("race -")) { + return "Race"; + } + if (t.includes("terugblik") || t.includes("highlight") || t.includes("hoogtepunten")) { + return "Hoogtepunten"; + } + return item.kind === "clip" ? "F1" : item.badge; +} + +function f1ReplayRails(raw: ViaplayHomeRail[]): ViaplayHomeRail[] { + const seen = new Set(); + const items: ViaplayHomeItem[] = []; + for (const rail of raw) { + for (const item of rail.items) { + if (!isF1ReplayItem(item, rail.title) || seen.has(item.guid)) continue; + seen.add(item.guid); + items.push({ ...item, badge: f1ReplayBadge(item) }); + } + } + if (!items.length) return []; + return [{ id: "f1-replay", title: "Formule 1", items: items.slice(0, 48) }]; +} + let sportRailsCache: { at: number; rails: ViaplayHomeRail[] } | null = null; const SPORT_RAILS_TTL_MS = 5 * 60_000; @@ -246,7 +279,7 @@ async function loadSportRailsHome(nowMs: number): Promise { } } -/** Hub: tijd-rails + officiële sport-planken (samenvattingen/docs). */ +/** Hub: live + agenda, plus één Formule 1-terugkijkplank. */ export async function getViaplayHome(nowMs = Date.now()): Promise { const config = await loadViaplayConfigOnly(); const empty: ViaplayHomePayload = { @@ -267,20 +300,15 @@ export async function getViaplayHome(nowMs = Date.now()): Promise horizon) continue; - const pub = eventToPublic(event, nowMs); - if (pub.phase === "ended") { - if (pub.playable) catchup.push(pub); - continue; - } if (event.endMs < nowMs) continue; + const pub = eventToPublic(event, nowMs); + if (pub.phase === "ended") continue; all.push(pub); } all.sort((a, b) => Date.parse(a.start) - Date.parse(b.start)); - catchup.sort((a, b) => Date.parse(b.start) - Date.parse(a.start)); const live = all.filter((e) => e.phase === "live"); const upcoming = all.filter((e) => e.phase === "upcoming"); @@ -307,7 +335,7 @@ export async function getViaplayHome(nowMs = Date.now()): Promise(); - for (const e of [...all, ...catchup.slice(0, 40)]) { + for (const e of all) { const id = e.sportBucket || "other"; const label = e.sportLabel || id; const cur = sportCounts.get(id) ?? { label, count: 0 }; @@ -319,7 +347,7 @@ export async function getViaplayHome(nowMs = Date.now()): Promise ({ id, label: v.label, count: v.count })) .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, "nl")); - const rails = await loadSportRailsHome(nowMs); + const rails = f1ReplayRails(await loadSportRailsHome(nowMs)); return { generatedAt: new Date(nowMs).toISOString(), @@ -327,7 +355,7 @@ export async function getViaplayHome(nowMs = Date.now()): Promise event.endMs + VIAPLAY_CATCHUP_MS) { + if (now > event.endMs) { throw new AppError("GONE", "Dit event is afgelopen", 410); } return await ensureViaplayEventReady(sessionSecret, event);