diff --git a/apps/master-api/src/viaplay/client.ts b/apps/master-api/src/viaplay/client.ts index bc2283f..cbbad8c 100644 --- a/apps/master-api/src/viaplay/client.ts +++ b/apps/master-api/src/viaplay/client.ts @@ -30,13 +30,17 @@ function cookieHeader(creds: ViaplayAccountCreds): string { } export function authHeaders(creds: ViaplayAccountCreds): Record { - return { + const headers: Record = { Accept: "*/*", Origin: "https://viaplay.com", Referer: "https://viaplay.com/", "User-Agent": VIAPLAY_USER_AGENT, Cookie: cookieHeader(creds), }; + // play.viaplay.com + content-API: browser stuurt ook VIAPLAY-AT (cookies alleen is vaak promo/Don't go) + const token = normalizeAccessToken(creds.accessToken); + if (token) headers.Authorization = `VIAPLAY-AT ${token}`; + return headers; } async function fetchJson( @@ -415,6 +419,27 @@ export type ViaplaySportRail = { 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"]); @@ -422,6 +447,12 @@ function streamHref(product: Record): string | 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()) { @@ -495,6 +526,8 @@ function productToRailItem(node: Record): ViaplaySportRailItem content?.originalTitle ?? content?.synopsis ?? content?.description ?? "" ).trim() || null; + rememberStreamHref(guid, href); + return { guid, title, @@ -714,7 +747,7 @@ function parsePlayStreamBody( export function expandViaplayStreamHref( hrefRaw: string, creds: ViaplayAccountCreds, - opts: { deviceKey: string } + opts: { deviceKey: string; guid?: string } ): string { const cleaned = hrefRaw .replace(/\{\?[^}]+\}/g, "?") @@ -727,13 +760,70 @@ export function expandViaplayStreamHref( 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; + } +): string { + let productUrlFinal = opts.productUrl; + try { + const pu = new URL(opts.productUrl); + if (!pu.searchParams.has("partial")) pu.searchParams.set("partial", "true"); + productUrlFinal = pu.toString(); + } catch { + /* keep */ + } + 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("guid", guid); + u.searchParams.set("cse", "true"); + u.searchParams.set("win10edge", "true"); + u.searchParams.set("producturl", productUrlFinal); + u.searchParams.set("returnurl", opts.returnUrl); + u.searchParams.set("sectionPath", opts.sectionPath); + u.searchParams.set( + "templatedproducturl", + `https://content.viaplay.com/{deviceKey}/sport/${guid}?partial=true` + ); + u.searchParams.set( + "contextualv2url", + `https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?contextualV2=true&productType=clip` + ); + u.searchParams.set("defaultAvailabilityContext", "svod"); + if (creds.profileId) u.searchParams.set("profileId", creds.profileId); + 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; +} + async function fetchProductStreamMeta( creds: ViaplayAccountCreds, productUrl: string, @@ -782,8 +872,9 @@ async function fetchProductStreamMeta( /** * VOD / clips / samenvattingen / docs — HAR: play.viaplay.com/api/stream/byguid * - * Belangrijk: eerst product-pagina (met cookies) → stream-href mét producturl/context. - * Kale byguid zonder die params levert Viaplay's fallback (“Don't go” documentary). + * HAR-volgorde: stream-href uit de sport-rail (contextualV2) → byguid met device-params + * + cookies/VIAPLAY-AT. Product-pagina komt pas daarna (metadata). Kale byguid zonder + * context/auth levert Viaplay's promo (“Don't go”). */ export async function playByGuid( creds: ViaplayAccountCreds, @@ -799,110 +890,90 @@ export async function playByGuid( const guid = String(guidRaw || "").trim(); if (!guid) throw new Error("Geen Viaplay guid"); + // pageUrl mag alleen als die de guid bevat — anders verkeerde producturl → promo + const rawProduct = (opts.productUrl && String(opts.productUrl).trim()) || ""; const productUrl = - (opts.productUrl && String(opts.productUrl).trim()) || - `https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?partial=true`; + rawProduct && rawProduct.includes(guid) + ? rawProduct + : `https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?partial=true`; - let streamHref: string | null = null; + const returnUrl = + (opts.returnUrl && String(opts.returnUrl).trim()) || + `https://content.viaplay.com/${opts.deviceKey}/sport`; + const sectionPath = (opts.sectionPath && String(opts.sectionPath).trim()) || "/sport"; + + // HAR: web gebruikt eerst de rail-lijst stream-href (rijkere contextualV2). + const listingHref = cachedStreamHref(guid); + let productHref: string | null = null; let pageTitle: string | null = null; try { const meta = await fetchProductStreamMeta(creds, productUrl, opts.timeoutMs); - streamHref = meta.streamHref; pageTitle = meta.title; + if (meta.streamHref) { + productHref = meta.streamHref; + rememberStreamHref(guid, meta.streamHref); + } } catch { /* fallback hieronder */ } - let playUrl: string; - if (streamHref && /play\.viaplay\.com\/api\/stream\/byguid/i.test(streamHref)) { - playUrl = expandViaplayStreamHref(streamHref, creds, { deviceKey: opts.deviceKey }); - } else { - // Fallback: zelfde query als web-HAR (producturl + cse + sectionPath) - let productUrlFinal = productUrl; - try { - const pu = new URL(productUrl); - if (!pu.searchParams.has("partial")) pu.searchParams.set("partial", "true"); - productUrlFinal = pu.toString(); - } catch { - /* keep */ - } - 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 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("guid", guid); - u.searchParams.set("cse", "true"); - u.searchParams.set("producturl", productUrlFinal); - u.searchParams.set("returnurl", returnUrl); - u.searchParams.set("sectionPath", sectionPath); - u.searchParams.set("defaultAvailabilityContext", "svod"); - if (creds.profileId) u.searchParams.set("profileId", creds.profileId); - playUrl = u.toString(); - } - - 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); - } - let played = parsePlayStreamBody(body, guid); - if (!played.title && pageTitle) played.title = pageTitle; - - // Kapotte stream-href of ontbrekende producturl → promo-docu. - // Dan één keer opnieuw met expliciete HAR-query i.p.v. hard falen. - const titleLc = (played.title || "").toLowerCase(); - const looksLikePromo = /viaplay\s*presents|don'?t\s*go|viaplay\s*documentary/i.test( - titleLc - ); - if (looksLikePromo && streamHref) { - let productUrlFinal = productUrl; - try { - const pu = new URL(productUrl); - if (!pu.searchParams.has("partial")) pu.searchParams.set("partial", "true"); - productUrlFinal = pu.toString(); - } catch { - /* keep */ - } - 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 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("guid", guid); - u.searchParams.set("cse", "true"); - u.searchParams.set("producturl", productUrlFinal); - u.searchParams.set("returnurl", returnUrl); - u.searchParams.set("sectionPath", sectionPath); - u.searchParams.set("defaultAvailabilityContext", "svod"); - if (creds.profileId) u.searchParams.set("profileId", creds.profileId); - const retry = await fetchJson(u.toString(), { - headers: authHeaders(creds), - timeoutMs: opts.timeoutMs, + const playUrls: string[] = []; + const seen = new Set(); + const pushExpanded = (href: string | null) => { + if (!href || !/play\.viaplay\.com\/api\/stream\/byguid/i.test(href)) return; + const expanded = expandViaplayStreamHref(href, creds, { + deviceKey: opts.deviceKey, + guid, }); - const retryBody = asRecord(retry.json); - if (retry.status === 200 && retryBody) { - played = parsePlayStreamBody(retryBody, guid); - if (!played.title && pageTitle) played.title = pageTitle; + if (seen.has(expanded)) return; + seen.add(expanded); + playUrls.push(expanded); + }; + pushExpanded(listingHref); + pushExpanded(productHref); + const harUrl = buildHarByguidUrl(creds, guid, { + deviceKey: opts.deviceKey, + productUrl, + returnUrl, + sectionPath, + }); + if (!seen.has(harUrl)) playUrls.push(harUrl); + + 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); + const responseGuid = playBodyGuid(body); + // Nooit product-titel over promo plakken — dat maskeerde Don't go eerder. + 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}”)`); + } + if (!played.title && pageTitle && !isPromoVodTitle(pageTitle)) { + played.title = pageTitle; + } + return played; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); } } - return played; + + throw lastError || new Error("Viaplay VOD-play mislukt"); } /** Bouw MPD-URL met EPG start/stop kap (seconden). */