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}`); if (creds.persistentLogin) parts.push(`persistentLogin=${creds.persistentLogin}`); return parts.join("; "); } export function authHeaders(creds: ViaplayAccountCreds): Record { const headers: Record = { 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 { 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 = {}; 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 | null = null; try { body = (await res.json()) as Record; } 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; 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 — geen cookies, geen Bearer. */ export async function fetchAccountOverview( creds: ViaplayAccountCreds, timeoutMs: number ): Promise { 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 = { 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): 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) => 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", "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 { 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); } /** 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(); 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 | 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 | 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 | 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): 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> { const root = asRecord(json); const embedded = asRecord(root?._embedded); const blocks = embedded?.["viaplay:blocks"]; if (!Array.isArray(blocks)) return []; const out: Array> = []; for (const b of blocks) { const rec = asRecord(b); if (rec) out.push(rec); } return out; } function productsFromBlock(block: Record): ViaplaySportRailItem[] { const embedded = asRecord(block._embedded); const products = embedded?.["viaplay:products"]; if (!Array.isArray(products)) return []; const out: ViaplaySportRailItem[] = []; const seen = new Set(); 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 { 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 = { 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 { const url = `https://content.viaplay.com/${deviceKey}/sport?language=nl`; const headers: Record = { 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(); 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); } return parsePlayStreamBody(body, mediaGuid); } function parsePlayStreamBody( body: Record, 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 | 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, 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 { 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(); 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( /]*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)"); }