diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 6818f4b..37cec33 100644 --- a/apps/android-tv/app/build.gradle.kts +++ b/apps/android-tv/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "nl.vonas.mediacluster.tv" minSdk = 24 targetSdk = 36 - versionCode = 149 - versionName = "0.14.86" + versionCode = 150 + versionName = "0.14.87" buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") diff --git a/apps/master-api/src/viaplay/client.ts b/apps/master-api/src/viaplay/client.ts index 7ed669e..b0d2447 100644 --- a/apps/master-api/src/viaplay/client.ts +++ b/apps/master-api/src/viaplay/client.ts @@ -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 - * (live sport blijft play-live …/bymediaguid). * - * Zonder producturl/cse/sectionPath geeft Viaplay vaak een fallback-slate - * ("Viaplay Presents / Don't go") i.p.v. de echte clip. + * 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). */ export async function playByGuid( creds: ViaplayAccountCreds, @@ -731,34 +799,51 @@ export async function playByGuid( const productUrl = (opts.productUrl && String(opts.productUrl).trim()) || `https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?partial=true`; - let productUrlFinal = productUrl; + + let streamHref: string | null = null; + let pageTitle: string | null = null; try { - const pu = new URL(productUrl); - if (!pu.searchParams.has("partial")) pu.searchParams.set("partial", "true"); - productUrlFinal = pu.toString(); + const meta = await fetchProductStreamMeta(creds, productUrl, opts.timeoutMs); + streamHref = meta.streamHref; + pageTitle = meta.title; } 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"); - 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); + 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(u.toString(), { + const res = await fetchJson(playUrl, { headers: authHeaders(creds), timeoutMs: opts.timeoutMs, }); @@ -770,21 +855,21 @@ export async function playByGuid( throw new Error(msg); } 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(); - if ( - /viaplay\s*presents|don'?t\s*go|viaplay\s*documentary/i.test(title) && - !title.includes(guid.toLowerCase()) - ) { + if (/viaplay\s*presents|don'?t\s*go|viaplay\s*documentary/i.test(title)) { 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)) { - // Live-CDN voor VOD is verdacht; sport-clips horen op vod-dash - if (/live-dash|play-live|tve_/i.test(played.mpdUrl)) { - throw new Error("Viaplay gaf live-stream i.p.v. VOD voor deze clip"); - } + if (played.mpdUrl && /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; } diff --git a/apps/master-api/src/viaplay/play.ts b/apps/master-api/src/viaplay/play.ts index ad8e43c..cb7ae52 100644 --- a/apps/master-api/src/viaplay/play.ts +++ b/apps/master-api/src/viaplay/play.ts @@ -95,6 +95,7 @@ export type ResolveViaplayPlayResult = { fromCache: boolean; mediaGuid: string; accountId?: string; + title?: string | null; }; function accountOrder( @@ -361,6 +362,7 @@ export async function resolveViaplayGuidPlay( fromCache: false, mediaGuid: guid, accountId: account.id, + title: play.title, }; } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 1c86468..eb07127 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -1060,6 +1060,7 @@ export function registerViewerRoutes( const { resolveViaplayGuidPlay } = await import("../viaplay/play"); const played = await resolveViaplayGuidPlay(config.SESSION_SECRET, guid, { productUrl }); + const displayTitle = played.title?.trim() || guid; let watchSessionId: string | null = null; try { const { touchClientWatch } = await import("./client-watches"); @@ -1067,7 +1068,7 @@ export function registerViewerRoutes( viewerUserId: auth.viewerId, kind: "event", resourceId: `viaplay-content:${guid}`, - title: guid, + title: displayTitle, provider: "viaplay", }); watchSessionId = watch.id; @@ -1076,7 +1077,7 @@ export function registerViewerRoutes( } return { eventId: `viaplay-content:${guid}`, - name: guid, + name: displayTitle, logoUrl: null, streamUrl: played.streamUrl, format: played.format,