Without those params byguid returns a documentary fallback slate (“Don't go”) instead of the clip.
328 lines
9.5 KiB
TypeScript
328 lines
9.5 KiB
TypeScript
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<ScheduleEventPublic[]> {
|
||
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<ScheduleEventPublic | null> {
|
||
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<string, number> = {
|
||
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<ViaplayHomeRail[]> {
|
||
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<ViaplayHomePayload> {
|
||
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<string, { label: string; count: number }>();
|
||
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,
|
||
};
|
||
}
|