Resolve Viaplay VOD via product stream href and return real titles.
Fetch the content product page with account cookies first (like the web player), expand the templated byguid URL, and expose the clip title instead of the raw guid.
This commit is contained in:
parent
03e4deab33
commit
c6bd855334
4 changed files with 129 additions and 41 deletions
|
|
@ -20,8 +20,8 @@ android {
|
||||||
applicationId = "nl.vonas.mediacluster.tv"
|
applicationId = "nl.vonas.mediacluster.tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 149
|
versionCode = 150
|
||||||
versionName = "0.14.86"
|
versionName = "0.14.87"
|
||||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
|
||||||
|
|
@ -707,12 +707,80 @@ function parsePlayStreamBody(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bouw play.viaplay.com URL uit templated stream-href (content-API).
|
||||||
|
* Strip `{?deviceId,...}` en vul device-params + bestaande query.
|
||||||
|
*/
|
||||||
|
export function expandViaplayStreamHref(
|
||||||
|
hrefRaw: string,
|
||||||
|
creds: ViaplayAccountCreds,
|
||||||
|
opts: { deviceKey: string }
|
||||||
|
): string {
|
||||||
|
const cleaned = hrefRaw.replace(/\{\?[^}]+\}/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");
|
||||||
|
if (!u.searchParams.get("defaultAvailabilityContext")) {
|
||||||
|
u.searchParams.set("defaultAvailabilityContext", "svod");
|
||||||
|
}
|
||||||
|
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 / docs — HAR: play.viaplay.com/api/stream/byguid
|
* VOD / clips / samenvattingen / docs — HAR: play.viaplay.com/api/stream/byguid
|
||||||
* (live sport blijft play-live …/bymediaguid).
|
|
||||||
*
|
*
|
||||||
* Zonder producturl/cse/sectionPath geeft Viaplay vaak een fallback-slate
|
* Belangrijk: eerst product-pagina (met cookies) → stream-href mét producturl/context.
|
||||||
* ("Viaplay Presents / Don't go") i.p.v. de echte clip.
|
* Kale byguid zonder die params levert Viaplay's fallback (“Don't go” documentary).
|
||||||
*/
|
*/
|
||||||
export async function playByGuid(
|
export async function playByGuid(
|
||||||
creds: ViaplayAccountCreds,
|
creds: ViaplayAccountCreds,
|
||||||
|
|
@ -731,34 +799,51 @@ export async function playByGuid(
|
||||||
const productUrl =
|
const productUrl =
|
||||||
(opts.productUrl && String(opts.productUrl).trim()) ||
|
(opts.productUrl && String(opts.productUrl).trim()) ||
|
||||||
`https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?partial=true`;
|
`https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?partial=true`;
|
||||||
let productUrlFinal = productUrl;
|
|
||||||
|
let streamHref: string | null = null;
|
||||||
|
let pageTitle: string | null = null;
|
||||||
try {
|
try {
|
||||||
const pu = new URL(productUrl);
|
const meta = await fetchProductStreamMeta(creds, productUrl, opts.timeoutMs);
|
||||||
if (!pu.searchParams.has("partial")) pu.searchParams.set("partial", "true");
|
streamHref = meta.streamHref;
|
||||||
productUrlFinal = pu.toString();
|
pageTitle = meta.title;
|
||||||
} catch {
|
} catch {
|
||||||
/* keep raw */
|
/* fallback hieronder */
|
||||||
}
|
}
|
||||||
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");
|
let playUrl: string;
|
||||||
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
|
if (streamHref && /play\.viaplay\.com\/api\/stream\/byguid/i.test(streamHref)) {
|
||||||
u.searchParams.set("deviceName", "web");
|
playUrl = expandViaplayStreamHref(streamHref, creds, { deviceKey: opts.deviceKey });
|
||||||
u.searchParams.set("deviceType", "pc");
|
} else {
|
||||||
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
|
// Fallback: zelfde query als web-HAR (producturl + cse + sectionPath)
|
||||||
u.searchParams.set("deviceKey", opts.deviceKey);
|
let productUrlFinal = productUrl;
|
||||||
u.searchParams.set("guid", guid);
|
try {
|
||||||
u.searchParams.set("cse", "true");
|
const pu = new URL(productUrl);
|
||||||
u.searchParams.set("producturl", productUrlFinal);
|
if (!pu.searchParams.has("partial")) pu.searchParams.set("partial", "true");
|
||||||
u.searchParams.set("returnurl", returnUrl);
|
productUrlFinal = pu.toString();
|
||||||
u.searchParams.set("sectionPath", sectionPath);
|
} catch {
|
||||||
u.searchParams.set("defaultAvailabilityContext", "svod");
|
/* keep */
|
||||||
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
|
}
|
||||||
|
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(u.toString(), {
|
const res = await fetchJson(playUrl, {
|
||||||
headers: authHeaders(creds),
|
headers: authHeaders(creds),
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
});
|
});
|
||||||
|
|
@ -770,21 +855,21 @@ export async function playByGuid(
|
||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
const played = parsePlayStreamBody(body, guid);
|
const played = parsePlayStreamBody(body, guid);
|
||||||
// Fallback-promo herkennen: verkeerde stream i.p.v. gevraagde clip
|
if (!played.title && pageTitle) played.title = pageTitle;
|
||||||
|
|
||||||
|
// Fallback-promo herkennen
|
||||||
const title = (played.title || "").toLowerCase();
|
const title = (played.title || "").toLowerCase();
|
||||||
if (
|
if (/viaplay\s*presents|don'?t\s*go|viaplay\s*documentary/i.test(title)) {
|
||||||
/viaplay\s*presents|don'?t\s*go|viaplay\s*documentary/i.test(title) &&
|
|
||||||
!title.includes(guid.toLowerCase())
|
|
||||||
) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Viaplay gaf fallback-stream (“${played.title}”) — producturl/context ontbreekt of is ongeldig`
|
`Viaplay gaf fallback-stream (“${played.title}”) — check tokens/producturl`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (played.mpdUrl && !/vod-dash|vod-|ism\/index\.mpd|\/vod\//i.test(played.mpdUrl)) {
|
if (played.mpdUrl && /live-dash|play-live|tve_/i.test(played.mpdUrl)) {
|
||||||
// Live-CDN voor VOD is verdacht; sport-clips horen op vod-dash
|
throw new Error("Viaplay gaf live-stream i.p.v. VOD voor deze clip");
|
||||||
if (/live-dash|play-live|tve_/i.test(played.mpdUrl)) {
|
}
|
||||||
throw new Error("Viaplay gaf live-stream i.p.v. VOD voor deze clip");
|
// Clips horen op vod-cdn
|
||||||
}
|
if (played.mpdUrl && !/vod-/i.test(played.mpdUrl)) {
|
||||||
|
throw new Error(`Onverwachte VOD-MPD host: ${played.mpdUrl.slice(0, 80)}`);
|
||||||
}
|
}
|
||||||
return played;
|
return played;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ export type ResolveViaplayPlayResult = {
|
||||||
fromCache: boolean;
|
fromCache: boolean;
|
||||||
mediaGuid: string;
|
mediaGuid: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
title?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function accountOrder(
|
function accountOrder(
|
||||||
|
|
@ -361,6 +362,7 @@ export async function resolveViaplayGuidPlay(
|
||||||
fromCache: false,
|
fromCache: false,
|
||||||
mediaGuid: guid,
|
mediaGuid: guid,
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
|
title: play.title,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err instanceof Error ? err : new Error(String(err));
|
lastError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
|
|
||||||
|
|
@ -1060,6 +1060,7 @@ export function registerViewerRoutes(
|
||||||
|
|
||||||
const { resolveViaplayGuidPlay } = await import("../viaplay/play");
|
const { resolveViaplayGuidPlay } = await import("../viaplay/play");
|
||||||
const played = await resolveViaplayGuidPlay(config.SESSION_SECRET, guid, { productUrl });
|
const played = await resolveViaplayGuidPlay(config.SESSION_SECRET, guid, { productUrl });
|
||||||
|
const displayTitle = played.title?.trim() || guid;
|
||||||
let watchSessionId: string | null = null;
|
let watchSessionId: string | null = null;
|
||||||
try {
|
try {
|
||||||
const { touchClientWatch } = await import("./client-watches");
|
const { touchClientWatch } = await import("./client-watches");
|
||||||
|
|
@ -1067,7 +1068,7 @@ export function registerViewerRoutes(
|
||||||
viewerUserId: auth.viewerId,
|
viewerUserId: auth.viewerId,
|
||||||
kind: "event",
|
kind: "event",
|
||||||
resourceId: `viaplay-content:${guid}`,
|
resourceId: `viaplay-content:${guid}`,
|
||||||
title: guid,
|
title: displayTitle,
|
||||||
provider: "viaplay",
|
provider: "viaplay",
|
||||||
});
|
});
|
||||||
watchSessionId = watch.id;
|
watchSessionId = watch.id;
|
||||||
|
|
@ -1076,7 +1077,7 @@ export function registerViewerRoutes(
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
eventId: `viaplay-content:${guid}`,
|
eventId: `viaplay-content:${guid}`,
|
||||||
name: guid,
|
name: displayTitle,
|
||||||
logoUrl: null,
|
logoUrl: null,
|
||||||
streamUrl: played.streamUrl,
|
streamUrl: played.streamUrl,
|
||||||
format: played.format,
|
format: played.format,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue