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:
parent
f9ec3be09e
commit
03e4deab33
9 changed files with 108 additions and 20 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 = 148
|
versionCode = 149
|
||||||
versionName = "0.14.85"
|
versionName = "0.14.86"
|
||||||
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")
|
||||||
|
|
|
||||||
|
|
@ -764,6 +764,7 @@ class ApiClient(
|
||||||
durationLabel = it.optClean("durationLabel"),
|
durationLabel = it.optClean("durationLabel"),
|
||||||
start = it.optClean("start"),
|
start = it.optClean("start"),
|
||||||
badge = it.optClean("badge"),
|
badge = it.optClean("badge"),
|
||||||
|
pageUrl = it.optClean("pageUrl"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -792,9 +793,13 @@ class ApiClient(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Viaplay VOD/clip play (play.viaplay.com/byguid). */
|
/** Viaplay VOD/clip play (play.viaplay.com/byguid). */
|
||||||
suspend fun viaplayContentPlay(guid: String): IptvPlayInfo = withContext(Dispatchers.IO) {
|
suspend fun viaplayContentPlay(guid: String, productUrl: String? = null): IptvPlayInfo =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
val enc = android.net.Uri.encode(guid)
|
val enc = android.net.Uri.encode(guid)
|
||||||
val req = authed("$baseUrl/api/v1/client/viaplay/content/$enc/play").get().build()
|
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 ->
|
parseObject(execute(req)) { o ->
|
||||||
o.toIptvPlayInfo(fallbackStreamId = "viaplay-content:$guid", defaultFormat = "dash")
|
o.toIptvPlayInfo(fallbackStreamId = "viaplay-content:$guid", defaultFormat = "dash")
|
||||||
}
|
}
|
||||||
|
|
@ -1413,6 +1418,7 @@ data class ViaplayHomeItem(
|
||||||
val durationLabel: String? = null,
|
val durationLabel: String? = null,
|
||||||
val start: String? = null,
|
val start: String? = null,
|
||||||
val badge: String? = null,
|
val badge: String? = null,
|
||||||
|
val pageUrl: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ViaplayHomeRail(
|
data class ViaplayHomeRail(
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@ fun EventPlayerScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
/** Hub-play (live + replay via /client/f1/content/…/play). */
|
/** Hub-play (live + replay via /client/f1/content/…/play). */
|
||||||
hubPlay: Boolean = false,
|
hubPlay: Boolean = false,
|
||||||
|
/** Optioneel: Viaplay product page URL voor correcte byguid-context. */
|
||||||
|
productUrl: String? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val playerRef = remember { AtomicReference<ExoPlayer?>(null) }
|
val playerRef = remember { AtomicReference<ExoPlayer?>(null) }
|
||||||
|
|
@ -136,7 +138,7 @@ fun EventPlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(eventId, hubPlay) {
|
LaunchedEffect(eventId, hubPlay, productUrl) {
|
||||||
loading = true
|
loading = true
|
||||||
error = null
|
error = null
|
||||||
watchSessionId = null
|
watchSessionId = null
|
||||||
|
|
@ -149,7 +151,7 @@ fun EventPlayerScreen(
|
||||||
val play = when {
|
val play = when {
|
||||||
hubPlay && eventId.startsWith("viaplay-content:", ignoreCase = true) -> {
|
hubPlay && eventId.startsWith("viaplay-content:", ignoreCase = true) -> {
|
||||||
val guid = eventId.removePrefix("viaplay-content:").removePrefix("VIAPLAY-CONTENT:")
|
val guid = eventId.removePrefix("viaplay-content:").removePrefix("VIAPLAY-CONTENT:")
|
||||||
api.viaplayContentPlay(guid)
|
api.viaplayContentPlay(guid, productUrl)
|
||||||
}
|
}
|
||||||
hubPlay -> {
|
hubPlay -> {
|
||||||
val contentId = Regex("""(?i)^f1tv:(\d+)$""").find(eventId.trim())
|
val contentId = Regex("""(?i)^f1tv:(\d+)$""").find(eventId.trim())
|
||||||
|
|
|
||||||
|
|
@ -601,20 +601,33 @@ fun McNav() {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPlayContent = { guid ->
|
onPlayContent = { guid, pageUrl ->
|
||||||
nav.navigate("viaplay_play/${android.net.Uri.encode(guid)}") {
|
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
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
"viaplay_play/{guid}",
|
"viaplay_play/{guid}?productUrl={productUrl}",
|
||||||
arguments = listOf(navArgument("guid") { type = NavType.StringType }),
|
arguments = listOf(
|
||||||
|
navArgument("guid") { type = NavType.StringType },
|
||||||
|
navArgument("productUrl") {
|
||||||
|
type = NavType.StringType
|
||||||
|
nullable = true
|
||||||
|
defaultValue = null
|
||||||
|
},
|
||||||
|
),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val guid = entry.arguments?.getString("guid")
|
val guid = entry.arguments?.getString("guid")
|
||||||
?.let { android.net.Uri.decode(it) }
|
?.let { android.net.Uri.decode(it) }
|
||||||
?: return@composable
|
?: return@composable
|
||||||
|
val productUrl = entry.arguments?.getString("productUrl")
|
||||||
|
?.let { android.net.Uri.decode(it) }
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
pendingEventsFocusRestore = true
|
pendingEventsFocusRestore = true
|
||||||
|
|
@ -625,6 +638,7 @@ fun McNav() {
|
||||||
api = api,
|
api = api,
|
||||||
eventId = "viaplay-content:$guid",
|
eventId = "viaplay-content:$guid",
|
||||||
hubPlay = true,
|
hubPlay = true,
|
||||||
|
productUrl = productUrl,
|
||||||
onBack = { nav.popBackStack() },
|
onBack = { nav.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ fun ViaplayHubScreen(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenEvent: (String) -> Unit,
|
onOpenEvent: (String) -> Unit,
|
||||||
onPlayContent: (String) -> Unit = {},
|
onPlayContent: (guid: String, pageUrl: String?) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
var home by remember { mutableStateOf<ViaplayHomePayload?>(null) }
|
var home by remember { mutableStateOf<ViaplayHomePayload?>(null) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
|
@ -132,7 +132,7 @@ fun ViaplayHubScreen(
|
||||||
if (!item.playable) return
|
if (!item.playable) return
|
||||||
when (item.openAs.lowercase(Locale.US)) {
|
when (item.openAs.lowercase(Locale.US)) {
|
||||||
"event" -> onOpenEvent(item.id)
|
"event" -> onOpenEvent(item.id)
|
||||||
else -> onPlayContent(item.guid)
|
else -> onPlayContent(item.guid, item.pageUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -710,14 +710,40 @@ function parsePlayStreamBody(
|
||||||
/**
|
/**
|
||||||
* 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).
|
* (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(
|
export async function playByGuid(
|
||||||
creds: ViaplayAccountCreds,
|
creds: ViaplayAccountCreds,
|
||||||
guidRaw: string,
|
guidRaw: string,
|
||||||
opts: { deviceKey: string; timeoutMs: number }
|
opts: {
|
||||||
|
deviceKey: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
productUrl?: string | null;
|
||||||
|
returnUrl?: string | null;
|
||||||
|
sectionPath?: string | null;
|
||||||
|
}
|
||||||
): Promise<ViaplayPlayResult> {
|
): Promise<ViaplayPlayResult> {
|
||||||
const guid = String(guidRaw || "").trim();
|
const guid = String(guidRaw || "").trim();
|
||||||
if (!guid) throw new Error("Geen Viaplay guid");
|
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");
|
const u = new URL("https://play.viaplay.com/api/stream/byguid");
|
||||||
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
|
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
|
||||||
u.searchParams.set("deviceName", "web");
|
u.searchParams.set("deviceName", "web");
|
||||||
|
|
@ -725,6 +751,10 @@ export async function playByGuid(
|
||||||
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
|
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
|
||||||
u.searchParams.set("deviceKey", opts.deviceKey);
|
u.searchParams.set("deviceKey", opts.deviceKey);
|
||||||
u.searchParams.set("guid", guid);
|
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");
|
u.searchParams.set("defaultAvailabilityContext", "svod");
|
||||||
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
|
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
|
||||||
|
|
||||||
|
|
@ -739,7 +769,24 @@ export async function playByGuid(
|
||||||
`play-byguid mislukt (${res.status})`;
|
`play-byguid mislukt (${res.status})`;
|
||||||
throw new Error(msg);
|
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). */
|
/** Bouw MPD-URL met EPG start/stop kap (seconden). */
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,8 @@ export type ViaplayHomeItem = {
|
||||||
durationLabel: string | null;
|
durationLabel: string | null;
|
||||||
start: string | null;
|
start: string | null;
|
||||||
badge: string | null;
|
badge: string | null;
|
||||||
|
/** Content-API page URL — nodig voor correcte byguid producturl */
|
||||||
|
pageUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ViaplayHomeRail = {
|
export type ViaplayHomeRail = {
|
||||||
|
|
@ -206,6 +208,7 @@ function railItemToHome(item: ViaplaySportRailItem, nowMs: number): ViaplayHomeI
|
||||||
durationLabel: item.durationLabel,
|
durationLabel: item.durationLabel,
|
||||||
start: item.startMs != null ? new Date(item.startMs).toISOString() : null,
|
start: item.startMs != null ? new Date(item.startMs).toISOString() : null,
|
||||||
badge,
|
badge,
|
||||||
|
pageUrl: item.pageUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -266,7 +266,8 @@ export async function resolveViaplayPlay(
|
||||||
*/
|
*/
|
||||||
export async function resolveViaplayGuidPlay(
|
export async function resolveViaplayGuidPlay(
|
||||||
sessionSecret: string,
|
sessionSecret: string,
|
||||||
guidRaw: string
|
guidRaw: string,
|
||||||
|
opts?: { productUrl?: string | null }
|
||||||
): Promise<ResolveViaplayPlayResult> {
|
): Promise<ResolveViaplayPlayResult> {
|
||||||
const guid = String(guidRaw || "").trim();
|
const guid = String(guidRaw || "").trim();
|
||||||
if (!guid) throw new AppError("BAD_REQUEST", "Geen Viaplay guid", 400);
|
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.
|
// Zelfde round-robin als live: elke VOD-play start op een ander account.
|
||||||
const ordered = accountOrder(guid, accounts, []);
|
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) {
|
for (const account of ordered) {
|
||||||
try {
|
try {
|
||||||
const play = await playByGuid(account, guid, {
|
const play = await playByGuid(account, guid, {
|
||||||
deviceKey: tweaks.deviceKey,
|
deviceKey: tweaks.deviceKey,
|
||||||
timeoutMs: tweaks.httpTimeoutMs,
|
timeoutMs: tweaks.httpTimeoutMs,
|
||||||
|
productUrl,
|
||||||
|
returnUrl: `https://content.viaplay.com/${tweaks.deviceKey}/sport`,
|
||||||
|
sectionPath: "/sport",
|
||||||
});
|
});
|
||||||
|
|
||||||
let keys = cachedKeys;
|
let keys = cachedKeys;
|
||||||
|
|
@ -305,6 +313,7 @@ export async function resolveViaplayGuidPlay(
|
||||||
/* speel base MPD */
|
/* speel base MPD */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Veel sport-clips zijn clear VOD (geen license in HAR) — geen keys forceren.
|
||||||
if (wantKeys && pssh && play.licenseUrl && !keys.length) {
|
if (wantKeys && pssh && play.licenseUrl && !keys.length) {
|
||||||
const extracted = await extractViaplayKeys({
|
const extracted = await extractViaplayKeys({
|
||||||
wvdPath: settings.wvdPath,
|
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 {
|
return {
|
||||||
streamUrl: mpdUrl,
|
streamUrl: mpdUrl,
|
||||||
format: "dash",
|
format: "dash",
|
||||||
|
|
|
||||||
|
|
@ -1037,6 +1037,8 @@ export function registerViewerRoutes(
|
||||||
const { guid: rawGuid } = request.params as { guid: string };
|
const { guid: rawGuid } = request.params as { guid: string };
|
||||||
const guid = decodeURIComponent(rawGuid || "").trim();
|
const guid = decodeURIComponent(rawGuid || "").trim();
|
||||||
if (!guid) throw new AppError("INVALID_REQUEST", "Ongeldige Viaplay guid", 400);
|
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 { resolveViewerLiveEventsAccess } = await import("./schedule-events");
|
||||||
const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
|
const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
|
||||||
|
|
@ -1057,7 +1059,7 @@ export function registerViewerRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { resolveViaplayGuidPlay } = await import("../viaplay/play");
|
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;
|
let watchSessionId: string | null = null;
|
||||||
try {
|
try {
|
||||||
const { touchClientWatch } = await import("./client-watches");
|
const { touchClientWatch } = await import("./client-watches");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue