diff --git a/apps/master-api/src/viaplay/client.ts b/apps/master-api/src/viaplay/client.ts index 9fa49b3..72c4c9e 100644 --- a/apps/master-api/src/viaplay/client.ts +++ b/apps/master-api/src/viaplay/client.ts @@ -44,6 +44,33 @@ export function authHeaders(creds: ViaplayAccountCreds): Record return headers; } +/** + * HAR afspelen: play.viaplay.com / play-live krijgen GEEN Authorization. + * Alleen cookies + browser-UA. VIAPLAY-AT op play geeft Don't go-fallback. + */ +export function playHeaders(creds: ViaplayAccountCreds): Record { + return { + Accept: "*/*", + "Accept-Language": + "nl,en;q=0.9,en-GB;q=0.8,en-US;q=0.7,es-ES;q=0.6,es;q=0.5,nl-NL;q=0.4", + Origin: "https://viaplay.com", + Referer: "https://viaplay.com/", + "User-Agent": VIAPLAY_USER_AGENT, + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-site", + "sec-ch-ua": + '"Microsoft Edge";v="153", "Not_A Brand";v="8", "Chromium";v="153"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + Cookie: cookieHeader(creds), + }; +} + +/** TCF-string uit web-HAR (afspelen.har). */ +const VIAPLAY_TC_STRING = + "CQq8bIAQq8bIAAcABBNLCxFsAP_gAEPgAAYgKaQQYAFAAYABAACsAFwAZAA4AB4AEAAJAAWgAyABoAD-AIoAjQBMAE4AKAAVAAtgBfgDCAMQAaAA3ABzAD8AICAQYBCACGAEYANEAcYA7wB4gD9AIQARMAi0BHAEdAJ2AVkAuoBigDOAGmAPUAe0A_gCAAETAIpARqAmKBOIE5AKTAWKAtkBeYC9gGMgMkAZYA1cBrgD-4IzAjOBO4CmgJ3QIQAFgAVAAuABwADwAIAASAAyABoAEUAJgAYgA0AB-AEIAIYAd4A_QCEQEcAR0AxQBnwD2gJiAUmAsUBeYDJAGWANXAf2BGYCdwAYSACAH4dADAD8ANEAixSAGAH4AaIBFgAA.f_wACHwAAAAA.IKaQQoAFAAYABAACsAFwAZAA4AB4AEAAJAAWgAyABoAD-AIoAjQBMAE4AKAAVAAtgBfgDCAMQAaAA3ABzAD8AICAQYBCACGAEYANEAcYA7wB4gD9AIQARMAi0BHAEdAJ2AVkAuoBigDOAGfANMAeoA9oB_AEAAImARSAjUBMUCcQJyAUmAsUBbIC8wF7AMZAZIAywBq4DXAH9wRmBGcCdwFNAAAA"; + /** * Vernieuw session/accessToken via persistentLogin (zoals de webapp na cookie-expiry). * Zonder verse session-cookie geeft play.viaplay.com 403 of rare fallbacks. @@ -781,28 +808,55 @@ export function isLiveDashMpd(mpdUrl: string | null | undefined): boolean { return !!mpdUrl && /live-dash/i.test(mpdUrl); } -function assertLivePlayNotSoftFail(played: ViaplayPlayResult): void { +function assertPlayNotSoftFail( + played: ViaplayPlayResult, + opts?: { requireLiveDash?: boolean } +): void { if (isPromoVodTitle(played.title)) { throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`); } if (isDontGoPlaceholder(played.mpdUrl)) { throw new Error("Viaplay gaf Don't go-placeholder i.p.v. de wedstrijdstream"); } - if (!isLiveDashMpd(played.mpdUrl)) { + if (opts?.requireLiveDash && !isLiveDashMpd(played.mpdUrl)) { throw new Error( "Viaplay gaf geen live-dash stream (Don't go/VOD-fallback — sessie of rechten)" ); } } -export async function playByMediaGuid( +function sportContentUrls( + pageUrl: string | null | undefined, + deviceKey: string, + mediaGuid: string +): { productUrl: string; contextualUrl: string; contextualv2Url: string } { + const guidPath = mediaGuid.replace(/-NL$/i, "").toLowerCase(); + let clean = ""; + const raw = (pageUrl || "").trim(); + if (raw) { + try { + const u = new URL(raw); + u.search = ""; + clean = u.toString().replace(/\/$/, ""); + } catch { + clean = ""; + } + } + if (!clean) { + clean = `https://content.viaplay.com/${deviceKey}/sport/${guidPath}`; + } + return { + productUrl: `${clean}?partial=true`, + contextualUrl: `${clean}?contextual=true`, + contextualv2Url: `${clean}?contextualV2=true&productType=sport`, + }; +} + +function buildPlayLiveUrl( creds: ViaplayAccountCreds, - mediaGuidRaw: string, - opts: { deviceKey: string; timeoutMs: number; pageUrl?: string | null } -): Promise { - const mediaGuid = toMediaGuid(mediaGuidRaw); - // Zelfde query als het werkende live-script. Extra HAR-svod/producturl - // triggert bij terugkijken de Don't go-VOD-fallback. + mediaGuid: string, + opts: { deviceKey: string; pageUrl?: string | null; harStyle: boolean } +): string { const u = new URL("https://play-live.viaplay.com/api/stream/bymediaguid"); u.searchParams.set("deviceId", creds.deviceId || randomUUID()); u.searchParams.set("deviceName", "web"); @@ -812,21 +866,74 @@ export async function playByMediaGuid( u.searchParams.set("mediaGuid", mediaGuid); u.searchParams.set("nocc", "autoplayer"); if (creds.profileId) u.searchParams.set("profileId", creds.profileId); + if (!opts.harStyle) return u.toString(); - 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 pages = sportContentUrls(opts.pageUrl, opts.deviceKey, mediaGuid); + const returnUrl = new URL(`https://content.viaplay.com/${opts.deviceKey}`); + returnUrl.searchParams.set("language", "nl"); + if (creds.profileId) returnUrl.searchParams.set("profileId", creds.profileId); + u.searchParams.set("cse", "true"); + u.searchParams.set( + "splitIOFlagIds", + "all_sports_beyondLiveBeacon_on,web_player_pause_ads_on" + ); + u.searchParams.set("tcString", VIAPLAY_TC_STRING); + u.searchParams.set("returnurl", returnUrl.toString()); + u.searchParams.set("producturl", pages.productUrl); + u.searchParams.set("contextualurl", pages.contextualUrl); + u.searchParams.set("contextualv2url", pages.contextualv2Url); + u.searchParams.set("sectionPath", "/sport"); + u.searchParams.set("defaultAvailabilityContext", "svod"); + u.searchParams.set("win10edge", "true"); + return u.toString(); +} + +export async function playByMediaGuid( + creds: ViaplayAccountCreds, + mediaGuidRaw: string, + opts: { + deviceKey: string; + timeoutMs: number; + pageUrl?: string | null; + /** Terugkijken: echte VOD-MPD toestaan, Don't go niet. */ + allowVod?: boolean; } - const played = parsePlayStreamBody(body, mediaGuid); - assertLivePlayNotSoftFail(played); - return played; +): Promise { + const mediaGuid = toMediaGuid(mediaGuidRaw); + const urls = [ + buildPlayLiveUrl(creds, mediaGuid, { + deviceKey: opts.deviceKey, + pageUrl: opts.pageUrl, + harStyle: true, + }), + buildPlayLiveUrl(creds, mediaGuid, { + deviceKey: opts.deviceKey, + harStyle: false, + }), + ]; + + let lastError: Error | null = null; + for (const playUrl of urls) { + try { + const res = await fetchJson(playUrl, { + headers: playHeaders(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 played = parsePlayStreamBody(body, mediaGuid); + assertPlayNotSoftFail(played, { requireLiveDash: !opts.allowVod }); + return played; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + } + } + throw lastError || new Error("Viaplay play-live mislukt"); } function parsePlayStreamBody( @@ -875,6 +982,7 @@ export function expandViaplayStreamHref( u.searchParams.set("userAgent", VIAPLAY_USER_AGENT); u.searchParams.set("deviceKey", opts.deviceKey); u.searchParams.set("cse", "true"); + u.searchParams.set("tcString", VIAPLAY_TC_STRING); u.searchParams.set("win10edge", "true"); if (!u.searchParams.get("defaultAvailabilityContext")) { u.searchParams.set("defaultAvailabilityContext", "svod"); @@ -915,6 +1023,7 @@ function buildHarByguidUrl( "splitIOFlagIds", "all_sports_beyondLiveBeacon_on,web_player_pause_ads_on" ); + u.searchParams.set("tcString", VIAPLAY_TC_STRING); u.searchParams.set("guid", guid); u.searchParams.set("returnurl", opts.returnUrl); u.searchParams.set("producturl", productUrlFinal); @@ -1055,7 +1164,7 @@ async function fetchProductStreamMeta( /* keep */ } const res = await fetchJson(url, { - headers: { ...authHeaders(creds), Accept: "application/json" }, + headers: { ...playHeaders(creds), Accept: "application/json" }, timeoutMs, }); if (res.status !== 200) { @@ -1166,7 +1275,7 @@ export async function playByGuid( for (const playUrl of playUrls) { try { const res = await fetchJson(playUrl, { - headers: authHeaders(creds), + headers: playHeaders(creds), timeoutMs: opts.timeoutMs, }); const body = asRecord(res.json); diff --git a/apps/master-api/src/viaplay/play.ts b/apps/master-api/src/viaplay/play.ts index 0a1b630..65d3678 100644 --- a/apps/master-api/src/viaplay/play.ts +++ b/apps/master-api/src/viaplay/play.ts @@ -308,6 +308,7 @@ export async function ensureViaplayEventReady( deviceKey: tweaks.deviceKey, timeoutMs: tweaks.httpTimeoutMs, pageUrl: event.pageUrl, + allowVod: ended, }); const kap = isLiveDashMpd(play.mpdUrl) ? await resolveKapMpd( @@ -371,7 +372,7 @@ export async function ensureViaplayEventReady( if (isDontGoPlaceholder(play.mpdUrl) || isDontGoPlaceholder(kap.mpdUrl)) { throw new Error("Viaplay gaf Don't go-placeholder i.p.v. de wedstrijdstream"); } - if (!isLiveDashMpd(play.mpdUrl)) { + if (!ended && !isLiveDashMpd(play.mpdUrl)) { throw new Error("Viaplay gaf geen live-dash stream (Don't go/VOD-fallback)"); }