stremio/apps/master-api/src/viaplay/client.ts
Jos Vooges | STH 8be634f783 Show Viaplay subscription type on each account after add/check.
Auto-probe on account add and always render an Abonnement row, with session-cookie fallback when overview is unavailable.
2026-09-22 22:37:40 +02:00

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)");
}