Align Viaplay play-live and clips with website HAR.

This commit is contained in:
Jos Vooges | STH 2026-09-28 04:11:22 +02:00
parent d1c3176ffe
commit 672d3597c6
5 changed files with 147 additions and 73 deletions

View file

@ -766,12 +766,55 @@ function extractProducts(json: unknown): ViaplayEpgProduct[] {
return out;
}
/** HAR: Don't go-placeholder (live soft-fail). Clips mogen dit pad wél hebben. */
export const VIAPLAY_DONT_GO_MEDIA_RE = /\/7c4cd64\/88000100-1778484912807\.ism\b/i;
export function isDontGoPlaceholder(mpdUrl: string | null | undefined): boolean {
return !!mpdUrl && VIAPLAY_DONT_GO_MEDIA_RE.test(mpdUrl);
}
export function isLiveDashMpd(mpdUrl: string | null | undefined): boolean {
return !!mpdUrl && /live-dash/i.test(mpdUrl);
}
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`,
};
}
export async function playByMediaGuid(
creds: ViaplayAccountCreds,
mediaGuidRaw: string,
opts: { deviceKey: string; timeoutMs: number }
opts: { deviceKey: string; timeoutMs: number; pageUrl?: string | null }
): Promise<ViaplayPlayResult> {
const mediaGuid = toMediaGuid(mediaGuidRaw);
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);
const u = new URL("https://play-live.viaplay.com/api/stream/bymediaguid");
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
u.searchParams.set("deviceName", "web");
@ -779,7 +822,19 @@ export async function playByMediaGuid(
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
u.searchParams.set("deviceKey", opts.deviceKey);
u.searchParams.set("mediaGuid", mediaGuid);
u.searchParams.set("cse", "true");
u.searchParams.set("nocc", "autoplayer");
u.searchParams.set(
"splitIOFlagIds",
"all_sports_beyondLiveBeacon_on,web_player_pause_ads_on"
);
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");
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
const res = await fetchJson(u.toString(), {
@ -793,7 +848,13 @@ export async function playByMediaGuid(
`play-live mislukt (${res.status})`;
throw new Error(msg);
}
return parsePlayStreamBody(body, mediaGuid);
const played = parsePlayStreamBody(body, mediaGuid);
if (isDontGoPlaceholder(played.mpdUrl)) {
throw new Error(
"Viaplay play-live gaf Don't go-placeholder i.p.v. de wedstrijdstream"
);
}
return played;
}
function parsePlayStreamBody(
@ -907,42 +968,9 @@ function playBodyGuid(body: Record<string, unknown>): string | null {
return typeof system?.guid === "string" ? system.guid.trim() : null;
}
/** Bekende Don't go soft-fail CDN-asset (titel/guid kloppen, media niet). */
const VIAPLAY_DONT_GO_MEDIA_RE = /\/7c4cd64\/88000100-1778484912807\.ism\b/i;
function cseMediaGuid(body: Record<string, unknown>): string | null {
try {
const cse = asRecord(body.cseReporting);
const link = asRecord(asRecord(cse?.link)?.reportingUrl);
const href = typeof link?.href === "string" ? link.href : "";
const m = href.match(/\/viaplay\/([^/$]+)/);
if (!m?.[1]) return null;
const payload = JSON.parse(
Buffer.from(m[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString()
) as { mediaGuid?: string };
return typeof payload.mediaGuid === "string" ? payload.mediaGuid : null;
} catch {
return null;
}
}
function mediaHrefFromPlayBody(body: Record<string, unknown>): string | null {
const links = asRecord(body._links);
for (const key of [
"viaplay:media",
"viaplay:playlist",
"viaplay:encryptedPlaylist",
] as const) {
const href = asRecord(links?.[key])?.href;
if (typeof href === "string" && href.trim()) return href.trim();
}
return null;
}
/**
* HAR-success: guid + titel + product-link + echte media (geen Don't go soft-fail).
* Soft-fail: 200 met juiste product-metadata maar placeholder-MPD “Don't go”
* (vaak bij max devices / geen stream-recht).
* HAR afspelen.har: web speelt clip als guid+titel kloppen, ook zonder DRM
* en met cse mediaGuid=unknown. Alleen een promo-titel is een echte soft-fail.
*/
function assertVodPlayMatchesGuid(
body: Record<string, unknown>,
@ -964,19 +992,6 @@ function assertVodPlayMatchesGuid(
throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`);
}
const mediaHref = mediaHrefFromPlayBody(body) || played.mpdUrl;
if (VIAPLAY_DONT_GO_MEDIA_RE.test(mediaHref)) {
throw new Error(
"Viaplay soft-fail: Don't go-placeholder i.p.v. echte stream (mediaGuid ontbreekt — geen speelrecht of account/stream-fout)"
);
}
const streamMediaGuid = cseMediaGuid(body);
if (streamMediaGuid === "unknown") {
throw new Error(
"Viaplay soft-fail: mediaGuid=unknown (geen echte stream voor dit account)"
);
}
const links = asRecord(body._links);
const productHref = asRecord(links?.["viaplay:product"])?.href;
if (typeof productHref !== "string" || !productHref.trim()) {

View file

@ -90,7 +90,11 @@ export async function getViaplayScheduleEvent(
if (!mediaGuid) return null;
const config = await loadViaplayConfigOnly();
const event = config?.events.find(
(e) => e.mediaGuid === mediaGuid || e.guid === mediaGuid
(e) =>
e.mediaGuid === mediaGuid ||
e.guid === mediaGuid ||
e.mediaGuid === `${mediaGuid}-NL` ||
e.mediaGuid.replace(/-NL$/i, "") === mediaGuid.replace(/-NL$/i, "")
);
if (!event) return null;
return eventToPublic(event, nowMs);

View file

@ -14,6 +14,8 @@ import {
fetchMpdXml,
extractPsshFromMpd,
fetchViaplayBootstrapPssh,
isDontGoPlaceholder,
isLiveDashMpd,
VIAPLAY_USER_AGENT,
} from "./client";
import {
@ -49,7 +51,7 @@ function recordVodPlay( partial: Omit<LastVodPlayDiag, "at" | "resolver"> ) {
lastVodPlay = {
...partial,
at: new Date().toISOString(),
resolver: "viaplay-byguid-har-v7",
resolver: "viaplay-byguid-har-v8",
};
}
@ -138,6 +140,8 @@ export type ResolveViaplayPlayResult = {
mediaGuid: string;
accountId?: string;
title?: string | null;
/** false = catch-up/VOD — client mag niet naar live-edge zoeken. */
live: boolean;
};
function accountOrder(
@ -205,24 +209,31 @@ export async function ensureViaplayEventReady(
!!event.mpdUrl &&
isMpdCacheFresh(event.mpdCachedAt, tweaks.mpdCacheTtlMinutes);
if (cacheFresh && cachedKeys.length) {
if (cacheFresh && cachedKeys.length && !isDontGoPlaceholder(event.mpdUrl)) {
return {
streamUrl: event.mpdUrl!,
format: "dash",
keys: cachedKeys,
fromCache: true,
mediaGuid,
live: !ended,
};
}
// Terugkijken: zelfde pad als het werkende script — KAP op zender-MPD, geen play-live.
if (ended && cachedMpd && !opts?.forceRefresh) {
// Terugkijken: KAP alleen op echte live-dash zender-MPD (niet op VOD/Don't go).
if (
ended &&
cachedMpd &&
!opts?.forceRefresh &&
isLiveDashMpd(cachedMpd) &&
!isDontGoPlaceholder(cachedMpd)
) {
try {
const kap = await resolveKapMpd(
cachedMpd,
event.startMs,
event.endMs,
tweaks.httpTimeoutMs
Math.min(tweaks.httpTimeoutMs, 12_000)
);
if (!cachedKeys.length) {
cachedKeys = await keysForChannelPath(sessionSecret, kap.mpdUrl);
@ -242,6 +253,7 @@ export async function ensureViaplayEventReady(
keys: cachedKeys,
fromCache: true,
mediaGuid,
live: false,
};
}
} catch {
@ -250,13 +262,19 @@ export async function ensureViaplayEventReady(
}
// Cache MPD zonder keys is ok als keys later komen — of clear feed
if (cacheFresh && event.mpdUrl && !(opts?.fetchKeys ?? tweaks.fetchKeysIfMissing)) {
if (
cacheFresh &&
event.mpdUrl &&
!isDontGoPlaceholder(event.mpdUrl) &&
!(opts?.fetchKeys ?? tweaks.fetchKeysIfMissing)
) {
return {
streamUrl: event.mpdUrl,
format: "dash",
keys: cachedKeys,
fromCache: true,
mediaGuid,
live: !ended,
};
}
@ -289,13 +307,21 @@ export async function ensureViaplayEventReady(
const play = await playByMediaGuid(creds, mediaGuid, {
deviceKey: tweaks.deviceKey,
timeoutMs: tweaks.httpTimeoutMs,
pageUrl: event.pageUrl,
});
const kap = await resolveKapMpd(
play.mpdUrl,
event.startMs,
event.endMs,
tweaks.httpTimeoutMs
);
const kap = isLiveDashMpd(play.mpdUrl)
? await resolveKapMpd(
play.mpdUrl,
event.startMs,
event.endMs,
Math.min(tweaks.httpTimeoutMs, ended ? 12_000 : tweaks.httpTimeoutMs)
)
: {
mpdUrl: play.mpdUrl,
xml: "",
hasPssh: false,
pssh: null as string | null,
};
let keys = cachedKeys;
if (!keys.length) {
@ -362,6 +388,7 @@ export async function ensureViaplayEventReady(
fromCache: false,
mediaGuid,
accountId: account.id,
live: !ended,
};
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
@ -383,17 +410,25 @@ export async function resolveViaplayPlay(
sessionSecret: string,
eventId: string
): Promise<ResolveViaplayPlayResult> {
const settings = await loadViaplaySettings(sessionSecret);
const event = findViaplayEvent(settings.config, eventId);
if (!event) throw new AppError("NOT_FOUND", "Viaplay-event niet gevonden", 404);
try {
const settings = await loadViaplaySettings(sessionSecret);
const event = findViaplayEvent(settings.config, eventId);
if (!event) throw new AppError("NOT_FOUND", "Viaplay-event niet gevonden", 404);
const now = Date.now();
// Catch-up: officieel ~48u; live-pad (play-live + KAP) blijft na einde bruikbaar.
if (now > event.endMs + VIAPLAY_CATCHUP_MS) {
throw new AppError("GONE", "Dit event is afgelopen", 410);
const now = Date.now();
// Catch-up: officieel ~48u; live-pad (play-live + KAP) blijft na einde bruikbaar.
if (now > event.endMs + VIAPLAY_CATCHUP_MS) {
throw new AppError("GONE", "Dit event is afgelopen", 410);
}
return await ensureViaplayEventReady(sessionSecret, event);
} catch (err) {
if (err instanceof AppError) throw err;
throw new AppError(
"UPSTREAM",
err instanceof Error ? err.message : "Viaplay play mislukt",
502
);
}
// Pre-live: wel MPD/keys toestaan binnen preLiveMinutes; viewer play blijft live-only
return ensureViaplayEventReady(sessionSecret, event);
}
/**
@ -403,6 +438,23 @@ export async function resolveViaplayGuidPlay(
sessionSecret: string,
guidRaw: string,
opts?: { productUrl?: string | null }
): Promise<ResolveViaplayPlayResult> {
try {
return await resolveViaplayGuidPlayInner(sessionSecret, guidRaw, opts);
} catch (err) {
if (err instanceof AppError) throw err;
throw new AppError(
"UPSTREAM",
err instanceof Error ? err.message : "Viaplay VOD-play mislukt",
502
);
}
}
async function resolveViaplayGuidPlayInner(
sessionSecret: string,
guidRaw: string,
opts?: { productUrl?: string | null }
): Promise<ResolveViaplayPlayResult> {
const guid = String(guidRaw || "").trim();
if (!guid) throw new AppError("BAD_REQUEST", "Geen Viaplay guid", 400);
@ -544,6 +596,7 @@ export async function resolveViaplayGuidPlay(
mediaGuid: guid,
accountId: account.id,
title: play.title,
live: false,
};
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));

View file

@ -1094,8 +1094,9 @@ export function registerViewerRoutes(
format: played.format,
fallbackStreamUrl: null,
fallbackFormat: null,
live: false,
/** Deploy/debug: bevestigt VOD-resolver revisie na Dokploy. */
resolver: "viaplay-byguid-har-v7",
resolver: "viaplay-byguid-har-v8",
mediaPath: (() => {
try {
const u = new URL(played.streamUrl);

View file

@ -1134,6 +1134,7 @@ export async function getScheduleEventPlay(
format: played.format,
fallbackStreamUrl: null,
fallbackFormat: null,
live: played.live,
drm: played.keys.length
? { type: "clearkey" as const, keys: played.keys }
: null,