import type { ScheduleEventPublic } from "../viewer/schedule-events"; import { fetchSportRails, type ViaplaySportRail, type ViaplaySportRailItem, } from "./client"; import { loadViaplayConfigOnly, } from "./settings"; 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"; return "live"; } export function toEventId(mediaGuid: string): string { return `viaplay:${mediaGuid}`; } export function parseViaplayEventId(eventId: string): string | null { const m = /^viaplay:(.+)$/i.exec(eventId.trim()); return m?.[1]?.trim() || null; } export function eventToPublic( event: ViaplayEventSnapshot, 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, description: event.subtitle, provider: "viaplay", category: event.category || "sport", eventType: "live", subtitle: event.sportLabel, channel: event.channelName || "Viaplay", network: "Viaplay", isLive: phase === "live", phase, playable: phase === "live" || catchupOk, status: phase, start: new Date(event.startMs).toISOString(), stop: new Date(event.endMs).toISOString(), startLocal: null, stopLocal: null, sportBucket: event.sportBucket || "sport", sportLabel: event.sportLabel || "Sport", imageLandscape: event.imageUrl, imageBackdrop: event.imageUrl, imagePortrait: null, logoUrl: null, images: event.imageUrl ? [event.imageUrl] : [], audioLanguages: [], }; } export async function listViaplayScheduleEvents( nowMs = Date.now() ): Promise { const config = await loadViaplayConfigOnly(); if (!config?.events?.length) return []; const aheadMs = Math.max(1, config.syncDaysAhead || 3) * 86_400_000; 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. if (event.endMs < nowMs) continue; if (event.startMs > horizon) continue; const pub = eventToPublic(event, nowMs); if (pub.phase === "ended") continue; out.push(pub); } return out; } export async function getViaplayScheduleEvent( eventId: string, nowMs = Date.now() ): Promise { const mediaGuid = parseViaplayEventId(eventId); if (!mediaGuid) return null; const config = await loadViaplayConfigOnly(); const event = config?.events.find( (e) => e.mediaGuid === mediaGuid || e.guid === mediaGuid ); if (!event) return null; return eventToPublic(event, nowMs); } export type ViaplaySportChip = { id: string; label: string; count: number; }; export type ViaplayHomeItem = { id: string; guid: string; title: string; subtitle: string | null; imageUrl: string | null; kind: ViaplaySportRailItem["kind"]; /** event = schedule detail; content = VOD/clip play */ openAs: "event" | "content"; playable: boolean; durationLabel: string | null; start: string | null; badge: string | null; /** Content-API page URL — nodig voor correcte byguid producturl */ pageUrl: string | null; }; export type ViaplayHomeRail = { id: string; title: string; items: ViaplayHomeItem[]; }; export type ViaplayHomePayload = { generatedAt: string; /** Nu live */ live: ScheduleEventPublic[]; /** Upcoming vandaag (lokale kalenderdag) */ today: ScheduleEventPublic[]; /** Upcoming dit weekend (vr–zo), niet al in today */ weekend: ScheduleEventPublic[]; /** Upcoming daarna binnen sync-horizon */ later: ScheduleEventPublic[]; /** Recent afgelopen binnen catch-up venster */ catchup: ScheduleEventPublic[]; /** Sport-chips aanwezig in de payload */ sports: ViaplaySportChip[]; /** Alle niet-ended events (voor client-side sportfilter) */ events: ScheduleEventPublic[]; /** Officiële sport-sectie planken (samenvattingen, docs, …) */ rails: ViaplayHomeRail[]; }; const ZONE_NL = "Europe/Amsterdam"; function localYmd(ms: number, timeZone = ZONE_NL): string { return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", }).format(new Date(ms)); } function localWeekday(ms: number, timeZone = ZONE_NL): number { // 0=zo … 6=za (en-US) const day = new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short", }).format(new Date(ms)); const map: Record = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, }; return map[day] ?? 0; } function isWeekendLocal(ms: number): boolean { const d = localWeekday(ms); return d === 5 || d === 6 || d === 0; // vr za zo } function railItemToHome(item: ViaplaySportRailItem, nowMs: number): ViaplayHomeItem { 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}`; let badge: string | null = null; if (item.startMs != null && item.endMs != null) { const phase = phaseFor(item.startMs, item.endMs, nowMs); if (phase === "live") badge = "LIVE"; else if (phase === "ended") badge = "TERUGKIJKEN"; } else if (item.kind === "clip") { badge = item.durationLabel || "CLIP"; } else if (item.kind === "series") { badge = "SERIE"; } return { id, guid: item.guid, title: item.title, subtitle: item.subtitle, imageUrl: item.imageUrl, kind: item.kind, openAs, playable: item.playMode != null, durationLabel: item.durationLabel, start: item.startMs != null ? new Date(item.startMs).toISOString() : null, badge, pageUrl: item.pageUrl, }; } function mapSportRails(rails: ViaplaySportRail[], nowMs: number): ViaplayHomeRail[] { return rails.map((rail) => ({ id: rail.id, title: rail.title, items: rail.items.map((item) => railItemToHome(item, nowMs)), })); } let sportRailsCache: { at: number; rails: ViaplayHomeRail[] } | null = null; const SPORT_RAILS_TTL_MS = 5 * 60_000; async function loadSportRailsHome(nowMs: number): Promise { if (sportRailsCache && nowMs - sportRailsCache.at < SPORT_RAILS_TTL_MS) { return sportRailsCache.rails; } try { const config = await loadViaplayConfigOnly(); if (!config) return sportRailsCache?.rails ?? []; const raw = await fetchSportRails(config.deviceKey, config.httpTimeoutMs); const rails = mapSportRails(raw, nowMs); sportRailsCache = { at: nowMs, rails }; return rails; } catch { return sportRailsCache?.rails ?? []; } } /** Hub: tijd-rails + officiële sport-planken (samenvattingen/docs). */ export async function getViaplayHome(nowMs = Date.now()): Promise { const config = await loadViaplayConfigOnly(); const empty: ViaplayHomePayload = { generatedAt: new Date(nowMs).toISOString(), live: [], today: [], weekend: [], later: [], catchup: [], sports: [], events: [], rails: [], }; if (!config) return empty; const aheadMs = Math.max(1, config.syncDaysAhead || 7) * 86_400_000; const horizon = nowMs + aheadMs; const todayYmd = localYmd(nowMs); const all: ScheduleEventPublic[] = []; const catchup: ScheduleEventPublic[] = []; for (const event of config.events || []) { 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; 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"); const today: ScheduleEventPublic[] = []; const weekend: ScheduleEventPublic[] = []; const later: ScheduleEventPublic[] = []; for (const e of upcoming) { const startMs = Date.parse(e.start); if (!Number.isFinite(startMs)) { later.push(e); continue; } if (localYmd(startMs) === todayYmd) { today.push(e); continue; } if (isWeekendLocal(startMs) && startMs - nowMs < 7 * 86_400_000) { weekend.push(e); continue; } later.push(e); } const sportCounts = new Map(); for (const e of [...all, ...catchup.slice(0, 40)]) { const id = e.sportBucket || "other"; const label = e.sportLabel || id; const cur = sportCounts.get(id) ?? { label, count: 0 }; cur.count += 1; if (!cur.label) cur.label = label; sportCounts.set(id, cur); } const sports: ViaplaySportChip[] = [...sportCounts.entries()] .map(([id, v]) => ({ 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); return { generatedAt: new Date(nowMs).toISOString(), live, today, weekend, later, catchup: catchup.slice(0, 40), sports, events: all, rails, }; }