Fix Viaplay VOD play: send producturl/cse like the official player.

Without those params byguid returns a documentary fallback slate (“Don't go”) instead of the clip.
This commit is contained in:
Jos Vooges | STH 2026-09-23 02:47:47 +02:00
parent f9ec3be09e
commit 03e4deab33
9 changed files with 108 additions and 20 deletions

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
targetSdk = 36
versionCode = 148
versionName = "0.14.85"
versionCode = 149
versionName = "0.14.86"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -764,6 +764,7 @@ class ApiClient(
durationLabel = it.optClean("durationLabel"),
start = it.optClean("start"),
badge = it.optClean("badge"),
pageUrl = it.optClean("pageUrl"),
),
)
}
@ -792,13 +793,17 @@ class ApiClient(
}
/** Viaplay VOD/clip play (play.viaplay.com/byguid). */
suspend fun viaplayContentPlay(guid: String): IptvPlayInfo = withContext(Dispatchers.IO) {
val enc = android.net.Uri.encode(guid)
val req = authed("$baseUrl/api/v1/client/viaplay/content/$enc/play").get().build()
parseObject(execute(req)) { o ->
o.toIptvPlayInfo(fallbackStreamId = "viaplay-content:$guid", defaultFormat = "dash")
suspend fun viaplayContentPlay(guid: String, productUrl: String? = null): IptvPlayInfo =
withContext(Dispatchers.IO) {
val enc = android.net.Uri.encode(guid)
val q = productUrl?.trim()?.takeIf { it.isNotEmpty() }?.let {
"?productUrl=${android.net.Uri.encode(it)}"
}.orEmpty()
val req = authed("$baseUrl/api/v1/client/viaplay/content/$enc/play$q").get().build()
parseObject(execute(req)) { o ->
o.toIptvPlayInfo(fallbackStreamId = "viaplay-content:$guid", defaultFormat = "dash")
}
}
}
suspend fun getF1tvMeeting(meetingKey: String): F1tvMeetingDetail = withContext(Dispatchers.IO) {
val enc = android.net.Uri.encode(meetingKey)
@ -1413,6 +1418,7 @@ data class ViaplayHomeItem(
val durationLabel: String? = null,
val start: String? = null,
val badge: String? = null,
val pageUrl: String? = null,
)
data class ViaplayHomeRail(

View file

@ -88,6 +88,8 @@ fun EventPlayerScreen(
onBack: () -> Unit,
/** Hub-play (live + replay via /client/f1/content/…/play). */
hubPlay: Boolean = false,
/** Optioneel: Viaplay product page URL voor correcte byguid-context. */
productUrl: String? = null,
) {
val context = LocalContext.current
val playerRef = remember { AtomicReference<ExoPlayer?>(null) }
@ -136,7 +138,7 @@ fun EventPlayerScreen(
}
}
LaunchedEffect(eventId, hubPlay) {
LaunchedEffect(eventId, hubPlay, productUrl) {
loading = true
error = null
watchSessionId = null
@ -149,7 +151,7 @@ fun EventPlayerScreen(
val play = when {
hubPlay && eventId.startsWith("viaplay-content:", ignoreCase = true) -> {
val guid = eventId.removePrefix("viaplay-content:").removePrefix("VIAPLAY-CONTENT:")
api.viaplayContentPlay(guid)
api.viaplayContentPlay(guid, productUrl)
}
hubPlay -> {
val contentId = Regex("""(?i)^f1tv:(\d+)$""").find(eventId.trim())

View file

@ -601,20 +601,33 @@ fun McNav() {
launchSingleTop = true
}
},
onPlayContent = { guid ->
nav.navigate("viaplay_play/${android.net.Uri.encode(guid)}") {
onPlayContent = { guid, pageUrl ->
val enc = android.net.Uri.encode(guid)
val q = pageUrl?.trim()?.takeIf { it.isNotEmpty() }?.let {
"?productUrl=${android.net.Uri.encode(it)}"
}.orEmpty()
nav.navigate("viaplay_play/$enc$q") {
launchSingleTop = true
}
},
)
}
composable(
"viaplay_play/{guid}",
arguments = listOf(navArgument("guid") { type = NavType.StringType }),
"viaplay_play/{guid}?productUrl={productUrl}",
arguments = listOf(
navArgument("guid") { type = NavType.StringType },
navArgument("productUrl") {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { entry ->
val guid = entry.arguments?.getString("guid")
?.let { android.net.Uri.decode(it) }
?: return@composable
val productUrl = entry.arguments?.getString("productUrl")
?.let { android.net.Uri.decode(it) }
DisposableEffect(Unit) {
onDispose {
pendingEventsFocusRestore = true
@ -625,6 +638,7 @@ fun McNav() {
api = api,
eventId = "viaplay-content:$guid",
hubPlay = true,
productUrl = productUrl,
onBack = { nav.popBackStack() },
)
}

View file

@ -82,7 +82,7 @@ fun ViaplayHubScreen(
api: ApiClient,
onBack: () -> Unit,
onOpenEvent: (String) -> Unit,
onPlayContent: (String) -> Unit = {},
onPlayContent: (guid: String, pageUrl: String?) -> Unit = { _, _ -> },
) {
var home by remember { mutableStateOf<ViaplayHomePayload?>(null) }
var loading by remember { mutableStateOf(true) }
@ -132,7 +132,7 @@ fun ViaplayHubScreen(
if (!item.playable) return
when (item.openAs.lowercase(Locale.US)) {
"event" -> onOpenEvent(item.id)
else -> onPlayContent(item.guid)
else -> onPlayContent(item.guid, item.pageUrl)
}
}

View file

@ -710,14 +710,40 @@ function parsePlayStreamBody(
/**
* 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.
*/
export async function playByGuid(
creds: ViaplayAccountCreds,
guidRaw: string,
opts: { deviceKey: string; timeoutMs: number }
opts: {
deviceKey: string;
timeoutMs: number;
productUrl?: string | null;
returnUrl?: string | null;
sectionPath?: string | null;
}
): Promise<ViaplayPlayResult> {
const guid = String(guidRaw || "").trim();
if (!guid) throw new Error("Geen Viaplay guid");
const productUrl =
(opts.productUrl && String(opts.productUrl).trim()) ||
`https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?partial=true`;
let productUrlFinal = productUrl;
try {
const pu = new URL(productUrl);
if (!pu.searchParams.has("partial")) pu.searchParams.set("partial", "true");
productUrlFinal = pu.toString();
} catch {
/* keep raw */
}
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");
@ -725,6 +751,10 @@ export async function playByGuid(
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);
@ -739,7 +769,24 @@ export async function playByGuid(
`play-byguid mislukt (${res.status})`;
throw new Error(msg);
}
return parsePlayStreamBody(body, guid);
const played = parsePlayStreamBody(body, guid);
// Fallback-promo herkennen: verkeerde stream i.p.v. gevraagde clip
const title = (played.title || "").toLowerCase();
if (
/viaplay\s*presents|don'?t\s*go|viaplay\s*documentary/i.test(title) &&
!title.includes(guid.toLowerCase())
) {
throw new Error(
`Viaplay gaf fallback-stream (“${played.title}”) — producturl/context ontbreekt of is ongeldig`
);
}
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");
}
}
return played;
}
/** Bouw MPD-URL met EPG start/stop kap (seconden). */

View file

@ -115,6 +115,8 @@ export type ViaplayHomeItem = {
durationLabel: string | null;
start: string | null;
badge: string | null;
/** Content-API page URL — nodig voor correcte byguid producturl */
pageUrl: string | null;
};
export type ViaplayHomeRail = {
@ -206,6 +208,7 @@ function railItemToHome(item: ViaplaySportRailItem, nowMs: number): ViaplayHomeI
durationLabel: item.durationLabel,
start: item.startMs != null ? new Date(item.startMs).toISOString() : null,
badge,
pageUrl: item.pageUrl,
};
}

View file

@ -266,7 +266,8 @@ export async function resolveViaplayPlay(
*/
export async function resolveViaplayGuidPlay(
sessionSecret: string,
guidRaw: 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);
@ -284,11 +285,18 @@ export async function resolveViaplayGuidPlay(
// Zelfde round-robin als live: elke VOD-play start op een ander account.
const ordered = accountOrder(guid, accounts, []);
const productUrl =
(opts?.productUrl && String(opts.productUrl).trim()) ||
`https://content.viaplay.com/${tweaks.deviceKey}/sport/${guid}?partial=true`;
for (const account of ordered) {
try {
const play = await playByGuid(account, guid, {
deviceKey: tweaks.deviceKey,
timeoutMs: tweaks.httpTimeoutMs,
productUrl,
returnUrl: `https://content.viaplay.com/${tweaks.deviceKey}/sport`,
sectionPath: "/sport",
});
let keys = cachedKeys;
@ -305,6 +313,7 @@ export async function resolveViaplayGuidPlay(
/* speel base MPD */
}
// Veel sport-clips zijn clear VOD (geen license in HAR) — geen keys forceren.
if (wantKeys && pssh && play.licenseUrl && !keys.length) {
const extracted = await extractViaplayKeys({
wvdPath: settings.wvdPath,
@ -340,6 +349,11 @@ export async function resolveViaplayGuidPlay(
}
}
// Geen license + geen keys → clear VOD (zoals officiële web player)
if (!play.licenseUrl && !keys.length) {
keys = [];
}
return {
streamUrl: mpdUrl,
format: "dash",

View file

@ -1037,6 +1037,8 @@ export function registerViewerRoutes(
const { guid: rawGuid } = request.params as { guid: string };
const guid = decodeURIComponent(rawGuid || "").trim();
if (!guid) throw new AppError("INVALID_REQUEST", "Ongeldige Viaplay guid", 400);
const q = request.query as { productUrl?: string };
const productUrl = typeof q.productUrl === "string" ? q.productUrl.trim() : null;
const { resolveViewerLiveEventsAccess } = await import("./schedule-events");
const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
@ -1057,7 +1059,7 @@ export function registerViewerRoutes(
}
const { resolveViaplayGuidPlay } = await import("../viaplay/play");
const played = await resolveViaplayGuidPlay(config.SESSION_SECRET, guid);
const played = await resolveViaplayGuidPlay(config.SESSION_SECRET, guid, { productUrl });
let watchSessionId: string | null = null;
try {
const { touchClientWatch } = await import("./client-watches");