stremio/apps/master-api/src/viaplay/client.ts
Jos Vooges | STH 2cc46d87ce Refresh Viaplay session via persistentLogin before VOD play.
Probe proved byguid works with a fresh session cookie; expired tray tokens were the real failure mode. Also drop stale ClearKey on clear VOD and add admin vod-probe.
2026-09-23 03:37:33 +02:00

1196 lines
38 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 { randomUUID } from "node:crypto";
import type { ViaplayAccountCreds } from "./accounts";
import { toMediaGuid } from "./accounts";
export const VIAPLAY_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36 Edg/153.0.0.0";
export type ViaplayPlayResult = {
mediaGuid: string;
mpdUrl: string;
licenseUrl: string | null;
licenseAuth: string | null;
channelName: string | null;
stationId: string | null;
title: string | null;
};
function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: null;
}
function cookieHeader(creds: ViaplayAccountCreds): string {
const parts: string[] = [];
if (creds.session) parts.push(`session=${creds.session}`);
if (creds.accessToken) parts.push(`accessToken=${creds.accessToken}`);
if (creds.profileId) parts.push(`viaplay_profileId=${creds.profileId}`);
if (creds.persistentLogin) parts.push(`persistentLogin=${creds.persistentLogin}`);
return parts.join("; ");
}
export function authHeaders(creds: ViaplayAccountCreds): Record<string, string> {
const headers: Record<string, string> = {
Accept: "*/*",
"Accept-Language": "nl,en;q=0.9,en-GB;q=0.8,en-US;q=0.7",
Origin: "https://viaplay.com",
Referer: "https://viaplay.com/",
"User-Agent": VIAPLAY_USER_AGENT,
Cookie: cookieHeader(creds),
};
const token = normalizeAccessToken(creds.accessToken);
if (token) headers.Authorization = `VIAPLAY-AT ${token}`;
return headers;
}
/**
* Vernieuw session/accessToken via persistentLogin (zoals de webapp na cookie-expiry).
* Zonder verse session-cookie geeft play.viaplay.com 403 of rare fallbacks.
*/
export async function refreshViaplaySession(
creds: ViaplayAccountCreds,
opts: { deviceKey: string; timeoutMs: number }
): Promise<ViaplayAccountCreds | null> {
const pl = creds.persistentLogin?.trim();
if (!pl) return null;
const url = new URL("https://login.viaplay.com/api/persistentLogin/v1");
url.searchParams.set("deviceKey", opts.deviceKey);
url.searchParams.set(
"returnurl",
`https://content.viaplay.com/${opts.deviceKey}`
);
const res = await fetch(url.toString(), {
headers: {
Accept: "application/json",
"Accept-Language": "nl,en;q=0.9",
Origin: "https://viaplay.com",
Referer: "https://viaplay.com/",
"User-Agent": VIAPLAY_USER_AGENT,
Cookie: `persistentLogin=${pl}`,
},
signal: AbortSignal.timeout(opts.timeoutMs),
});
const jar: Record<string, string> = {};
const rawSet =
typeof res.headers.getSetCookie === "function"
? res.headers.getSetCookie()
: [];
for (const sc of rawSet) {
const m = sc.match(/^([^=]+)=([^;]*)/);
if (m) jar[m[1]!] = m[2]!;
}
let body: Record<string, unknown> | null = null;
try {
body = (await res.json()) as Record<string, unknown>;
} catch {
body = null;
}
if (res.status !== 200 || !body?.success) return null;
const sessionRaw = jar.session || "";
const accessRaw =
jar.accessToken ||
(typeof asRecord(body.userData)?.accessToken === "string"
? String(asRecord(body.userData)!.accessToken)
: "");
if (!sessionRaw || !accessRaw) return null;
let session = sessionRaw;
try {
session = decodeURIComponent(sessionRaw);
} catch {
/* keep raw */
}
const accessToken = normalizeAccessToken(accessRaw) || accessRaw.trim();
const nextPl = jar.persistentLogin
? (() => {
try {
return decodeURIComponent(jar.persistentLogin);
} catch {
return jar.persistentLogin;
}
})()
: pl;
return {
...creds,
session,
accessToken,
persistentLogin: nextPl,
};
}
async function fetchJson(
url: string,
opts: {
headers?: Record<string, string>;
timeoutMs: number;
method?: string;
}
): Promise<{ status: number; json: unknown; text: string }> {
const response = await fetch(url, {
method: opts.method ?? "GET",
headers: opts.headers,
signal: AbortSignal.timeout(opts.timeoutMs),
});
const text = await response.text();
let json: unknown = null;
try {
json = JSON.parse(text) as unknown;
} catch {
/* ignore */
}
return { status: response.status, json, text };
}
export type ViaplayOverview = {
ok: boolean;
status: number;
partnerName: string | null;
productNames: string[];
summary: string;
error?: string;
/** Sessie werkt, maar pakketinfo ontbreekt/mislukt. */
sessionOk?: boolean;
};
function normalizeAccessToken(raw: string | null | undefined): string | null {
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed) return null;
// Browser/HAR kan "VIAPLAY-AT …", "MTG-AT …" of "Bearer …" plakken
return (
trimmed.replace(/^(VIAPLAY-AT|MTG-AT|Bearer)\s+/i, "").trim() || null
);
}
function accessTokenExpiryInfo(token: string): {
expired: boolean;
expMs: number | null;
} {
try {
const payload = JSON.parse(
Buffer.from(token.split(".")[1]!.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString()
) as { exp?: number };
if (!payload.exp) return { expired: false, expMs: null };
const expMs = payload.exp * 1000;
return { expired: Date.now() >= expMs, expMs };
} catch {
return { expired: false, expMs: null };
}
}
/**
* Pakketinfo komt van account.mtg-api.com/overview.
* Browser (HAR): Authorization: VIAPLAY-AT <jwt> — geen cookies, geen Bearer.
*/
export async function fetchAccountOverview(
creds: ViaplayAccountCreds,
timeoutMs: number
): Promise<ViaplayOverview> {
const token = normalizeAccessToken(creds.accessToken);
if (!token) {
return {
ok: false,
status: 0,
partnerName: null,
productNames: [],
summary: "",
error: "Geen accessToken",
sessionOk: false,
};
}
const tokenInfo = accessTokenExpiryInfo(token);
const headers: Record<string, string> = {
Accept: "*/*",
"Content-Type": "application/json",
"Accept-Language": "nl,en-US;q=0.9,en;q=0.8",
Origin: "https://viaplay.com",
Referer: "https://viaplay.com/",
"User-Agent": VIAPLAY_USER_AGENT,
Authorization: `VIAPLAY-AT ${token}`,
};
let lastStatus = 0;
let lastError = "Overview mislukt";
try {
const res = await fetchJson("https://account.mtg-api.com/overview", {
headers,
timeoutMs: Math.min(timeoutMs, 15_000),
});
lastStatus = res.status;
const body = asRecord(res.json);
if (res.status === 200 && body) {
const parsed = parseOverviewBody(body);
if (parsed.ok && parsed.summary) return parsed;
lastError = "Overview gaf geen pakketinformatie terug";
} else {
lastError =
(typeof body?.message === "string" && body.message) ||
(typeof body?.userMessage === "string" && body.userMessage) ||
(res.status === 403
? "Forbidden"
: res.status === 401
? "Unauthorized"
: `Overview HTTP ${res.status}`);
}
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
let sessionOk = false;
try {
const ping = await fetchJson("https://content.viaplay.com/pcdash-nl?language=nl", {
headers: { ...authHeaders(creds), Accept: "application/json" },
timeoutMs: Math.min(timeoutMs, 12_000),
});
const root = asRecord(ping.json);
sessionOk = Boolean(
ping.status === 200 &&
(asRecord(root?._links)?.["viaplay:userData"] ||
asRecord(root?._links)?.["viaplay:logout"])
);
} catch {
/* ignore */
}
if (
sessionOk &&
(lastStatus === 401 || lastStatus === 403 || /forbidden|unauthorized/i.test(lastError))
) {
lastError = tokenInfo.expired
? "accessToken verlopen — vernieuw tokens en Check opnieuw"
: `overview ${lastError} (Authorization moet VIAPLAY-AT zijn)`;
}
return {
ok: false,
status: lastStatus,
partnerName: null,
productNames: [],
summary: "",
error: sessionOk
? `Sessie geldig, pakketinfo niet opgehaald (${lastError})`
: lastError,
sessionOk,
};
}
function packageLabel(raw: unknown): string | null {
const rec = asRecord(raw);
if (!rec) return null;
for (const key of ["name", "displayName", "title", "productName"]) {
const v = rec[key];
if (typeof v === "string" && v.trim()) return v.trim();
}
return null;
}
/** Alleen echte overview-velden — geen aannames over abonnementstype. */
function parseOverviewBody(body: Record<string, unknown>): ViaplayOverview {
const partner = asRecord(body.partnerInformation);
const partnerName =
typeof partner?.partnerDisplayName === "string"
? partner.partnerDisplayName.trim()
: null;
const productNames = Array.isArray(partner?.productNames)
? partner!.productNames.map((p) => String(p).trim()).filter(Boolean)
: [];
const pkg =
packageLabel(body.package) ||
packageLabel(body.contract) ||
packageLabel(body.rolloverPackage) ||
packageLabel(body.promotion);
if (pkg && !productNames.includes(pkg)) productNames.push(pkg);
const parts = [partnerName, productNames.join(", ")].filter(Boolean);
return {
ok: parts.length > 0,
status: 200,
partnerName,
productNames,
summary: parts.join(" · "),
};
}
function walk(node: unknown, visit: (n: Record<string, unknown>) => void) {
if (!node || typeof node !== "object") return;
if (Array.isArray(node)) {
for (const item of node) walk(item, visit);
return;
}
const rec = node as Record<string, unknown>;
visit(rec);
for (const [k, v] of Object.entries(rec)) {
if (k === "images") continue;
walk(v, visit);
}
}
export type ViaplayEpgProduct = {
guid: string;
title: string;
subtitle: string | null;
startMs: number;
endMs: number;
imageUrl: string | null;
pageUrl: string | null;
category: string | null;
sportLabel: string | null;
sportBucket: string | null;
};
function parseIsoMs(raw: unknown): number | null {
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
return raw > 1e12 ? raw : raw * 1000;
}
const t = String(raw ?? "").trim();
if (!t) return null;
const ms = Date.parse(t);
return Number.isFinite(ms) ? ms : null;
}
function sportFromPageUrl(pageUrl: string | null): {
category: string | null;
sportLabel: string | null;
sportBucket: string | null;
} {
if (!pageUrl) return { category: null, sportLabel: null, sportBucket: null };
try {
const path = new URL(pageUrl).pathname.toLowerCase();
// /pcdash-nl/sport/voetbal/... or /sport/motorsport/formule-1/...
const m = path.match(/\/sport\/([^/]+)(?:\/([^/]+))?/i);
const cat = m?.[1] ? decodeURIComponent(m[1]) : null;
const sub = m?.[2] ? decodeURIComponent(m[2]) : null;
const label = sub || cat;
let bucket: string | null = null;
if (cat) {
if (/motor|formule|racing|f1/i.test(`${cat} ${sub || ""}`)) bucket = "racing";
else if (/voetbal|football|soccer/i.test(cat)) bucket = "football";
else if (/tennis/i.test(cat)) bucket = "tennis";
else if (/hockey|ijshockey/i.test(cat)) bucket = "hockey";
else bucket = "sport";
}
return {
category: cat,
sportLabel: label
? label.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
: null,
sportBucket: bucket,
};
} catch {
return { category: null, sportLabel: null, sportBucket: null };
}
}
function pickImage(content: Record<string, unknown> | null): string | null {
if (!content) return null;
const images = asRecord(content.images);
if (!images) return null;
for (const key of [
"landscape",
"coverart169",
"hero169",
"boxart",
"coverart23",
"cover",
"hero",
"event169",
]) {
const img = asRecord(images[key]);
const url = typeof img?.url === "string" ? img.url : null;
if (url) return url;
}
for (const value of Object.values(images)) {
const img = asRecord(value);
if (typeof img?.url === "string") return img.url;
}
return null;
}
export async function fetchSportDays(
deviceKey: string,
timeoutMs: number
): Promise<string[]> {
const url = `https://content.viaplay.com/${deviceKey}/sport/all?language=nl`;
const res = await fetchJson(url, {
headers: {
Accept: "*/*",
"User-Agent": VIAPLAY_USER_AGENT,
Referer: "https://viaplay.com/",
},
timeoutMs,
});
if (res.status !== 200) {
throw new Error(`Viaplay EPG days mislukt (${res.status})`);
}
const root = asRecord(res.json);
const days = root?._links && asRecord(root._links)?.["viaplay:days"];
const list = Array.isArray(days) ? days : [];
const out: string[] = [];
for (const d of list) {
const href = typeof asRecord(d)?.href === "string" ? String(asRecord(d)!.href) : "";
const m = href.match(/[?&]date=(\d{4}-\d{2}-\d{2})/i);
if (m?.[1]) out.push(m[1]);
}
return [...new Set(out)];
}
export async function fetchEpgDay(
deviceKey: string,
date: string,
timeoutMs: number
): Promise<ViaplayEpgProduct[]> {
const url = `https://content.viaplay.com/${deviceKey}/sport/all?date=${encodeURIComponent(date)}&language=nl`;
const res = await fetchJson(url, {
headers: {
Accept: "*/*",
"User-Agent": VIAPLAY_USER_AGENT,
Referer: "https://viaplay.com/",
},
timeoutMs,
});
if (res.status !== 200) {
throw new Error(`Viaplay EPG ${date} mislukt (${res.status})`);
}
return extractProducts(res.json);
}
export async function fetchSportHighlights(
deviceKey: string,
timeoutMs: number
): Promise<ViaplayEpgProduct[]> {
const url = `https://content.viaplay.com/${deviceKey}/sport?language=nl`;
const res = await fetchJson(url, {
headers: {
Accept: "*/*",
"User-Agent": VIAPLAY_USER_AGENT,
Referer: "https://viaplay.com/",
},
timeoutMs,
});
if (res.status !== 200) return [];
return extractProducts(res.json);
}
/** Catalog-item van de Viaplay sport-sectie (plank/rail). */
export type ViaplaySportRailItem = {
guid: string;
title: string;
subtitle: string | null;
imageUrl: string | null;
/** sport | clip | series | other */
kind: "sport" | "clip" | "series" | "other";
/** Speelbaar via play-live (sport/EPG) of play.viaplay.com/byguid (clip/series). */
playMode: "live" | "vod" | null;
durationLabel: string | null;
startMs: number | null;
endMs: number | null;
pageUrl: string | null;
};
export type ViaplaySportRail = {
id: string;
title: string;
items: ViaplaySportRailItem[];
};
/** HAR: web speelt via stream-href uit de rail-lijst (contextualV2), niet pas na product-fetch. */
const streamHrefCache = new Map<string, { href: string; at: number }>();
const STREAM_HREF_TTL_MS = 15 * 60_000;
function rememberStreamHref(guid: string, href: string | null | undefined): void {
const g = String(guid || "").trim();
const h = typeof href === "string" ? href.trim() : "";
if (!g || !h) return;
streamHrefCache.set(g, { href: h, at: Date.now() });
}
function cachedStreamHref(guid: string): string | null {
const hit = streamHrefCache.get(guid);
if (!hit) return null;
if (Date.now() - hit.at > STREAM_HREF_TTL_MS) {
streamHrefCache.delete(guid);
return null;
}
return hit.href;
}
function streamHref(product: Record<string, unknown>): string | null {
const links = asRecord(product._links);
const stream = asRecord(links?.["viaplay:stream"]);
const href = typeof stream?.href === "string" ? stream.href : null;
return href?.trim() || null;
}
function isPromoVodTitle(title: string | null | undefined): boolean {
return /viaplay\s*presents|don'?t\s*go|viaplay\s*documentary/i.test(
String(title || "")
);
}
function parseDurationLabel(content: Record<string, unknown> | null): string | null {
const duration = asRecord(content?.duration);
if (typeof duration?.readable === "string" && duration.readable.trim()) {
return duration.readable.trim();
}
const ms = Number(duration?.milliseconds);
if (!Number.isFinite(ms) || ms <= 0) return null;
const totalSec = Math.round(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
if (m >= 60) {
const h = Math.floor(m / 60);
return `${h}:${String(m % 60).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
return `${m}:${String(s).padStart(2, "0")}`;
}
function productTitle(content: Record<string, unknown> | null, guid: string): string {
if (!content) return guid;
if (typeof content.title === "string" && content.title.trim()) return content.title.trim();
const series = asRecord(content.series);
if (typeof series?.title === "string" && series.title.trim()) return series.title.trim();
if (typeof content.originalTitle === "string" && content.originalTitle.trim()) {
return content.originalTitle.trim();
}
return guid;
}
function productToRailItem(node: Record<string, unknown>): ViaplaySportRailItem | null {
const system = asRecord(node.system);
const content = asRecord(node.content);
const guid = String(system?.guid ?? "").trim();
if (!guid) return null;
const type = String(node.type ?? "").trim().toLowerCase();
const epg = asRecord(node.epg);
const startMs = epg ? parseIsoMs(epg.start ?? epg.startTime) : null;
const endMs = epg ? parseIsoMs(epg.end ?? epg.endTime ?? epg.stop) : null;
const href = streamHref(node);
const links = asRecord(node._links);
const page =
(typeof asRecord(links?.["viaplay:page"])?.href === "string"
? String(asRecord(links!["viaplay:page"])!.href)
: null) ||
(typeof asRecord(links?.self)?.href === "string"
? String(asRecord(links!.self)!.href)
: null);
let kind: ViaplaySportRailItem["kind"] = "other";
if (type === "sport") kind = "sport";
else if (type === "clip") kind = "clip";
else if (type === "series" || type === "sportseries" || type === "episode") kind = "series";
else if (type === "movie" || type === "film") kind = "series"; // docs/specials → VOD byguid
let playMode: ViaplaySportRailItem["playMode"] = null;
if (href?.includes("play-live.viaplay.com") || (kind === "sport" && startMs != null)) {
playMode = "live";
} else if (
href?.includes("play.viaplay.com/api/stream/byguid") ||
kind === "clip" ||
(kind === "series" && type !== "sportseries")
) {
// sportSeries is vaak een categorie-pagina zonder directe stream
playMode = "vod";
} else if (type === "movie" || type === "film") {
playMode = "vod";
}
const title = productTitle(content, guid);
const subtitle =
String(
content?.originalTitle ?? content?.synopsis ?? content?.description ?? ""
).trim() || null;
rememberStreamHref(guid, href);
return {
guid,
title,
subtitle: subtitle === title ? null : subtitle,
imageUrl: pickImage(content),
kind,
playMode,
durationLabel: parseDurationLabel(content),
startMs,
endMs,
pageUrl: page,
};
}
function blocksFromPage(json: unknown): Array<Record<string, unknown>> {
const root = asRecord(json);
const embedded = asRecord(root?._embedded);
const blocks = embedded?.["viaplay:blocks"];
if (!Array.isArray(blocks)) return [];
const out: Array<Record<string, unknown>> = [];
for (const b of blocks) {
const rec = asRecord(b);
if (rec) out.push(rec);
}
return out;
}
function productsFromBlock(block: Record<string, unknown>): ViaplaySportRailItem[] {
const embedded = asRecord(block._embedded);
const products = embedded?.["viaplay:products"];
if (!Array.isArray(products)) return [];
const out: ViaplaySportRailItem[] = [];
const seen = new Set<string>();
for (const raw of products) {
const node = asRecord(raw);
if (!node) continue;
const item = productToRailItem(node);
if (!item || seen.has(item.guid)) continue;
seen.add(item.guid);
out.push(item);
}
return out;
}
async function fetchSportBlockPage(
deviceKey: string,
blockId: string,
pageNumber: number,
timeoutMs: number,
creds?: ViaplayAccountCreds | null
): Promise<ViaplaySportRailItem[]> {
const u = new URL(`https://content.viaplay.com/${deviceKey}/sport`);
u.searchParams.set("blockId", blockId);
u.searchParams.set("partial", "1");
u.searchParams.set("pageNumber", String(pageNumber));
u.searchParams.set("language", "nl");
const headers: Record<string, string> = {
Accept: "*/*",
"User-Agent": VIAPLAY_USER_AGENT,
Referer: "https://viaplay.com/sport",
};
if (creds) Object.assign(headers, authHeaders(creds));
const res = await fetchJson(u.toString(), { headers, timeoutMs });
if (res.status !== 200) return [];
const blocks = blocksFromPage(res.json);
const block = blocks[0] || asRecord(res.json);
return block ? productsFromBlock(block) : [];
}
/**
* Officiële Viaplay sport-sectie als hub-planken (HAR: /pcdash-nl/sport).
* Lege list-blocks worden 1× bijgeladen via blockId&partial=1.
*/
export async function fetchSportRails(
deviceKey: string,
timeoutMs: number,
creds?: ViaplayAccountCreds | null
): Promise<ViaplaySportRail[]> {
const url = `https://content.viaplay.com/${deviceKey}/sport?language=nl`;
const headers: Record<string, string> = {
Accept: "*/*",
"User-Agent": VIAPLAY_USER_AGENT,
Referer: "https://viaplay.com/sport",
};
if (creds) Object.assign(headers, authHeaders(creds));
const res = await fetchJson(url, { headers, timeoutMs });
if (res.status !== 200) {
throw new Error(`Viaplay sport-sectie mislukt (${res.status})`);
}
const skipTitles = /^(favorites|schema|featurebox)/i;
const rails: ViaplaySportRail[] = [];
for (const block of blocksFromPage(res.json)) {
const type = String(block.type ?? "").trim().toLowerCase();
if (type !== "list" && type !== "singleproductpromo") continue;
const id = String(block.id ?? "").trim();
const title = String(block.title ?? block.profileBlockTitle ?? "").trim();
if (!id || !title || skipTitles.test(title)) continue;
let items = productsFromBlock(block);
if (!items.length) {
try {
items = await fetchSportBlockPage(deviceKey, id, 1, timeoutMs, creds);
} catch {
items = [];
}
}
// Promo zonder embedded products: skip
if (!items.length) continue;
// Alleen speelbare items (geen categorie-tegels zoals sportSeries)
const playable = items.filter((it) => it.playMode != null).slice(0, 40);
if (!playable.length) continue;
rails.push({ id, title, items: playable });
}
return rails;
}
function extractProducts(json: unknown): ViaplayEpgProduct[] {
const out: ViaplayEpgProduct[] = [];
const seen = new Set<string>();
walk(json, (node) => {
const system = asRecord(node.system);
const content = asRecord(node.content);
const epg = asRecord(node.epg);
const guid = String(system?.guid ?? "").trim();
if (!guid || !epg || seen.has(guid)) return;
const startMs = parseIsoMs(epg.start ?? epg.startTime);
const endMs = parseIsoMs(epg.end ?? epg.endTime ?? epg.stop);
if (startMs == null || endMs == null || endMs <= startMs) return;
seen.add(guid);
const links = asRecord(node._links);
const page =
(typeof asRecord(links?.["viaplay:page"])?.href === "string"
? String(asRecord(links!["viaplay:page"])!.href)
: null) ||
(typeof asRecord(links?.self)?.href === "string"
? String(asRecord(links!.self)!.href)
: null);
const sport = sportFromPageUrl(page);
out.push({
guid,
title: String(content?.title ?? system?.guid ?? guid).trim(),
subtitle: String(content?.subtitle ?? content?.description ?? "").trim() || null,
startMs,
endMs,
imageUrl: pickImage(content),
pageUrl: page,
category: sport.category,
sportLabel: sport.sportLabel,
sportBucket: sport.sportBucket,
});
});
return out;
}
export async function playByMediaGuid(
creds: ViaplayAccountCreds,
mediaGuidRaw: string,
opts: { deviceKey: string; timeoutMs: number }
): Promise<ViaplayPlayResult> {
const mediaGuid = toMediaGuid(mediaGuidRaw);
const u = new URL("https://play-live.viaplay.com/api/stream/bymediaguid");
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
u.searchParams.set("deviceName", "web");
u.searchParams.set("deviceType", "pc");
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
u.searchParams.set("deviceKey", opts.deviceKey);
u.searchParams.set("mediaGuid", mediaGuid);
u.searchParams.set("nocc", "autoplayer");
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
const res = await fetchJson(u.toString(), {
headers: authHeaders(creds),
timeoutMs: opts.timeoutMs,
});
const body = asRecord(res.json);
if (res.status !== 200 || !body) {
const msg =
(typeof body?.message === "string" && body.message) ||
`play-live mislukt (${res.status})`;
throw new Error(msg);
}
return parsePlayStreamBody(body, mediaGuid);
}
function parsePlayStreamBody(
body: Record<string, unknown>,
id: string
): ViaplayPlayResult {
const links = asRecord(body._links);
const media = asRecord(links?.["viaplay:media"]);
const license =
asRecord(links?.["viaplay:widevineLicense"]) || asRecord(links?.["viaplay:license"]);
const mpdUrl = typeof media?.href === "string" ? media.href.trim() : "";
if (!mpdUrl) throw new Error("Geen viaplay:media in play-response");
const product = asRecord(body.product);
const channel = asRecord(body.channel) || asRecord(asRecord(body.streamingSession)?.channel);
return {
mediaGuid: id,
mpdUrl,
licenseUrl: typeof license?.href === "string" ? license.href : null,
licenseAuth: typeof license?.auth === "string" ? license.auth : null,
channelName: typeof channel?.channelName === "string" ? channel.channelName : null,
stationId: typeof channel?.stationId === "string" ? channel.stationId : null,
title:
typeof asRecord(product?.content)?.title === "string"
? String(asRecord(product!.content)!.title)
: null,
};
}
/**
* Bouw play.viaplay.com URL uit templated stream-href (content-API).
* RFC 6570 `{?deviceId,...}` → `?` (niet strippen: anders blijft `byguid&deviceKey=` over).
*/
export function expandViaplayStreamHref(
hrefRaw: string,
creds: ViaplayAccountCreds,
opts: { deviceKey: string; guid?: string }
): string {
const cleaned = hrefRaw
.replace(/\{\?[^}]+\}/g, "?")
.replace(/\?&/, "?")
.replace(/\?{2,}/g, "?");
const u = new URL(cleaned);
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
u.searchParams.set("deviceName", "web");
u.searchParams.set("deviceType", "pc");
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
u.searchParams.set("deviceKey", opts.deviceKey);
u.searchParams.set("cse", "true");
u.searchParams.set("win10edge", "true");
if (!u.searchParams.get("defaultAvailabilityContext")) {
u.searchParams.set("defaultAvailabilityContext", "svod");
}
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
const guid = opts.guid || u.searchParams.get("guid") || "";
if (guid && !u.searchParams.get("templatedproducturl")) {
u.searchParams.set(
"templatedproducturl",
`https://content.viaplay.com/{deviceKey}/sport/${guid}?partial=true`
);
}
return u.toString();
}
function buildHarByguidUrl(
creds: ViaplayAccountCreds,
guid: string,
opts: {
deviceKey: string;
productUrl: string;
returnUrl: string;
sectionPath: string;
/** Rijkere contextualV2 uit rail-lijst (HAR). */
contextualv2url?: string | null;
}
): string {
// Altijd canonieke producturl zoals web-HAR — geen profileId/extra query.
const productUrlFinal = `https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?partial=true`;
const u = new URL("https://play.viaplay.com/api/stream/byguid");
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
u.searchParams.set("deviceName", "web");
u.searchParams.set("deviceType", "pc");
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
u.searchParams.set("deviceKey", opts.deviceKey);
u.searchParams.set("cse", "true");
u.searchParams.set(
"splitIOFlagIds",
"all_sports_beyondLiveBeacon_on,web_player_pause_ads_on"
);
u.searchParams.set("guid", guid);
u.searchParams.set("returnurl", opts.returnUrl);
u.searchParams.set("producturl", productUrlFinal);
u.searchParams.set(
"contextualv2url",
(opts.contextualv2url && String(opts.contextualv2url).trim()) ||
`https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?contextualV2=true&productType=clip`
);
u.searchParams.set(
"templatedproducturl",
`https://content.viaplay.com/{deviceKey}/sport/${guid}?partial=true`
);
u.searchParams.set("sectionPath", opts.sectionPath);
u.searchParams.set("defaultAvailabilityContext", "svod");
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
u.searchParams.set("win10edge", "true");
return u.toString();
}
function playBodyGuid(body: Record<string, unknown>): string | null {
const product = asRecord(body.product);
const system = asRecord(product?.system);
return typeof system?.guid === "string" ? system.guid.trim() : null;
}
/** HAR-success: product-link + guid + productType=clip. Promo-docu faalt hierop. */
function assertVodPlayMatchesGuid(
body: Record<string, unknown>,
guid: string,
played: ViaplayPlayResult
): void {
const responseGuid = playBodyGuid(body);
if (responseGuid && responseGuid !== guid) {
throw new Error(`Viaplay gaf andere guid (${responseGuid}) i.p.v. ${guid}`);
}
if (isPromoVodTitle(played.title)) {
throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`);
}
const links = asRecord(body._links);
for (const key of ["viaplay:product", "viaplay:productPage"] as const) {
const href = asRecord(links?.[key])?.href;
if (typeof href === "string" && href.trim() && !href.includes(guid)) {
throw new Error(`Viaplay product-link mismatch (${key})`);
}
}
const product = asRecord(body.product);
const productType =
typeof product?.productType === "string"
? product.productType.trim().toLowerCase()
: "";
if (/vp-sports-clip|sports-clip/i.test(guid) && productType && productType !== "clip") {
throw new Error(`Viaplay productType “${productType}” i.p.v. clip`);
}
const durationMs = Number(body.duration);
// Samenvattingen < ~2u; Don't go-feature is veel langer
if (
/vp-sports-clip|sports-clip/i.test(guid) &&
Number.isFinite(durationMs) &&
durationMs > 2.5 * 60 * 60 * 1000
) {
throw new Error(`Viaplay gaf te lange stream (${Math.round(durationMs / 60000)} min)`);
}
}
function contextualv2FromStreamHref(href: string | null | undefined): string | null {
if (!href) return null;
try {
const cleaned = href
.replace(/\{\?[^}]+\}/g, "?")
.replace(/\?&/, "?")
.replace(/\?{2,}/g, "?");
return new URL(cleaned).searchParams.get("contextualv2url");
} catch {
return null;
}
}
async function fetchProductStreamMeta(
creds: ViaplayAccountCreds,
productUrl: string,
timeoutMs: number
): Promise<{ streamHref: string | null; title: string | null; guid: string | null }> {
let url = productUrl;
try {
const u = new URL(productUrl);
u.searchParams.set("partial", "true");
if (creds.profileId && !u.searchParams.get("profileId")) {
u.searchParams.set("profileId", creds.profileId);
}
url = u.toString();
} catch {
/* keep */
}
const res = await fetchJson(url, {
headers: { ...authHeaders(creds), Accept: "application/json" },
timeoutMs,
});
if (res.status !== 200) {
return { streamHref: null, title: null, guid: null };
}
const root = asRecord(res.json);
const embedded = asRecord(root?._embedded);
const product =
asRecord(embedded?.["viaplay:product"]) ||
asRecord(root?.product) ||
root;
const links = asRecord(product?._links);
const stream =
asRecord(links?.["viaplay:stream"]) || asRecord(links?.["viaplay:templatedStream"]);
const href = typeof stream?.href === "string" ? stream.href.trim() : null;
const content = asRecord(product?.content);
const system = asRecord(product?.system);
const title =
typeof content?.title === "string"
? content.title.trim()
: typeof root?.title === "string"
? String(root.title).trim()
: null;
const guid = typeof system?.guid === "string" ? system.guid.trim() : null;
return { streamHref: href, title, guid };
}
/**
* VOD / clips / samenvattingen — HAR: play.viaplay.com/api/stream/byguid
*
* Web: rail stream-href → byguid (producturl/cse/contextualV2/win10edge) + session-cookie.
* Zonder die context geeft Viaplay een 200 met promo-docu (“Don't go”) i.p.v. 403.
*/
export async function playByGuid(
creds: ViaplayAccountCreds,
guidRaw: string,
opts: {
deviceKey: string;
timeoutMs: number;
productUrl?: string | null;
returnUrl?: string | null;
sectionPath?: string | null;
}
): Promise<ViaplayPlayResult> {
const guid = String(guidRaw || "").trim();
if (!guid) throw new Error("Geen Viaplay guid");
const rawProduct = (opts.productUrl && String(opts.productUrl).trim()) || "";
const productUrl =
rawProduct && rawProduct.includes(guid)
? rawProduct
: `https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?partial=true`;
const returnUrl =
(opts.returnUrl && String(opts.returnUrl).trim()) ||
`https://content.viaplay.com/${opts.deviceKey}/sport`;
const sectionPath = (opts.sectionPath && String(opts.sectionPath).trim()) || "/sport";
const listingHref = cachedStreamHref(guid);
let productHref: string | null = null;
let pageTitle: string | null = null;
try {
const meta = await fetchProductStreamMeta(creds, productUrl, opts.timeoutMs);
pageTitle = meta.title;
if (meta.streamHref) {
productHref = meta.streamHref;
rememberStreamHref(guid, meta.streamHref);
}
} catch {
/* har-url hieronder */
}
const contextualv2url =
contextualv2FromStreamHref(listingHref) ||
contextualv2FromStreamHref(productHref);
// Eerst exacte HAR-query (betrouwbaar), daarna eventuele stream-hrefs.
const playUrls: string[] = [];
const seen = new Set<string>();
const pushUrl = (url: string) => {
if (!url || seen.has(url)) return;
seen.add(url);
playUrls.push(url);
};
pushUrl(
buildHarByguidUrl(creds, guid, {
deviceKey: opts.deviceKey,
productUrl,
returnUrl,
sectionPath,
contextualv2url,
})
);
for (const href of [listingHref, productHref]) {
if (!href || !/play\.viaplay\.com\/api\/stream\/byguid/i.test(href)) continue;
pushUrl(
expandViaplayStreamHref(href, creds, { deviceKey: opts.deviceKey, guid })
);
}
let lastError: Error | null = null;
for (const playUrl of playUrls) {
try {
const res = await fetchJson(playUrl, {
headers: authHeaders(creds),
timeoutMs: opts.timeoutMs,
});
const body = asRecord(res.json);
if (res.status !== 200 || !body) {
const msg =
(typeof body?.message === "string" && body.message) ||
`play-byguid mislukt (${res.status})`;
throw new Error(msg);
}
const played = parsePlayStreamBody(body, guid);
assertVodPlayMatchesGuid(body, guid, played);
if (!played.title && pageTitle && !isPromoVodTitle(pageTitle)) {
played.title = pageTitle;
}
return played;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
}
}
throw lastError || new Error("Viaplay VOD-play mislukt");
}
/** Bouw MPD-URL met EPG start/stop kap (seconden). */
export function kapMpdUrl(
mpdUrl: string,
startMs: number,
endMs: number
): string[] {
const startSec = Math.floor(startMs / 1000);
const stopSec = Math.floor(endMs / 1000);
try {
const u = new URL(mpdUrl);
const file = u.pathname.split("/").pop() || "dash.mpd";
const dir = mpdUrl.replace(/\/[^/]+$/, "");
return [
`${dir}/start/${startSec}/stop/${stopSec}/${file}`,
`${dir}/start/${startSec}/stop/${stopSec}/index.mpd`,
mpdUrl,
];
} catch {
return [mpdUrl];
}
}
export async function fetchMpdXml(
url: string,
timeoutMs: number
): Promise<{ status: number; xml: string; url: string }> {
const response = await fetch(url, {
headers: {
Accept: "*/*",
"User-Agent": VIAPLAY_USER_AGENT,
Referer: "https://viaplay.com/",
},
signal: AbortSignal.timeout(timeoutMs),
});
const xml = await response.text();
return { status: response.status, xml, url };
}
export function extractPsshFromMpd(xml: string): string | null {
const widevine = xml.match(
/<ContentProtection\b[^>]*schemeIdUri=["']urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed["'][\s\S]*?<\/ContentProtection>/i
)?.[0];
const fromCp = widevine?.match(
/<(?:[\w-]+:)?pssh\b[^>]*>([^<]+)<\/(?:[\w-]+:)?pssh>/i
)?.[1]?.trim();
if (fromCp) return fromCp;
return (
xml.match(/<(?:[\w-]+:)?pssh\b[^>]*>([^<]+)<\/(?:[\w-]+:)?pssh>/i)?.[1]?.trim() ??
null
);
}
export function mpdHasContentProtection(xml: string): boolean {
return /ContentProtection/i.test(xml) || /<(?:[\w-]+:)?pssh\b/i.test(xml);
}
/** Probe kap-candidates; prefer first 200 with MPD body. Prefer DRM if available. */
export async function resolveKapMpd(
baseMpd: string,
startMs: number,
endMs: number,
timeoutMs: number
): Promise<{ mpdUrl: string; xml: string; hasPssh: boolean; pssh: string | null }> {
const candidates = kapMpdUrl(baseMpd, startMs, endMs);
let fallback: { mpdUrl: string; xml: string } | null = null;
for (const url of candidates) {
try {
const res = await fetchMpdXml(url, timeoutMs);
if (res.status !== 200 || !/<MPD[\s>]/i.test(res.xml)) continue;
const pssh = extractPsshFromMpd(res.xml);
const hasPssh = !!pssh || mpdHasContentProtection(res.xml);
if (hasPssh) {
return { mpdUrl: res.url, xml: res.xml, hasPssh: true, pssh };
}
if (!fallback) fallback = { mpdUrl: res.url, xml: res.xml };
} catch {
/* next */
}
}
if (fallback) {
return {
mpdUrl: fallback.mpdUrl,
xml: fallback.xml,
hasPssh: false,
pssh: null,
};
}
throw new Error("Geen bruikbare MPD (kap/base)");
}