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.
1196 lines
38 KiB
TypeScript
1196 lines
38 KiB
TypeScript
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)");
|
||
}
|