stremio/apps/master-api/src/viaplay/events.ts
Jos Vooges | STH 03e4deab33 Fix Viaplay VOD play: send producturl/cse like the official player.
Without those params byguid returns a documentary fallback slate (“Don't go”) instead of the clip.
2026-09-23 02:47:47 +02:00

328 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
};
}