Limit Viaplay hub catch-up to F1 clips; skip ended EPG play.

This commit is contained in:
Jos Vooges | STH 2026-09-29 01:12:42 +02:00
parent 7cfe2598a8
commit 84657acbc9
4 changed files with 90 additions and 55 deletions

View file

@ -178,11 +178,13 @@ fun ViaplayHubScreen(
val today = payload.today.sportFiltered() val today = payload.today.sportFiltered()
val weekend = payload.weekend.sportFiltered() val weekend = payload.weekend.sportFiltered()
val later = payload.later.sportFiltered() val later = payload.later.sportFiltered()
val catchup = payload.catchup.sportFiltered()
val sports = payload.sports 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() || val hasSchedule = live.isNotEmpty() || today.isNotEmpty() ||
weekend.isNotEmpty() || later.isNotEmpty() || catchup.isNotEmpty() weekend.isNotEmpty() || later.isNotEmpty()
val hasAny = hasSchedule || rails.isNotEmpty() val hasAny = hasSchedule || rails.isNotEmpty()
LazyColumn( LazyColumn(
@ -207,7 +209,7 @@ fun ViaplayHubScreen(
fontWeight = FontWeight.Black, fontWeight = FontWeight.Black,
) )
Text( Text(
"Live · agenda · samenvattingen · docs", "Live · agenda · Formule 1",
color = VpLook.Mist, color = VpLook.Mist,
fontSize = 14.sp, 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 -> rails.forEach { rail ->
item(key = "rail-${rail.id}") { item(key = "rail-${rail.id}") {
VpCatalogRail( VpCatalogRail(
title = rail.title, title = rail.title,
subtitle = if (rail.id == "f1-replay") "Races, kwalificatie en hoogtepunten" else null,
accent = VpLook.Accent, accent = VpLook.Accent,
items = rail.items, items = rail.items,
onOpen = ::onItem, onOpen = ::onItem,
@ -367,19 +358,29 @@ private fun VpEventRail(
@Composable @Composable
private fun VpCatalogRail( private fun VpCatalogRail(
title: String, title: String,
subtitle: String? = null,
accent: Color, accent: Color,
items: List<ViaplayHomeItem>, items: List<ViaplayHomeItem>,
onOpen: (ViaplayHomeItem) -> Unit, onOpen: (ViaplayHomeItem) -> Unit,
) { ) {
Column(Modifier.padding(top = 18.dp, bottom = 6.dp)) { Column(Modifier.padding(top = 18.dp, bottom = 6.dp)) {
Column(Modifier.padding(horizontal = HomeChrome.EdgeInset, vertical = 6.dp)) {
Text( Text(
title.uppercase(Locale("nl", "NL")), title.uppercase(Locale("nl", "NL")),
color = accent, color = accent,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp, letterSpacing = 1.2.sp,
modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset, vertical = 6.dp),
) )
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
color = VpLook.Mist,
fontSize = 12.sp,
modifier = Modifier.padding(top = 2.dp),
)
}
}
LazyRow( LazyRow(
contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),

View file

@ -568,9 +568,14 @@ struct ViaplayHubScreen: View {
let today = filtered(p.today) let today = filtered(p.today)
let weekend = filtered(p.weekend) let weekend = filtered(p.weekend)
let later = filtered(p.later) let later = filtered(p.later)
let catchup = filtered(p.catchup) let rails = p.rails.filter { rail in
let rails = p.rails.filter { !$0.items.isEmpty } guard !rail.items.isEmpty else { return false }
let noEvents = live.isEmpty && today.isEmpty && weekend.isEmpty && later.isEmpty && catchup.isEmpty 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) { VStack(alignment: .leading, spacing: 28) {
hero(featured: live.first ?? today.first ?? weekend.first ?? later.first, layout) hero(featured: live.first ?? today.first ?? weekend.first ?? later.first, layout)
@ -595,10 +600,13 @@ struct ViaplayHubScreen: View {
eventRail("Vandaag", today, width: 240) eventRail("Vandaag", today, width: 240)
eventRail("Dit weekend", weekend, width: 240) eventRail("Dit weekend", weekend, width: 240)
eventRail("Binnenkort", later, width: 240) eventRail("Binnenkort", later, width: 240)
eventRail("Terugkijken", catchup, width: 240)
ForEach(rails) { rail in 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 HubRail(items: rail.items) { item in
itemTap(item) { itemTap(item) {
HubContentCard(item: item, theme: theme, width: 240) HubContentCard(item: item, theme: theme, width: 240)
@ -642,7 +650,7 @@ struct ViaplayHubScreen: View {
eyebrow: "Viaplay", eyebrow: "Viaplay",
isLive: false, isLive: false,
title: "Viaplay Sport", title: "Viaplay Sport",
subtitle: "Live sport, samenvattingen en meer" subtitle: "Live, agenda en Formule 1 terugkijken"
) { EmptyView() } ) { EmptyView() }
} }
} }

View file

@ -10,9 +10,6 @@ import {
import type { ViaplayEventSnapshot } from "./tweaks"; import type { ViaplayEventSnapshot } from "./tweaks";
import { toMediaGuid } from "./accounts"; 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" { function phaseFor(startMs: number, endMs: number, nowMs: number): "live" | "upcoming" | "ended" {
if (nowMs < startMs) return "upcoming"; if (nowMs < startMs) return "upcoming";
if (nowMs > endMs) return "ended"; if (nowMs > endMs) return "ended";
@ -33,7 +30,6 @@ export function eventToPublic(
nowMs: number nowMs: number
): ScheduleEventPublic { ): ScheduleEventPublic {
const phase = phaseFor(event.startMs, event.endMs, nowMs); const phase = phaseFor(event.startMs, event.endMs, nowMs);
const catchupOk = phase === "ended" && nowMs <= event.endMs + VIAPLAY_CATCHUP_MS;
return { return {
id: toEventId(event.mediaGuid), id: toEventId(event.mediaGuid),
name: event.title, name: event.title,
@ -46,7 +42,7 @@ export function eventToPublic(
network: "Viaplay", network: "Viaplay",
isLive: phase === "live", isLive: phase === "live",
phase, phase,
playable: phase === "live" || catchupOk, playable: phase === "live",
status: phase, status: phase,
start: new Date(event.startMs).toISOString(), start: new Date(event.startMs).toISOString(),
stop: new Date(event.endMs).toISOString(), stop: new Date(event.endMs).toISOString(),
@ -72,7 +68,7 @@ export async function listViaplayScheduleEvents(
const horizon = nowMs + aheadMs; const horizon = nowMs + aheadMs;
const out: ScheduleEventPublic[] = []; const out: ScheduleEventPublic[] = [];
for (const event of config.events) { 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.endMs < nowMs) continue;
if (event.startMs > horizon) continue; if (event.startMs > horizon) continue;
const pub = eventToPublic(event, nowMs); const pub = eventToPublic(event, nowMs);
@ -139,7 +135,7 @@ export type ViaplayHomePayload = {
weekend: ScheduleEventPublic[]; weekend: ScheduleEventPublic[];
/** Upcoming daarna binnen sync-horizon */ /** Upcoming daarna binnen sync-horizon */
later: ScheduleEventPublic[]; later: ScheduleEventPublic[];
/** Recent afgelopen binnen catch-up venster */ /** Leeg: generic EPG-catch-up is uit. F1-terugkijken zit in `rails`. */
catchup: ScheduleEventPublic[]; catchup: ScheduleEventPublic[];
/** Sport-chips aanwezig in de payload */ /** Sport-chips aanwezig in de payload */
sports: ViaplaySportChip[]; sports: ViaplaySportChip[];
@ -184,10 +180,7 @@ function isWeekendLocal(ms: number): boolean {
} }
function railItemToHome(item: ViaplaySportRailItem, nowMs: number): ViaplayHomeItem { 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";
const isEvent =
(item.kind === "sport" && item.playMode === "live") ||
(item.kind === "sport" && epg);
const openAs: ViaplayHomeItem["openAs"] = isEvent ? "event" : "content"; const openAs: ViaplayHomeItem["openAs"] = isEvent ? "event" : "content";
const id = const id =
openAs === "event" ? toEventId(toMediaGuid(item.guid)) : `viaplay-content:${item.guid}`; 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<string>();
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; let sportRailsCache: { at: number; rails: ViaplayHomeRail[] } | null = null;
const SPORT_RAILS_TTL_MS = 5 * 60_000; const SPORT_RAILS_TTL_MS = 5 * 60_000;
@ -246,7 +279,7 @@ async function loadSportRailsHome(nowMs: number): Promise<ViaplayHomeRail[]> {
} }
} }
/** 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<ViaplayHomePayload> { export async function getViaplayHome(nowMs = Date.now()): Promise<ViaplayHomePayload> {
const config = await loadViaplayConfigOnly(); const config = await loadViaplayConfigOnly();
const empty: ViaplayHomePayload = { const empty: ViaplayHomePayload = {
@ -267,20 +300,15 @@ export async function getViaplayHome(nowMs = Date.now()): Promise<ViaplayHomePay
const todayYmd = localYmd(nowMs); const todayYmd = localYmd(nowMs);
const all: ScheduleEventPublic[] = []; const all: ScheduleEventPublic[] = [];
const catchup: ScheduleEventPublic[] = [];
for (const event of config.events || []) { for (const event of config.events || []) {
if (event.startMs > horizon) continue; if (event.startMs > horizon) continue;
const pub = eventToPublic(event, nowMs);
if (pub.phase === "ended") {
if (pub.playable) catchup.push(pub);
continue;
}
if (event.endMs < nowMs) continue; if (event.endMs < nowMs) continue;
const pub = eventToPublic(event, nowMs);
if (pub.phase === "ended") continue;
all.push(pub); all.push(pub);
} }
all.sort((a, b) => Date.parse(a.start) - Date.parse(b.start)); 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 live = all.filter((e) => e.phase === "live");
const upcoming = all.filter((e) => e.phase === "upcoming"); const upcoming = all.filter((e) => e.phase === "upcoming");
@ -307,7 +335,7 @@ export async function getViaplayHome(nowMs = Date.now()): Promise<ViaplayHomePay
} }
const sportCounts = new Map<string, { label: string; count: number }>(); const sportCounts = new Map<string, { label: string; count: number }>();
for (const e of [...all, ...catchup.slice(0, 40)]) { for (const e of all) {
const id = e.sportBucket || "other"; const id = e.sportBucket || "other";
const label = e.sportLabel || id; const label = e.sportLabel || id;
const cur = sportCounts.get(id) ?? { label, count: 0 }; const cur = sportCounts.get(id) ?? { label, count: 0 };
@ -319,7 +347,7 @@ export async function getViaplayHome(nowMs = Date.now()): Promise<ViaplayHomePay
.map(([id, v]) => ({ id, label: v.label, count: v.count })) .map(([id, v]) => ({ id, label: v.label, count: v.count }))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, "nl")); .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 { return {
generatedAt: new Date(nowMs).toISOString(), generatedAt: new Date(nowMs).toISOString(),
@ -327,7 +355,7 @@ export async function getViaplayHome(nowMs = Date.now()): Promise<ViaplayHomePay
today, today,
weekend, weekend,
later, later,
catchup: catchup.slice(0, 40), catchup: [],
sports, sports,
events: all, events: all,
rails, rails,

View file

@ -43,7 +43,6 @@ import {
} from "./settings"; } from "./settings";
import { findViaplayEvent } from "./sync"; import { findViaplayEvent } from "./sync";
import { isMpdCacheFresh, type ViaplayEventSnapshot } from "./tweaks"; import { isMpdCacheFresh, type ViaplayEventSnapshot } from "./tweaks";
import { VIAPLAY_CATCHUP_MS } from "./events";
export type LastVodPlayDiag = ViaplayPlayDiag; export type LastVodPlayDiag = ViaplayPlayDiag;
@ -968,8 +967,7 @@ export async function resolveViaplayPlay(
if (!event) throw new AppError("NOT_FOUND", "Viaplay-event niet gevonden", 404); if (!event) throw new AppError("NOT_FOUND", "Viaplay-event niet gevonden", 404);
const now = Date.now(); const now = Date.now();
// Catch-up: officieel ~48u; live-pad (play-live + KAP) blijft na einde bruikbaar. if (now > event.endMs) {
if (now > event.endMs + VIAPLAY_CATCHUP_MS) {
throw new AppError("GONE", "Dit event is afgelopen", 410); throw new AppError("GONE", "Dit event is afgelopen", 410);
} }
return await ensureViaplayEventReady(sessionSecret, event); return await ensureViaplayEventReady(sessionSecret, event);