diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index 5b8f769..6818f4b 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 = 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") diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 7b13cee..eee83d2 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -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( diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt index ad69adf..45b0ed0 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventPlayerScreen.kt @@ -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(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()) diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt index a96160d..191abec 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt @@ -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() }, ) } diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ViaplayHubScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ViaplayHubScreen.kt index 939cd69..a040613 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ViaplayHubScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/ViaplayHubScreen.kt @@ -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(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) } } diff --git a/apps/master-api/src/viaplay/client.ts b/apps/master-api/src/viaplay/client.ts index 2b06433..7ed669e 100644 --- a/apps/master-api/src/viaplay/client.ts +++ b/apps/master-api/src/viaplay/client.ts @@ -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 { 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). */ diff --git a/apps/master-api/src/viaplay/events.ts b/apps/master-api/src/viaplay/events.ts index 070720d..1102d4e 100644 --- a/apps/master-api/src/viaplay/events.ts +++ b/apps/master-api/src/viaplay/events.ts @@ -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, }; } diff --git a/apps/master-api/src/viaplay/play.ts b/apps/master-api/src/viaplay/play.ts index 709d611..ad8e43c 100644 --- a/apps/master-api/src/viaplay/play.ts +++ b/apps/master-api/src/viaplay/play.ts @@ -266,7 +266,8 @@ export async function resolveViaplayPlay( */ export async function resolveViaplayGuidPlay( sessionSecret: string, - guidRaw: string + guidRaw: string, + opts?: { productUrl?: string | null } ): Promise { 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", diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index eb63c5e..1c86468 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -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");