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 | null { return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : 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 { 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; 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 { const attempts: Array<{ label: string; headers: Record }> = [ { 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; 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): 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) => 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; 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 | 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 { 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 { 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 { 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(); 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 { 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( /]*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 || !/]/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)"); }