Auto-probe on account add and always render an Abonnement row, with session-cookie fallback when overview is unavailable.
623 lines
19 KiB
TypeScript
623 lines
19 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}`);
|
|
return parts.join("; ");
|
|
}
|
|
|
|
export function authHeaders(creds: ViaplayAccountCreds): Record<string, string> {
|
|
return {
|
|
Accept: "*/*",
|
|
Origin: "https://viaplay.com",
|
|
Referer: "https://viaplay.com/",
|
|
"User-Agent": VIAPLAY_USER_AGENT,
|
|
Cookie: cookieHeader(creds),
|
|
};
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
export async function fetchAccountOverview(
|
|
creds: ViaplayAccountCreds,
|
|
timeoutMs: number
|
|
): Promise<ViaplayOverview> {
|
|
const attempts: Array<{ label: string; headers: Record<string, string> }> = [
|
|
{
|
|
label: "cookie+bearer",
|
|
headers: {
|
|
...authHeaders(creds),
|
|
Accept: "application/json",
|
|
...(creds.accessToken
|
|
? { Authorization: `Bearer ${creds.accessToken}` }
|
|
: {}),
|
|
},
|
|
},
|
|
{
|
|
label: "cookie",
|
|
headers: { ...authHeaders(creds), Accept: "application/json" },
|
|
},
|
|
{
|
|
label: "bearer",
|
|
headers: {
|
|
Accept: "application/json",
|
|
Origin: "https://viaplay.com",
|
|
Referer: "https://viaplay.com/",
|
|
"User-Agent": VIAPLAY_USER_AGENT,
|
|
...(creds.accessToken
|
|
? { Authorization: `Bearer ${creds.accessToken}` }
|
|
: {}),
|
|
},
|
|
},
|
|
];
|
|
|
|
let lastStatus = 0;
|
|
let lastError = "Overview mislukt";
|
|
for (const attempt of attempts) {
|
|
try {
|
|
const res = await fetchJson("https://account.mtg-api.com/overview", {
|
|
headers: attempt.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.productNames.length || !parsed.partnerName) {
|
|
const fromSession = subscriptionFromSession(creds.session);
|
|
const partnerName = parsed.partnerName || fromSession.partnerName;
|
|
const productNames = parsed.productNames.length
|
|
? parsed.productNames
|
|
: fromSession.productNames;
|
|
const parts = [partnerName, productNames.join(", ")].filter(Boolean);
|
|
return {
|
|
...parsed,
|
|
partnerName,
|
|
productNames,
|
|
summary: parts.join(" · ") || parsed.summary,
|
|
};
|
|
}
|
|
return parsed;
|
|
}
|
|
lastError =
|
|
(typeof body?.message === "string" && body.message) ||
|
|
(typeof body?.userMessage === "string" && body.userMessage) ||
|
|
`Overview HTTP ${res.status}`;
|
|
} catch (err) {
|
|
lastError = err instanceof Error ? err.message : String(err);
|
|
}
|
|
}
|
|
|
|
// Fallback: sessie geldig? (light content call)
|
|
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);
|
|
const hasUser = Boolean(
|
|
asRecord(root?._links)?.["viaplay:userData"] ||
|
|
asRecord(root?._links)?.["viaplay:logout"]
|
|
);
|
|
if (ping.status === 200 && hasUser) {
|
|
const fromSession = subscriptionFromSession(creds.session);
|
|
const exp = accessTokenExpiry(creds.accessToken);
|
|
const summary =
|
|
fromSession.summary ||
|
|
(exp
|
|
? `Sessie actief · token tot ${exp}`
|
|
: "Sessie actief (abonnementdetails niet beschikbaar)");
|
|
return {
|
|
ok: true,
|
|
status: ping.status,
|
|
partnerName: fromSession.partnerName,
|
|
productNames: fromSession.productNames,
|
|
summary,
|
|
};
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
// Laatste redmiddel: abonnement uit session-cookie (lokaal, geen extra call)
|
|
const fromSession = subscriptionFromSession(creds.session);
|
|
if (fromSession.summary) {
|
|
return {
|
|
ok: true,
|
|
status: lastStatus || 200,
|
|
partnerName: fromSession.partnerName,
|
|
productNames: fromSession.productNames,
|
|
summary: fromSession.summary,
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
status: lastStatus,
|
|
partnerName: null,
|
|
productNames: [],
|
|
summary: "",
|
|
error: lastError,
|
|
};
|
|
}
|
|
|
|
/** Haal partner/pakket uit de Viaplay session-cookie (zonder netwerk). */
|
|
export function subscriptionFromSession(session: string | null | undefined): {
|
|
partnerName: string | null;
|
|
productNames: string[];
|
|
summary: string | null;
|
|
} {
|
|
if (!session?.trim()) {
|
|
return { partnerName: null, productNames: [], summary: null };
|
|
}
|
|
try {
|
|
let raw = session.trim();
|
|
try {
|
|
raw = decodeURIComponent(raw);
|
|
} catch {
|
|
/* already decoded */
|
|
}
|
|
// express-session vorm: s:j:{json}.{signature}
|
|
const m = /^s:j:(.+)\.[^.]+$/s.exec(raw);
|
|
const jsonText = m?.[1] ?? null;
|
|
if (!jsonText) return { partnerName: null, productNames: [], summary: null };
|
|
const data = JSON.parse(jsonText) as Record<string, unknown>;
|
|
const contracts = String(data.vmsContracts ?? "").trim();
|
|
const productNames: string[] = [];
|
|
let partnerName: string | null = null;
|
|
if (/tmobile|odido/i.test(contracts) || data.isVmsCustomer === true) {
|
|
partnerName = "Odido Internet + TV";
|
|
}
|
|
if (/b2b_vtotal|vtotal/i.test(contracts)) productNames.push("Total / V Total");
|
|
else if (/basis|basic/i.test(contracts)) productNames.push("Basis");
|
|
else if (contracts) {
|
|
productNames.push(contracts.replace(/\s+/g, " "));
|
|
}
|
|
if (data.isTveBCustomer === true && !productNames.length) {
|
|
productNames.push("TV-pakket");
|
|
}
|
|
const parts = [partnerName, productNames.join(", ")].filter(Boolean);
|
|
return {
|
|
partnerName,
|
|
productNames,
|
|
summary: parts.length ? parts.join(" · ") : null,
|
|
};
|
|
} catch {
|
|
return { partnerName: null, productNames: [], summary: null };
|
|
}
|
|
}
|
|
|
|
function accessTokenExpiry(token: string | null): string | null {
|
|
if (!token) return null;
|
|
try {
|
|
const payload = JSON.parse(
|
|
Buffer.from(token.split(".")[1]!.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString()
|
|
) as { exp?: number };
|
|
if (!payload.exp) return null;
|
|
return new Date(payload.exp * 1000).toLocaleString("nl-NL");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function packageLabel(raw: unknown): string | null {
|
|
const rec = asRecord(raw);
|
|
if (!rec) return null;
|
|
for (const key of ["name", "displayName", "title", "productName", "id"]) {
|
|
const v = rec[key];
|
|
if (typeof v === "string" && v.trim()) return v.trim();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseOverviewBody(body: Record<string, unknown>): ViaplayOverview {
|
|
const partner = asRecord(body.partnerInformation);
|
|
const partnerName =
|
|
typeof partner?.partnerDisplayName === "string"
|
|
? partner.partnerDisplayName
|
|
: typeof partner?.partnerId === "string"
|
|
? partner.partnerId
|
|
: 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: true,
|
|
status: 200,
|
|
partnerName,
|
|
productNames,
|
|
summary: parts.join(" · ") || "Actief abonnement",
|
|
};
|
|
}
|
|
|
|
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", "boxart", "cover", "hero"]) {
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
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,
|
|
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 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)");
|
|
}
|