From 8b39ce06eec3487d32f2bc9e379d28131d2f0e2e Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 23 Sep 2026 02:18:47 +0200 Subject: [PATCH] Add Viaplay sport hub rails with catch-up and VOD play. Hub mirrors the official sport page shelves; clips/docs use play.viaplay.com/byguid, and ended events stay playable for ~48h. --- apps/android-tv/app/build.gradle.kts | 4 +- .../vonas/mediacluster/tv/data/ApiClient.kt | 69 +++++ .../mediacluster/tv/ui/EventPlayerScreen.kt | 19 +- .../vonas/mediacluster/tv/ui/EventsScreen.kt | 135 +++++---- .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 25 ++ .../mediacluster/tv/ui/ViaplayHubScreen.kt | 187 +++++++++++- apps/master-api/src/viaplay/client.ts | 275 +++++++++++++++++- apps/master-api/src/viaplay/events.ts | 124 +++++++- apps/master-api/src/viaplay/play.ts | 99 ++++++- apps/master-api/src/viaplay/sync.ts | 4 +- apps/master-api/src/viewer/routes.ts | 60 +++- 11 files changed, 907 insertions(+), 94 deletions(-) diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts index b8e91da..aebe621 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 = 145 - versionName = "0.14.82" + versionCode = 147 + versionName = "0.14.84" 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 6473228..7b13cee 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 @@ -741,18 +741,65 @@ class ApiClient( ) } } + val railsArr = o.optJSONArray("rails") ?: JSONArray() + val rails = buildList { + for (i in 0 until railsArr.length()) { + val r = railsArr.optJSONObject(i) ?: continue + val rid = r.optClean("id") ?: continue + val itemsArr = r.optJSONArray("items") ?: JSONArray() + val items = buildList { + for (j in 0 until itemsArr.length()) { + val it = itemsArr.optJSONObject(j) ?: continue + val iid = it.optClean("id") ?: continue + add( + ViaplayHomeItem( + id = iid, + guid = it.optClean("guid") ?: iid, + title = it.optClean("title") ?: iid, + subtitle = it.optClean("subtitle"), + imageUrl = it.optClean("imageUrl"), + kind = it.optClean("kind") ?: "other", + openAs = it.optClean("openAs") ?: "content", + playable = it.optBoolean("playable", true), + durationLabel = it.optClean("durationLabel"), + start = it.optClean("start"), + badge = it.optClean("badge"), + ), + ) + } + } + add( + ViaplayHomeRail( + id = rid, + title = r.optClean("title") ?: rid, + items = items, + ), + ) + } + } ViaplayHomePayload( generatedAt = o.optClean("generatedAt"), live = eventsOf("live"), today = eventsOf("today"), weekend = eventsOf("weekend"), later = eventsOf("later"), + catchup = eventsOf("catchup"), sports = sports, events = eventsOf("events"), + rails = rails, ) } } + /** 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 getF1tvMeeting(meetingKey: String): F1tvMeetingDetail = withContext(Dispatchers.IO) { val enc = android.net.Uri.encode(meetingKey) val req = authed("$baseUrl/api/v1/client/f1/meetings/$enc").get().build() @@ -1354,14 +1401,36 @@ data class ViaplaySportChip( val count: Int = 0, ) +data class ViaplayHomeItem( + val id: String, + val guid: String, + val title: String, + val subtitle: String? = null, + val imageUrl: String? = null, + val kind: String = "other", + val openAs: String = "content", + val playable: Boolean = true, + val durationLabel: String? = null, + val start: String? = null, + val badge: String? = null, +) + +data class ViaplayHomeRail( + val id: String, + val title: String, + val items: List = emptyList(), +) + data class ViaplayHomePayload( val generatedAt: String? = null, val live: List = emptyList(), val today: List = emptyList(), val weekend: List = emptyList(), val later: List = emptyList(), + val catchup: List = emptyList(), val sports: List = emptyList(), val events: List = emptyList(), + val rails: List = emptyList(), ) data class F1tvMeetingDetail( 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 6aed4b2..cc4a38d 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 @@ -146,13 +146,18 @@ fun EventPlayerScreen( audioTracks = emptyList() textTracks = emptyList() try { - val play = if (hubPlay) { - val contentId = Regex("""(?i)^f1tv:(\d+)$""").find(eventId.trim()) - ?.groupValues?.getOrNull(1) - ?: eventId.trim().removePrefix("f1tv:").removePrefix("F1TV:") - api.f1tvContentPlay(contentId) - } else { - api.scheduleEventPlay(eventId) + val play = when { + hubPlay && eventId.startsWith("viaplay-content:", ignoreCase = true) -> { + val guid = eventId.removePrefix("viaplay-content:").removePrefix("VIAPLAY-CONTENT:") + api.viaplayContentPlay(guid) + } + hubPlay -> { + val contentId = Regex("""(?i)^f1tv:(\d+)$""").find(eventId.trim()) + ?.groupValues?.getOrNull(1) + ?: eventId.trim().removePrefix("f1tv:").removePrefix("F1TV:") + api.f1tvContentPlay(contentId) + } + else -> api.scheduleEventPlay(eventId) } if (!isActive) return@LaunchedEffect title = play.name diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt index 79b9f83..a9aeef3 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/EventsScreen.kt @@ -142,7 +142,6 @@ fun EventsScreen( val listState = rememberLazyListState() val firstRailFocus = remember { FocusRequester() } val restoreCardFocus = remember { FocusRequester() } - val hubRailFocus = remember { FocusRequester() } suspend fun refresh() { runCatching { api.listScheduleEvents() } @@ -158,7 +157,7 @@ fun EventsScreen( add(EventHubInfo("f1", "Formule 1", "Agenda & terugkijken")) } if (onOpenViaplayHub != null) { - add(EventHubInfo("viaplay", "Viaplay", "Live & agenda")) + add(EventHubInfo("viaplay", "Viaplay", "Sport · live · terugkijken")) } } } @@ -255,7 +254,7 @@ fun EventsScreen( val filterBarFocus = remember { FocusRequester() } val firstRailUpTarget = when { showFilterBar && filterBarFocusReady -> filterBarFocus - hasHubRail && hubRailFocusReady -> hubRailFocus + hasHubRail && hubRailFocusReady -> contentFocusRequester else -> navFocusRequester } val filterFocusDown: FocusRequester? = when { @@ -348,11 +347,12 @@ fun EventsScreen( showFilterBar -> filterBarFocusReady else -> railFocusReady } + // contentFocusRequester zit sticky op hub-rail (buiten LazyColumn) of filter/sink. + // Nooit: binden → unbinden → opnieuw (dat gooit focus terug naar TopNav). val needContentSink = when { loading && events.isEmpty() -> true error != null && events.isEmpty() -> true - // contentFocusRequester mag nooit "los" staan (TopNav.down → crash) - hasHubRail -> !hubRailFocusReady + hasHubRail -> false showFilterBar -> !filterBarFocusReady !hasRails -> true else -> !railFocusReady @@ -499,28 +499,27 @@ fun EventsScreen( } } else -> { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 56.dp), - ) { + Column(Modifier.fillMaxSize()) { + // Sticky hub-rail BUITEN LazyColumn: contentFocusRequester blijft stabiel gebonden + // (LazyColumn-item unbindt bij scroll/recompose → focus terug naar TopNav). if (hasHubRail) { - item(key = "hub_rail") { - // Zolang TopNav-sink contentFocusRequester vasthoudt → hubRailFocus op tile. - // Zodra ready → contentFocusRequester op eerste tile. - val hubEntryFocus = - if (hubRailFocusReady) contentFocusRequester else hubRailFocus - EventsHubRail( - hubs = hubs, - focusRequester = hubEntryFocus, - focusUp = navFocusRequester, - onOpenF1 = onOpenF1Hub, - onOpenViaplay = onOpenViaplayHub, - onFocusReady = { hubRailFocusReady = it }, - onFocus = onContentFocused, - ) - } + EventsHubRail( + hubs = hubs, + focusRequester = contentFocusRequester, + focusUp = navFocusRequester, + onOpenF1 = onOpenF1Hub, + onOpenViaplay = onOpenViaplayHub, + onFocusReady = { hubRailFocusReady = it }, + onFocus = onContentFocused, + ) } + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(bottom = 56.dp), + ) { // Eén filterrij (sport + bron) — altijd aanwezig bij data zodat focus stabiel blijft. if (showFilterBar) { item(key = "filters") { @@ -535,11 +534,7 @@ fun EventsScreen( liveCount = live.size, totalCount = filtered.size, firstFocusRequester = if (hasHubRail) filterBarFocus else contentFocusRequester, - focusUp = when { - hasHubRail && hubRailFocusReady -> contentFocusRequester - hasHubRail -> null - else -> navFocusRequester - }, + focusUp = if (hasHubRail) contentFocusRequester else navFocusRequester, focusDown = filterFocusDown, onFocusReady = { filterBarFocusReady = it }, onFocus = onContentFocused, @@ -658,6 +653,7 @@ fun EventsScreen( } } } + } } } } @@ -1127,41 +1123,68 @@ private fun EventsHubRail( onFocusReady: ((Boolean) -> Unit)? = null, onFocus: () -> Unit, ) { - LazyRow( + if (hubs.isEmpty()) return + fun accentFor(id: String) = when (id) { + "f1" -> Color(0xFFE10600) + "viaplay" -> Color(0xFF8B5CF6) + else -> McColors.Accent + } + fun badgeFor(hub: EventHubInfo) = when (hub.id) { + "f1" -> "F1" + "viaplay" -> "VP" + else -> hub.label.take(2).uppercase() + } + fun openFor(id: String): (() -> Unit)? = when (id) { + "f1" -> onOpenF1 + "viaplay" -> onOpenViaplay + else -> null + } + // Eerste hub sticky BUITEN LazyRow: contentFocusRequester mag niet unbinden bij scroll. + val first = hubs.first() + val firstOpen = openFor(first.id) + val rest = hubs.drop(1) + Row( modifier = Modifier .fillMaxWidth() - .padding(top = 6.dp, bottom = 4.dp), - contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), + .padding(top = 6.dp, bottom = 4.dp) + .padding(horizontal = HomeChrome.EdgeInset), horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - itemsIndexed(hubs, key = { _, h -> h.id }) { index, hub -> - val accent = when (hub.id) { - "f1" -> Color(0xFFE10600) - "viaplay" -> Color(0xFF8B5CF6) - else -> McColors.Accent - } - val badge = when (hub.id) { - "f1" -> "F1" - "viaplay" -> "VP" - else -> hub.label.take(2).uppercase() - } - val onOpen = when (hub.id) { - "f1" -> onOpenF1 - "viaplay" -> onOpenViaplay - else -> null - } ?: return@itemsIndexed + if (firstOpen != null) { HubRailTile( - badge = badge, - label = hub.label, - subtitle = hub.subtitle, - accent = accent, - focusRequester = if (index == 0) focusRequester else null, + badge = badgeFor(first), + label = first.label, + subtitle = first.subtitle, + accent = accentFor(first.id), + focusRequester = focusRequester, focusUp = focusUp, - onFocusReady = if (index == 0) onFocusReady else null, - onOpen = onOpen, + onFocusReady = onFocusReady, + onOpen = firstOpen, onFocus = onFocus, ) } + if (rest.isNotEmpty()) { + LazyRow( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + itemsIndexed(rest, key = { _, h -> h.id }) { _, hub -> + val onOpen = openFor(hub.id) ?: return@itemsIndexed + HubRailTile( + badge = badgeFor(hub), + label = hub.label, + subtitle = hub.subtitle, + accent = accentFor(hub.id), + focusRequester = null, + focusUp = focusUp, + onFocusReady = null, + onOpen = onOpen, + onFocus = onFocus, + ) + } + } + } } } 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 2218d5e..a96160d 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,6 +601,31 @@ fun McNav() { launchSingleTop = true } }, + onPlayContent = { guid -> + nav.navigate("viaplay_play/${android.net.Uri.encode(guid)}") { + launchSingleTop = true + } + }, + ) + } + composable( + "viaplay_play/{guid}", + arguments = listOf(navArgument("guid") { type = NavType.StringType }), + ) { entry -> + val guid = entry.arguments?.getString("guid") + ?.let { android.net.Uri.decode(it) } + ?: return@composable + DisposableEffect(Unit) { + onDispose { + pendingEventsFocusRestore = true + eventsReturnNonce++ + } + } + EventPlayerScreen( + api = api, + eventId = "viaplay-content:$guid", + hubPlay = true, + onBack = { nav.popBackStack() }, ) } composable( 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 0f068a7..939cd69 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 @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow @@ -32,7 +31,6 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush @@ -52,6 +50,7 @@ import coil.compose.AsyncImage import kotlinx.coroutines.delay import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ScheduleEvent +import nl.vonas.mediacluster.tv.data.ViaplayHomeItem import nl.vonas.mediacluster.tv.data.ViaplayHomePayload import nl.vonas.mediacluster.tv.ui.components.HomeChrome import nl.vonas.mediacluster.tv.ui.theme.McColors @@ -83,6 +82,7 @@ fun ViaplayHubScreen( api: ApiClient, onBack: () -> Unit, onOpenEvent: (String) -> Unit, + onPlayContent: (String) -> Unit = {}, ) { var home by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } @@ -128,6 +128,14 @@ fun ViaplayHubScreen( return filter { it.sportBucket.equals(f, ignoreCase = true) } } + fun onItem(item: ViaplayHomeItem) { + if (!item.playable) return + when (item.openAs.lowercase(Locale.US)) { + "event" -> onOpenEvent(item.id) + else -> onPlayContent(item.guid) + } + } + Box( Modifier .fillMaxSize() @@ -170,9 +178,12 @@ fun ViaplayHubScreen( val today = payload.today.sportFiltered() val weekend = payload.weekend.sportFiltered() val later = payload.later.sportFiltered() + val catchup = payload.catchup.sportFiltered() val sports = payload.sports - val hasAny = live.isNotEmpty() || today.isNotEmpty() || - weekend.isNotEmpty() || later.isNotEmpty() + val rails = payload.rails + val hasSchedule = live.isNotEmpty() || today.isNotEmpty() || + weekend.isNotEmpty() || later.isNotEmpty() || catchup.isNotEmpty() + val hasAny = hasSchedule || rails.isNotEmpty() LazyColumn( modifier = Modifier.fillMaxSize(), @@ -190,13 +201,13 @@ fun ViaplayHubScreen( VpChip("← Events", focusRequester = firstFocus, onClick = onBack) Column(Modifier.weight(1f)) { Text( - "Viaplay", + "Viaplay Sport", color = VpLook.White, fontSize = 28.sp, fontWeight = FontWeight.Black, ) Text( - "Live · vandaag · weekend", + "Live · agenda · samenvattingen · docs", color = VpLook.Mist, fontSize = 14.sp, ) @@ -242,7 +253,7 @@ fun ViaplayHubScreen( contentAlignment = Alignment.Center, ) { Text( - "Geen Viaplay-events in deze periode", + "Geen Viaplay-content beschikbaar", color = VpLook.Mist, fontSize = 16.sp, ) @@ -252,7 +263,7 @@ fun ViaplayHubScreen( if (live.isNotEmpty()) { item(key = "live") { - VpRail( + VpEventRail( title = "Nu live", accent = VpLook.Live, events = live, @@ -263,7 +274,7 @@ fun ViaplayHubScreen( } if (today.isNotEmpty()) { item(key = "today") { - VpRail( + VpEventRail( title = "Vandaag", accent = VpLook.Accent, events = today, @@ -274,7 +285,7 @@ fun ViaplayHubScreen( } if (weekend.isNotEmpty()) { item(key = "weekend") { - VpRail( + VpEventRail( title = "Weekend", accent = VpLook.Accent, events = weekend, @@ -285,7 +296,7 @@ fun ViaplayHubScreen( } if (later.isNotEmpty()) { item(key = "later") { - VpRail( + VpEventRail( title = "Later", accent = VpLook.Mist, events = later, @@ -294,6 +305,29 @@ fun ViaplayHubScreen( ) } } + if (catchup.isNotEmpty()) { + item(key = "catchup") { + VpEventRail( + title = "Terugkijken", + accent = VpLook.Accent, + events = catchup, + nowMs = nowMs, + onOpen = onOpenEvent, + ) + } + } + + // Officiële sport-sectie planken (HAR: /sport) + rails.forEach { rail -> + item(key = "rail-${rail.id}") { + VpCatalogRail( + title = rail.title, + accent = VpLook.Accent, + items = rail.items, + onOpen = ::onItem, + ) + } + } } } } @@ -302,7 +336,7 @@ fun ViaplayHubScreen( @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable -private fun VpRail( +private fun VpEventRail( title: String, accent: Color, events: List, @@ -329,6 +363,34 @@ private fun VpRail( } } +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun VpCatalogRail( + title: String, + accent: Color, + items: List, + onOpen: (ViaplayHomeItem) -> Unit, +) { + Column(Modifier.padding(top = 18.dp, bottom = 6.dp)) { + Text( + title.uppercase(Locale("nl", "NL")), + color = accent, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.2.sp, + modifier = Modifier.padding(horizontal = HomeChrome.EdgeInset, vertical = 6.dp), + ) + LazyRow( + contentPadding = PaddingValues(horizontal = HomeChrome.EdgeInset), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + itemsIndexed(items, key = { _, e -> e.id }) { _, item -> + VpCatalogCard(item = item, onOpen = { onOpen(item) }) + } + } + } +} + @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable private fun VpEventCard( @@ -338,6 +400,7 @@ private fun VpEventCard( ) { var focused by remember { mutableStateOf(false) } val isLive = event.phase.equals("live", ignoreCase = true) || event.isLive + val isCatchup = event.phase.equals("ended", ignoreCase = true) Surface( onClick = onOpen, modifier = Modifier @@ -377,9 +440,14 @@ private fun VpEventCard( modifier = Modifier.fillMaxSize(), ) } - if (isLive) { + val badge = when { + isLive -> "LIVE" + isCatchup -> "TERUGKIJKEN" + else -> null + } + if (badge != null) { Text( - "LIVE", + badge, color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Black, @@ -387,7 +455,7 @@ private fun VpEventCard( .align(Alignment.TopStart) .padding(10.dp) .clip(RoundedCornerShape(4.dp)) - .background(VpLook.Live) + .background(if (isLive) VpLook.Live else VpLook.AccentDeep) .padding(horizontal = 8.dp, vertical = 3.dp), ) } @@ -417,6 +485,95 @@ private fun VpEventCard( } } +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun VpCatalogCard( + item: ViaplayHomeItem, + onOpen: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + Surface( + onClick = onOpen, + enabled = item.playable, + modifier = Modifier + .width(280.dp) + .onFocusChanged { focused = it.isFocused }, + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow(elevationColor = VpLook.Accent, elevation = 10.dp), + ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, VpLook.Accent), + inset = 3.dp, + shape = RoundedCornerShape(14.dp), + ), + ), + colors = ClickableSurfaceDefaults.colors( + containerColor = VpLook.Panel, + focusedContainerColor = VpLook.PanelHot, + ), + shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)), + ) { + Column { + Box( + Modifier + .fillMaxWidth() + .height(148.dp) + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .background(Color(0xFF0E1018)), + ) { + if (!item.imageUrl.isNullOrBlank()) { + AsyncImage( + model = item.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + val badge = item.badge + if (!badge.isNullOrBlank()) { + Text( + badge, + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Black, + modifier = Modifier + .align(Alignment.TopStart) + .padding(10.dp) + .clip(RoundedCornerShape(4.dp)) + .background( + when { + badge.equals("LIVE", true) -> VpLook.Live + else -> VpLook.AccentDeep + }, + ) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + } + Column(Modifier.padding(12.dp)) { + Text( + item.title, + color = VpLook.White, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Text( + listOfNotNull(item.subtitle, item.durationLabel).joinToString(" · "), + color = VpLook.Mist, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun VpChip( diff --git a/apps/master-api/src/viaplay/client.ts b/apps/master-api/src/viaplay/client.ts index 7562208..2b06433 100644 --- a/apps/master-api/src/viaplay/client.ts +++ b/apps/master-api/src/viaplay/client.ts @@ -307,7 +307,16 @@ function pickImage(content: Record | null): string | null { if (!content) return null; const images = asRecord(content.images); if (!images) return null; - for (const key of ["landscape", "boxart", "cover", "hero"]) { + for (const key of [ + "landscape", + "coverart169", + "hero169", + "boxart", + "coverart23", + "cover", + "hero", + "event169", + ]) { const img = asRecord(images[key]); const url = typeof img?.url === "string" ? img.url : null; if (url) return url; @@ -384,6 +393,226 @@ export async function fetchSportHighlights( return extractProducts(res.json); } +/** Catalog-item van de Viaplay sport-sectie (plank/rail). */ +export type ViaplaySportRailItem = { + guid: string; + title: string; + subtitle: string | null; + imageUrl: string | null; + /** sport | clip | series | other */ + kind: "sport" | "clip" | "series" | "other"; + /** Speelbaar via play-live (sport/EPG) of play.viaplay.com/byguid (clip/series). */ + playMode: "live" | "vod" | null; + durationLabel: string | null; + startMs: number | null; + endMs: number | null; + pageUrl: string | null; +}; + +export type ViaplaySportRail = { + id: string; + title: string; + items: ViaplaySportRailItem[]; +}; + +function streamHref(product: Record): string | null { + const links = asRecord(product._links); + const stream = asRecord(links?.["viaplay:stream"]); + const href = typeof stream?.href === "string" ? stream.href : null; + return href?.trim() || null; +} + +function parseDurationLabel(content: Record | null): string | null { + const duration = asRecord(content?.duration); + if (typeof duration?.readable === "string" && duration.readable.trim()) { + return duration.readable.trim(); + } + const ms = Number(duration?.milliseconds); + if (!Number.isFinite(ms) || ms <= 0) return null; + const totalSec = Math.round(ms / 1000); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + if (m >= 60) { + const h = Math.floor(m / 60); + return `${h}:${String(m % 60).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + } + return `${m}:${String(s).padStart(2, "0")}`; +} + +function productTitle(content: Record | null, guid: string): string { + if (!content) return guid; + if (typeof content.title === "string" && content.title.trim()) return content.title.trim(); + const series = asRecord(content.series); + if (typeof series?.title === "string" && series.title.trim()) return series.title.trim(); + if (typeof content.originalTitle === "string" && content.originalTitle.trim()) { + return content.originalTitle.trim(); + } + return guid; +} + +function productToRailItem(node: Record): ViaplaySportRailItem | null { + const system = asRecord(node.system); + const content = asRecord(node.content); + const guid = String(system?.guid ?? "").trim(); + if (!guid) return null; + const type = String(node.type ?? "").trim().toLowerCase(); + const epg = asRecord(node.epg); + const startMs = epg ? parseIsoMs(epg.start ?? epg.startTime) : null; + const endMs = epg ? parseIsoMs(epg.end ?? epg.endTime ?? epg.stop) : null; + const href = streamHref(node); + const links = asRecord(node._links); + const page = + (typeof asRecord(links?.["viaplay:page"])?.href === "string" + ? String(asRecord(links!["viaplay:page"])!.href) + : null) || + (typeof asRecord(links?.self)?.href === "string" + ? String(asRecord(links!.self)!.href) + : null); + + let kind: ViaplaySportRailItem["kind"] = "other"; + if (type === "sport") kind = "sport"; + else if (type === "clip") kind = "clip"; + else if (type === "series" || type === "sportseries" || type === "episode") kind = "series"; + else if (type === "movie" || type === "film") kind = "series"; // docs/specials → VOD byguid + + let playMode: ViaplaySportRailItem["playMode"] = null; + if (href?.includes("play-live.viaplay.com") || (kind === "sport" && startMs != null)) { + playMode = "live"; + } else if ( + href?.includes("play.viaplay.com/api/stream/byguid") || + kind === "clip" || + (kind === "series" && type !== "sportseries") + ) { + // sportSeries is vaak een categorie-pagina zonder directe stream + playMode = "vod"; + } else if (type === "movie" || type === "film") { + playMode = "vod"; + } + + const title = productTitle(content, guid); + const subtitle = + String( + content?.originalTitle ?? content?.synopsis ?? content?.description ?? "" + ).trim() || null; + + return { + guid, + title, + subtitle: subtitle === title ? null : subtitle, + imageUrl: pickImage(content), + kind, + playMode, + durationLabel: parseDurationLabel(content), + startMs, + endMs, + pageUrl: page, + }; +} + +function blocksFromPage(json: unknown): Array> { + const root = asRecord(json); + const embedded = asRecord(root?._embedded); + const blocks = embedded?.["viaplay:blocks"]; + if (!Array.isArray(blocks)) return []; + const out: Array> = []; + for (const b of blocks) { + const rec = asRecord(b); + if (rec) out.push(rec); + } + return out; +} + +function productsFromBlock(block: Record): ViaplaySportRailItem[] { + const embedded = asRecord(block._embedded); + const products = embedded?.["viaplay:products"]; + if (!Array.isArray(products)) return []; + const out: ViaplaySportRailItem[] = []; + const seen = new Set(); + for (const raw of products) { + const node = asRecord(raw); + if (!node) continue; + const item = productToRailItem(node); + if (!item || seen.has(item.guid)) continue; + seen.add(item.guid); + out.push(item); + } + return out; +} + +async function fetchSportBlockPage( + deviceKey: string, + blockId: string, + pageNumber: number, + timeoutMs: number, + creds?: ViaplayAccountCreds | null +): Promise { + const u = new URL(`https://content.viaplay.com/${deviceKey}/sport`); + u.searchParams.set("blockId", blockId); + u.searchParams.set("partial", "1"); + u.searchParams.set("pageNumber", String(pageNumber)); + u.searchParams.set("language", "nl"); + const headers: Record = { + Accept: "*/*", + "User-Agent": VIAPLAY_USER_AGENT, + Referer: "https://viaplay.com/sport", + }; + if (creds) Object.assign(headers, authHeaders(creds)); + const res = await fetchJson(u.toString(), { headers, timeoutMs }); + if (res.status !== 200) return []; + const blocks = blocksFromPage(res.json); + const block = blocks[0] || asRecord(res.json); + return block ? productsFromBlock(block) : []; +} + +/** + * Officiële Viaplay sport-sectie als hub-planken (HAR: /pcdash-nl/sport). + * Lege list-blocks worden 1× bijgeladen via blockId&partial=1. + */ +export async function fetchSportRails( + deviceKey: string, + timeoutMs: number, + creds?: ViaplayAccountCreds | null +): Promise { + const url = `https://content.viaplay.com/${deviceKey}/sport?language=nl`; + const headers: Record = { + Accept: "*/*", + "User-Agent": VIAPLAY_USER_AGENT, + Referer: "https://viaplay.com/sport", + }; + if (creds) Object.assign(headers, authHeaders(creds)); + const res = await fetchJson(url, { headers, timeoutMs }); + if (res.status !== 200) { + throw new Error(`Viaplay sport-sectie mislukt (${res.status})`); + } + + const skipTitles = /^(favorites|schema|featurebox)/i; + const rails: ViaplaySportRail[] = []; + + for (const block of blocksFromPage(res.json)) { + const type = String(block.type ?? "").trim().toLowerCase(); + if (type !== "list" && type !== "singleproductpromo") continue; + const id = String(block.id ?? "").trim(); + const title = String(block.title ?? block.profileBlockTitle ?? "").trim(); + if (!id || !title || skipTitles.test(title)) continue; + + let items = productsFromBlock(block); + if (!items.length) { + try { + items = await fetchSportBlockPage(deviceKey, id, 1, timeoutMs, creds); + } catch { + items = []; + } + } + // Promo zonder embedded products: skip + if (!items.length) continue; + // Alleen speelbare items (geen categorie-tegels zoals sportSeries) + const playable = items.filter((it) => it.playMode != null).slice(0, 40); + if (!playable.length) continue; + rails.push({ id, title, items: playable }); + } + return rails; +} + function extractProducts(json: unknown): ViaplayEpgProduct[] { const out: ViaplayEpgProduct[] = []; const seen = new Set(); @@ -449,6 +678,13 @@ export async function playByMediaGuid( `play-live mislukt (${res.status})`; throw new Error(msg); } + return parsePlayStreamBody(body, mediaGuid); +} + +function parsePlayStreamBody( + body: Record, + id: string +): ViaplayPlayResult { const links = asRecord(body._links); const media = asRecord(links?.["viaplay:media"]); const license = @@ -458,7 +694,7 @@ export async function playByMediaGuid( const product = asRecord(body.product); const channel = asRecord(body.channel) || asRecord(asRecord(body.streamingSession)?.channel); return { - mediaGuid, + mediaGuid: id, mpdUrl, licenseUrl: typeof license?.href === "string" ? license.href : null, licenseAuth: typeof license?.auth === "string" ? license.auth : null, @@ -471,6 +707,41 @@ export async function playByMediaGuid( }; } +/** + * VOD / clips / samenvattingen / docs — HAR: play.viaplay.com/api/stream/byguid + * (live sport blijft play-live …/bymediaguid). + */ +export async function playByGuid( + creds: ViaplayAccountCreds, + guidRaw: string, + opts: { deviceKey: string; timeoutMs: number } +): Promise { + const guid = String(guidRaw || "").trim(); + if (!guid) throw new Error("Geen Viaplay guid"); + 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("defaultAvailabilityContext", "svod"); + if (creds.profileId) u.searchParams.set("profileId", creds.profileId); + + const res = await fetchJson(u.toString(), { + headers: authHeaders(creds), + timeoutMs: opts.timeoutMs, + }); + const body = asRecord(res.json); + if (res.status !== 200 || !body) { + const msg = + (typeof body?.message === "string" && body.message) || + `play-byguid mislukt (${res.status})`; + throw new Error(msg); + } + return parsePlayStreamBody(body, guid); +} + /** Bouw MPD-URL met EPG start/stop kap (seconden). */ export function kapMpdUrl( mpdUrl: string, diff --git a/apps/master-api/src/viaplay/events.ts b/apps/master-api/src/viaplay/events.ts index c9b2611..070720d 100644 --- a/apps/master-api/src/viaplay/events.ts +++ b/apps/master-api/src/viaplay/events.ts @@ -1,6 +1,17 @@ import type { ScheduleEventPublic } from "../viewer/schedule-events"; -import { loadViaplayConfigOnly } from "./settings"; +import { + fetchSportRails, + type ViaplaySportRail, + type ViaplaySportRailItem, +} from "./client"; +import { + loadViaplayConfigOnly, +} from "./settings"; import type { ViaplayEventSnapshot } from "./tweaks"; +import { toMediaGuid } from "./accounts"; + +/** Officieel Viaplay-terugkijken venster (~48u na start/einde). */ +export const VIAPLAY_CATCHUP_MS = 48 * 60 * 60_000; function phaseFor(startMs: number, endMs: number, nowMs: number): "live" | "upcoming" | "ended" { if (nowMs < startMs) return "upcoming"; @@ -22,6 +33,7 @@ export function eventToPublic( nowMs: number ): ScheduleEventPublic { const phase = phaseFor(event.startMs, event.endMs, nowMs); + const catchupOk = phase === "ended" && nowMs <= event.endMs + VIAPLAY_CATCHUP_MS; return { id: toEventId(event.mediaGuid), name: event.title, @@ -34,7 +46,7 @@ export function eventToPublic( network: "Viaplay", isLive: phase === "live", phase, - playable: phase === "live", + playable: phase === "live" || catchupOk, status: phase, start: new Date(event.startMs).toISOString(), stop: new Date(event.endMs).toISOString(), @@ -60,6 +72,7 @@ export async function listViaplayScheduleEvents( const horizon = nowMs + aheadMs; const out: ScheduleEventPublic[] = []; for (const event of config.events) { + // Hub/Events: live + upcoming; catch-up zit in Viaplay-hub rails / detail play. if (event.endMs < nowMs) continue; if (event.startMs > horizon) continue; const pub = eventToPublic(event, nowMs); @@ -89,6 +102,27 @@ export type ViaplaySportChip = { count: number; }; +export type ViaplayHomeItem = { + id: string; + guid: string; + title: string; + subtitle: string | null; + imageUrl: string | null; + kind: ViaplaySportRailItem["kind"]; + /** event = schedule detail; content = VOD/clip play */ + openAs: "event" | "content"; + playable: boolean; + durationLabel: string | null; + start: string | null; + badge: string | null; +}; + +export type ViaplayHomeRail = { + id: string; + title: string; + items: ViaplayHomeItem[]; +}; + export type ViaplayHomePayload = { generatedAt: string; /** Nu live */ @@ -99,10 +133,14 @@ export type ViaplayHomePayload = { weekend: ScheduleEventPublic[]; /** Upcoming daarna binnen sync-horizon */ later: ScheduleEventPublic[]; + /** Recent afgelopen binnen catch-up venster */ + catchup: ScheduleEventPublic[]; /** Sport-chips aanwezig in de payload */ sports: ViaplaySportChip[]; /** Alle niet-ended events (voor client-side sportfilter) */ events: ScheduleEventPublic[]; + /** Officiële sport-sectie planken (samenvattingen, docs, …) */ + rails: ViaplayHomeRail[]; }; const ZONE_NL = "Europe/Amsterdam"; @@ -139,7 +177,66 @@ function isWeekendLocal(ms: number): boolean { return d === 5 || d === 6 || d === 0; // vr za zo } -/** Hub: tijd-rails + sport-meta. Live blijft ook in Events; hier voor dieper browsen. */ +function railItemToHome(item: ViaplaySportRailItem, nowMs: number): ViaplayHomeItem { + const isEvent = item.kind === "sport" && item.playMode === "live"; + const openAs: ViaplayHomeItem["openAs"] = isEvent ? "event" : "content"; + const id = + openAs === "event" ? toEventId(toMediaGuid(item.guid)) : `viaplay-content:${item.guid}`; + + let badge: string | null = null; + if (item.startMs != null && item.endMs != null) { + const phase = phaseFor(item.startMs, item.endMs, nowMs); + if (phase === "live") badge = "LIVE"; + else if (phase === "ended") badge = "TERUGKIJKEN"; + } else if (item.kind === "clip") { + badge = item.durationLabel || "CLIP"; + } else if (item.kind === "series") { + badge = "SERIE"; + } + + return { + id, + guid: item.guid, + title: item.title, + subtitle: item.subtitle, + imageUrl: item.imageUrl, + kind: item.kind, + openAs, + playable: item.playMode != null, + durationLabel: item.durationLabel, + start: item.startMs != null ? new Date(item.startMs).toISOString() : null, + badge, + }; +} + +function mapSportRails(rails: ViaplaySportRail[], nowMs: number): ViaplayHomeRail[] { + return rails.map((rail) => ({ + id: rail.id, + title: rail.title, + items: rail.items.map((item) => railItemToHome(item, nowMs)), + })); +} + +let sportRailsCache: { at: number; rails: ViaplayHomeRail[] } | null = null; +const SPORT_RAILS_TTL_MS = 5 * 60_000; + +async function loadSportRailsHome(nowMs: number): Promise { + if (sportRailsCache && nowMs - sportRailsCache.at < SPORT_RAILS_TTL_MS) { + return sportRailsCache.rails; + } + try { + const config = await loadViaplayConfigOnly(); + if (!config) return sportRailsCache?.rails ?? []; + const raw = await fetchSportRails(config.deviceKey, config.httpTimeoutMs); + const rails = mapSportRails(raw, nowMs); + sportRailsCache = { at: nowMs, rails }; + return rails; + } catch { + return sportRailsCache?.rails ?? []; + } +} + +/** Hub: tijd-rails + officiële sport-planken (samenvattingen/docs). */ export async function getViaplayHome(nowMs = Date.now()): Promise { const config = await loadViaplayConfigOnly(); const empty: ViaplayHomePayload = { @@ -148,25 +245,32 @@ export async function getViaplayHome(nowMs = Date.now()): Promise horizon) continue; const pub = eventToPublic(event, nowMs); - if (pub.phase === "ended") continue; + if (pub.phase === "ended") { + if (pub.playable) catchup.push(pub); + continue; + } + if (event.endMs < nowMs) continue; all.push(pub); } all.sort((a, b) => Date.parse(a.start) - Date.parse(b.start)); + catchup.sort((a, b) => Date.parse(b.start) - Date.parse(a.start)); const live = all.filter((e) => e.phase === "live"); const upcoming = all.filter((e) => e.phase === "upcoming"); @@ -193,7 +297,7 @@ export async function getViaplayHome(nowMs = Date.now()): Promise(); - for (const e of all) { + for (const e of [...all, ...catchup.slice(0, 40)]) { const id = e.sportBucket || "other"; const label = e.sportLabel || id; const cur = sportCounts.get(id) ?? { label, count: 0 }; @@ -205,13 +309,17 @@ export async function getViaplayHome(nowMs = Date.now()): Promise ({ id, label: v.label, count: v.count })) .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, "nl")); + const rails = await loadSportRailsHome(nowMs); + return { generatedAt: new Date(nowMs).toISOString(), live, today, weekend, later, + catchup: catchup.slice(0, 40), sports, events: all, + rails, }; } diff --git a/apps/master-api/src/viaplay/play.ts b/apps/master-api/src/viaplay/play.ts index 5619553..35edf3a 100644 --- a/apps/master-api/src/viaplay/play.ts +++ b/apps/master-api/src/viaplay/play.ts @@ -7,8 +7,11 @@ import { } from "./accounts"; import { extractViaplayKeys } from "./cdm"; import { + playByGuid, playByMediaGuid, resolveKapMpd, + fetchMpdXml, + extractPsshFromMpd, VIAPLAY_USER_AGENT, } from "./client"; import { @@ -17,6 +20,7 @@ import { } from "./settings"; import { findViaplayEvent } from "./sync"; import { isMpdCacheFresh, type ViaplayEventSnapshot } from "./tweaks"; +import { VIAPLAY_CATCHUP_MS } from "./events"; function parseKey(value: string): { kid: string; key: string } | null { const [kid, key] = value.trim().toLowerCase().split(":", 2); @@ -249,9 +253,102 @@ export async function resolveViaplayPlay( if (!event) throw new AppError("NOT_FOUND", "Viaplay-event niet gevonden", 404); const now = Date.now(); - if (now > event.endMs + 15 * 60_000) { + // Catch-up: officieel ~48u; live-pad (play-live + KAP) blijft na einde bruikbaar. + if (now > event.endMs + VIAPLAY_CATCHUP_MS) { throw new AppError("GONE", "Dit event is afgelopen", 410); } // Pre-live: wel MPD/keys toestaan binnen preLiveMinutes; viewer play blijft live-only return ensureViaplayEventReady(sessionSecret, event); } + +/** + * VOD/clip/doc play via play.viaplay.com/byguid (HAR sport-samenvattingen). + */ +export async function resolveViaplayGuidPlay( + sessionSecret: string, + guidRaw: string +): Promise { + const guid = String(guidRaw || "").trim(); + if (!guid) throw new AppError("BAD_REQUEST", "Geen Viaplay guid", 400); + + const settings = await loadViaplaySettings(sessionSecret); + const tweaks = settings.config; + const accounts = settings.accounts; + if (!accounts.length) { + throw new AppError("FAILED_PRECONDITION", "Geen Viaplay-account", 400); + } + + let cachedKeys = await keysForMediaGuid(sessionSecret, guid); + let lastError: Error | null = null; + + for (const account of accounts) { + try { + const play = await playByGuid(account, guid, { + deviceKey: tweaks.deviceKey, + timeoutMs: tweaks.httpTimeoutMs, + }); + + let keys = cachedKeys; + const wantKeys = tweaks.fetchKeysIfMissing; + let pssh: string | null = null; + let mpdUrl = play.mpdUrl; + try { + const mpd = await fetchMpdXml(play.mpdUrl, tweaks.httpTimeoutMs); + if (mpd.status === 200 && /]/i.test(mpd.xml)) { + pssh = extractPsshFromMpd(mpd.xml); + mpdUrl = mpd.url; + } + } catch { + /* speel base MPD */ + } + + if (wantKeys && pssh && play.licenseUrl && !keys.length) { + const extracted = await extractViaplayKeys({ + wvdPath: settings.wvdPath, + pssh, + licUrl: play.licenseUrl, + licAuth: play.licenseAuth, + userAgent: VIAPLAY_USER_AGENT, + timeoutMs: tweaks.cdmTimeoutMs, + }); + const parsed = extracted + .map(parseKey) + .filter((k): k is { kid: string; key: string } => !!k); + if (parsed.length) { + keys = parsed; + cachedKeys = parsed; + const { upsertDrmKey } = await import("../drm/keys"); + for (const k of parsed) { + try { + await upsertDrmKey(prisma, sessionSecret, { + kid: k.kid, + key: k.key, + mpdUrl, + eventName: play.title || guid, + channelName: play.channelName, + source: "viaplay", + externalId: guid, + mediaGuid: guid, + }); + } catch { + /* ignore */ + } + } + } + } + + return { + streamUrl: mpdUrl, + format: "dash", + keys, + fromCache: false, + mediaGuid: guid, + accountId: account.id, + }; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + } + } + + throw lastError || new AppError("UPSTREAM", "Viaplay VOD-play mislukt", 502); +} diff --git a/apps/master-api/src/viaplay/sync.ts b/apps/master-api/src/viaplay/sync.ts index 98fab7d..18672e8 100644 --- a/apps/master-api/src/viaplay/sync.ts +++ b/apps/master-api/src/viaplay/sync.ts @@ -133,8 +133,8 @@ export async function syncViaplayCatalog(_sessionSecret?: string) { events.push(toSnapshot(product, prev, owners)); } - // Drop ended events older than 6h; also drop further than sync horizon+1d - const cutoff = now - 6 * 3_600_000; + // Bewaar catch-up tot ~48u na einde (officieel Viaplay-venster) + const cutoff = now - 48 * 3_600_000; const horizon = now + config.syncDaysAhead * 86_400_000 + 86_400_000; const kept = events.filter((e) => e.endMs >= cutoff && e.startMs <= horizon); kept.sort((a, b) => a.startMs - b.startMs); diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index d947b01..eb63c5e 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -964,7 +964,7 @@ export function registerViewerRoutes( hubs.push({ id: "viaplay", label: "Viaplay", - subtitle: "Live & agenda", + subtitle: "Sport · live · terugkijken", }); } } catch { @@ -1032,6 +1032,64 @@ export function registerViewerRoutes( }; }); + app.get("/api/v1/client/viaplay/content/:guid/play", async (request) => { + const auth = await viewers.authFromBearer(request.headers.authorization); + 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 { resolveViewerLiveEventsAccess } = await import("./schedule-events"); + const access = await resolveViewerLiveEventsAccess(auth.viewerId, config); + if (!access.enabled) { + throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403); + } + const { getIptvSettings } = await import("../settings/iptv"); + const s = await getIptvSettings(config); + if (s.liveEventsDisabledProviders.map((p) => p.toLowerCase()).includes("viaplay")) { + throw new AppError("NOT_FOUND", "Viaplay is uitgeschakeld", 404); + } + if ( + access.allowedProviders && + access.allowedProviders.length > 0 && + !access.allowedProviders.includes("viaplay") + ) { + throw new AppError("NOT_FOUND", "Viaplay is niet beschikbaar voor dit account", 404); + } + + const { resolveViaplayGuidPlay } = await import("../viaplay/play"); + const played = await resolveViaplayGuidPlay(config.SESSION_SECRET, guid); + let watchSessionId: string | null = null; + try { + const { touchClientWatch } = await import("./client-watches"); + const watch = touchClientWatch({ + viewerUserId: auth.viewerId, + kind: "event", + resourceId: `viaplay-content:${guid}`, + title: guid, + provider: "viaplay", + }); + watchSessionId = watch.id; + } catch { + /* best-effort */ + } + return { + eventId: `viaplay-content:${guid}`, + name: guid, + logoUrl: null, + streamUrl: played.streamUrl, + format: played.format, + fallbackStreamUrl: null, + fallbackFormat: null, + drm: played.keys.length + ? { + type: "clearkey", + keys: played.keys.map((k) => ({ kid: k.kid, key: k.key })), + } + : null, + watchSessionId, + }; + }); + /** * Lichte presence-heartbeat voor Live Events / custom live-lists. * Fire-and-forget vanaf de client; failures mogen genegeerd worden.