diff --git a/apps/admin-ui/src/app/viewers/page.tsx b/apps/admin-ui/src/app/viewers/page.tsx
index 9f7bc4f..e7b184f 100644
--- a/apps/admin-ui/src/app/viewers/page.tsx
+++ b/apps/admin-ui/src/app/viewers/page.tsx
@@ -26,6 +26,8 @@ type Viewer = {
name: string | null;
enabled: boolean;
appAccess: boolean;
+ iptvAccess: boolean;
+ iptvAddonTokenId: string | null;
googlePlayEmail: string | null;
googlePlayAccess: boolean;
googlePlayMembershipId: string | null;
@@ -143,7 +145,7 @@ export default function ViewersPage() {
}
}
- async function toggle(v: Viewer, field: "enabled" | "appAccess") {
+ async function toggle(v: Viewer, field: "enabled" | "appAccess" | "iptvAccess") {
setActionId(v.id);
setErr(null);
try {
@@ -481,6 +483,21 @@ export default function ViewersPage() {
+
+
+ Live TV (IPTV)
+ TV-tab in de Android-app
+
+
+
diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts
index 5e037ab..b861739 100644
--- a/apps/android-tv/app/build.gradle.kts
+++ b/apps/android-tv/app/build.gradle.kts
@@ -12,8 +12,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24
targetSdk = 35
- versionCode = 21
- versionName = "0.8.4"
+ versionCode = 22
+ versionName = "0.9.0"
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 4b91f2b..394d0ef 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
@@ -255,6 +255,91 @@ class ApiClient(
Unit
}
+ suspend fun clientMe(): ClientMe = withContext(Dispatchers.IO) {
+ val req = authed("$baseUrl/api/v1/client/me").get().build()
+ parseObject(execute(req)) { o ->
+ ClientMe(
+ iptvEnabled = o.optJSONObject("iptv")?.optBoolean("enabled") == true,
+ )
+ }
+ }
+
+ suspend fun iptvCategories(): List = withContext(Dispatchers.IO) {
+ val req = authed("$baseUrl/api/v1/client/iptv/categories").get().build()
+ parseObject(execute(req)) { o ->
+ val arr = o.optJSONArray("categories") ?: JSONArray()
+ buildList {
+ for (i in 0 until arr.length()) {
+ val c = arr.getJSONObject(i)
+ add(
+ IptvCategory(
+ id = c.getString("id"),
+ name = c.getString("name"),
+ channelCount = c.optInt("channelCount"),
+ )
+ )
+ }
+ }
+ }
+ }
+
+ suspend fun iptvChannels(categoryId: String? = null): List = withContext(Dispatchers.IO) {
+ val q = categoryId?.let { "?categoryId=${java.net.URLEncoder.encode(it, "UTF-8")}" }.orEmpty()
+ val req = authed("$baseUrl/api/v1/client/iptv/channels$q").get().build()
+ parseObject(execute(req)) { o ->
+ val arr = o.optJSONArray("channels") ?: JSONArray()
+ buildList {
+ for (i in 0 until arr.length()) {
+ val c = arr.getJSONObject(i)
+ add(
+ IptvChannel(
+ streamId = c.getInt("streamId"),
+ name = c.getString("name"),
+ logoUrl = c.optString("logoUrl").ifBlank { null },
+ number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") },
+ categoryId = c.optString("categoryId").ifBlank { null },
+ )
+ )
+ }
+ }
+ }
+ }
+
+ suspend fun iptvPlayUrl(streamId: Int): IptvPlayInfo = withContext(Dispatchers.IO) {
+ val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/play").get().build()
+ parseObject(execute(req)) { o ->
+ IptvPlayInfo(
+ streamId = o.getInt("streamId"),
+ name = o.getString("name"),
+ logoUrl = o.optString("logoUrl").ifBlank { null },
+ streamUrl = o.getString("streamUrl"),
+ format = o.optString("format").ifBlank { "hls" },
+ )
+ }
+ }
+
+ suspend fun iptvEpg(streamId: Int): IptvEpg = withContext(Dispatchers.IO) {
+ val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/epg").get().build()
+ parseObject(execute(req)) { o ->
+ IptvEpg(
+ streamId = o.getInt("streamId"),
+ channelName = o.getString("channelName"),
+ logoUrl = o.optString("logoUrl").ifBlank { null },
+ now = o.optJSONObject("now")?.toEpgProgram(),
+ next = o.optJSONObject("next")?.toEpgProgram(),
+ )
+ }
+ }
+
+ private fun JSONObject.toEpgProgram(): EpgProgram? {
+ val title = optString("title").ifBlank { return null }
+ return EpgProgram(
+ title = title,
+ start = optString("start").ifBlank { null },
+ end = optString("end").ifBlank { null },
+ )
+ }
+
private fun authed(url: String): Request.Builder {
val b = Request.Builder().url(url)
val t = token
@@ -378,6 +463,46 @@ data class ShelfPref(
val sortOrder: Int,
)
+data class ClientMe(
+ val iptvEnabled: Boolean,
+)
+
+data class IptvCategory(
+ val id: String,
+ val name: String,
+ val channelCount: Int,
+)
+
+data class IptvChannel(
+ val streamId: Int,
+ val name: String,
+ val logoUrl: String? = null,
+ val number: Int? = null,
+ val categoryId: String? = null,
+)
+
+data class IptvPlayInfo(
+ val streamId: Int,
+ val name: String,
+ val logoUrl: String? = null,
+ val streamUrl: String,
+ val format: String,
+)
+
+data class EpgProgram(
+ val title: String,
+ val start: String? = null,
+ val end: String? = null,
+)
+
+data class IptvEpg(
+ val streamId: Int,
+ val channelName: String,
+ val logoUrl: String? = null,
+ val now: EpgProgram? = null,
+ val next: EpgProgram? = null,
+)
+
private fun JSONArray?.toItems(): List {
if (this == null) return emptyList()
return buildList {
diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt
new file mode 100644
index 0000000..ea02bec
--- /dev/null
+++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/LiveTvPlayerScreen.kt
@@ -0,0 +1,274 @@
+package nl.vonas.mediacluster.tv.ui
+
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MimeTypes
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.ui.AspectRatioFrameLayout
+import androidx.media3.ui.PlayerView
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import kotlinx.coroutines.delay
+import nl.vonas.mediacluster.tv.data.ApiClient
+import nl.vonas.mediacluster.tv.data.IptvEpg
+import nl.vonas.mediacluster.tv.playback.buildTvPlayer
+import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
+import nl.vonas.mediacluster.tv.ui.theme.McColors
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun LiveTvPlayerScreen(
+ api: ApiClient,
+ streamId: Int,
+ onBack: () -> Unit,
+) {
+ val context = LocalContext.current
+ val player = remember { buildTvPlayer(context) }
+ val focus = remember { FocusRequester() }
+
+ var loading by remember { mutableStateOf(true) }
+ var error by remember { mutableStateOf(null) }
+ var channelName by remember { mutableStateOf("") }
+ var logoUrl by remember { mutableStateOf(null) }
+ var epg by remember { mutableStateOf(null) }
+ var overlay by remember { mutableStateOf(true) }
+
+ LaunchedEffect(streamId) {
+ loading = true
+ error = null
+ try {
+ val play = api.iptvPlayUrl(streamId)
+ channelName = play.name
+ logoUrl = play.logoUrl
+ val mime = when {
+ play.format.equals("hls", true) || play.streamUrl.contains(".m3u8", true) ->
+ MimeTypes.APPLICATION_M3U8
+ else -> MimeTypes.VIDEO_MP2T
+ }
+ player.setMediaItem(
+ MediaItem.Builder()
+ .setUri(play.streamUrl)
+ .setMimeType(mime)
+ .build(),
+ )
+ player.prepare()
+ player.playWhenReady = true
+ runCatching { epg = api.iptvEpg(streamId) }
+ } catch (e: Exception) {
+ error = e.message ?: "Kon zender niet starten"
+ } finally {
+ loading = false
+ }
+ }
+
+ LaunchedEffect(overlay) {
+ if (overlay) {
+ delay(5000)
+ overlay = false
+ }
+ }
+
+ DisposableEffect(Unit) {
+ val listener = object : Player.Listener {
+ override fun onPlaybackStateChanged(state: Int) {
+ if (state == Player.STATE_READY) player.ensureAudioSelected()
+ }
+
+ override fun onPlayerError(e: PlaybackException) {
+ error = e.message ?: "Afspeelfout"
+ }
+ }
+ player.addListener(listener)
+ onDispose {
+ player.removeListener(listener)
+ player.release()
+ }
+ }
+
+ BackHandler { onBack() }
+
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ .focusRequester(focus)
+ .focusable()
+ .onPreviewKeyEvent { ev ->
+ if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+ when (ev.key) {
+ Key.Back, Key.Escape -> {
+ onBack()
+ true
+ }
+ Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> {
+ overlay = !overlay
+ true
+ }
+ else -> {
+ overlay = true
+ false
+ }
+ }
+ },
+ ) {
+ AndroidView(
+ factory = { ctx ->
+ PlayerView(ctx).apply {
+ layoutParams = FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ useController = false
+ resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
+ this.player = player
+ }
+ },
+ update = { it.player = player },
+ modifier = Modifier.fillMaxSize(),
+ )
+
+ LaunchedEffect(Unit) {
+ delay(200)
+ runCatching { focus.requestFocus() }
+ }
+
+ AnimatedVisibility(
+ visible = overlay && error == null,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ listOf(Color(0xCC000000), Color.Transparent, Color(0xCC000000)),
+ ),
+ ),
+ ) {
+ Row(
+ Modifier
+ .align(Alignment.TopStart)
+ .padding(32.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (!logoUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = logoUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .size(56.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color(0x22FFFFFF)),
+ contentScale = ContentScale.Fit,
+ )
+ }
+ Column {
+ Text("LIVE", color = McColors.Danger, fontSize = 12.sp, fontWeight = FontWeight.Bold)
+ Text(
+ channelName.ifBlank { "Live TV" },
+ color = Color.White,
+ fontSize = 28.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ epg?.let { info ->
+ Column(
+ Modifier
+ .align(Alignment.BottomStart)
+ .padding(32.dp),
+ ) {
+ info.now?.let { now ->
+ Text(
+ "Nu · ${now.title}",
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ info.next?.let { next ->
+ Text(
+ "Straks · ${next.title}",
+ color = McColors.TextDim,
+ fontSize = 15.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (loading) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Zender laden…", color = Color.White, fontSize = 18.sp)
+ }
+ }
+
+ if (error != null) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color(0xEE000000)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Kan zender niet afspelen", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
+ Text(error!!, color = McColors.Danger, fontSize = 15.sp)
+ Text("Druk op Terug om terug te gaan", color = McColors.TextDim, fontSize = 14.sp)
+ }
+ }
+ }
+ }
+}
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 c22232a..c989193 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
@@ -112,6 +112,7 @@ fun McNav() {
val s = seriesId?.ifBlank { null } ?: "-"
nav.navigate("player/$mediaFileId/$type/$m/$e/$s/$startMs")
},
+ onPlayLive = { streamId -> nav.navigate("live/$streamId") },
onLogout = {
scope.launch {
store.setToken(null)
@@ -190,6 +191,17 @@ fun McNav() {
onBack = { nav.popBackStack() },
)
}
+ composable(
+ "live/{streamId}",
+ arguments = listOf(navArgument("streamId") { type = NavType.IntType }),
+ ) { entry ->
+ val streamId = entry.arguments?.getInt("streamId") ?: return@composable
+ LiveTvPlayerScreen(
+ api = api,
+ streamId = streamId,
+ onBack = { nav.popBackStack() },
+ )
+ }
}
}
@@ -200,9 +212,11 @@ private fun MainHub(
onOpenSeries: (String) -> Unit,
onBrowse: (catalogId: String, title: String) -> Unit,
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
+ onPlayLive: (Int) -> Unit,
onLogout: () -> Unit,
) {
var tab by remember { mutableStateOf(MainTab.Home) }
+ var iptvEnabled by remember { mutableStateOf(false) }
var navFocused by remember { mutableStateOf(false) }
var exitConfirm by remember { mutableStateOf(false) }
val navFocus = remember { FocusRequester() }
@@ -217,6 +231,11 @@ private fun MainHub(
LaunchedEffect(Unit) {
runCatching { shelfPrefs = api.getShelfPrefs().sortedBy { it.sortOrder } }
+ runCatching { iptvEnabled = api.clientMe().iptvEnabled }
+ }
+
+ val visibleTabs = remember(iptvEnabled) {
+ MainTab.entries.filter { it != MainTab.Tv || iptvEnabled }
}
val movieSubs = remember(shelfPrefs) {
@@ -293,6 +312,10 @@ private fun MainHub(
tab = MainTab.Home
focusTopNav()
}
+ // TV: BrowseScreen-achtig — content scrollt terug, daarna top-nav
+ BackHandler(enabled = tab == MainTab.Tv && !navFocused && !exitConfirm) {
+ focusTopNav()
+ }
LaunchedEffect(navFocused) {
if (navFocused) {
@@ -306,7 +329,9 @@ private fun MainHub(
Box(Modifier.focusRequester(navFocus)) {
TopNavBar(
selected = tab,
+ visibleTabs = visibleTabs,
onSelect = {
+ if (it == MainTab.Tv && !iptvEnabled) return@TopNavBar
tab = it
exitConfirm = false
focusTopNav()
@@ -374,6 +399,12 @@ private fun MainHub(
onOpenMovie = onOpenMovie,
onOpenSeries = onOpenSeries,
)
+ MainTab.Tv -> TvScreen(
+ api = api,
+ onAtTopBack = { focusTopNav() },
+ onPlayChannel = onPlayLive,
+ onContentFocused = { navFocused = false },
+ )
MainTab.Search -> SearchScreen(
api = api,
onOpenMovie = onOpenMovie,
diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt
new file mode 100644
index 0000000..f01a9e8
--- /dev/null
+++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/TvScreen.kt
@@ -0,0 +1,384 @@
+package nl.vonas.mediacluster.tv.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+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.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.ClickableSurfaceDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import androidx.compose.runtime.rememberCoroutineScope
+import nl.vonas.mediacluster.tv.data.ApiClient
+import nl.vonas.mediacluster.tv.data.IptvCategory
+import nl.vonas.mediacluster.tv.data.IptvChannel
+import nl.vonas.mediacluster.tv.data.IptvEpg
+import nl.vonas.mediacluster.tv.ui.theme.McColors
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun TvScreen(
+ api: ApiClient,
+ onAtTopBack: () -> Unit,
+ onPlayChannel: (streamId: Int) -> Unit,
+ onContentFocused: () -> Unit = {},
+) {
+ var categories by remember { mutableStateOf>(emptyList()) }
+ var channels by remember { mutableStateOf>(emptyList()) }
+ var selectedCategoryId by remember { mutableStateOf(null) }
+ var loading by remember { mutableStateOf(true) }
+ var loadingChannels by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf(null) }
+ var focusedEpg by remember { mutableStateOf(null) }
+ var epgJob by remember { mutableStateOf(null) }
+ val scope = rememberCoroutineScope()
+
+ LaunchedEffect(Unit) {
+ loading = true
+ error = null
+ try {
+ val cats = api.iptvCategories()
+ categories = cats
+ selectedCategoryId = cats.firstOrNull()?.id
+ } catch (e: Exception) {
+ error = e.message ?: "Kon TV niet laden"
+ } finally {
+ loading = false
+ }
+ }
+
+ LaunchedEffect(selectedCategoryId) {
+ val catId = selectedCategoryId ?: return@LaunchedEffect
+ loadingChannels = true
+ focusedEpg = null
+ try {
+ channels = api.iptvChannels(catId)
+ } catch (e: Exception) {
+ error = e.message
+ channels = emptyList()
+ } finally {
+ loadingChannels = false
+ }
+ }
+
+ fun loadEpg(streamId: Int) {
+ epgJob?.cancel()
+ epgJob = scope.launch {
+ delay(280)
+ runCatching { focusedEpg = api.iptvEpg(streamId) }
+ }
+ }
+
+ val selectedCategory = categories.firstOrNull { it.id == selectedCategoryId }
+
+ Box(Modifier.fillMaxSize().background(McColors.Bg)) {
+ when {
+ loading -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("TV laden…", color = McColors.TextDim, fontSize = 18.sp)
+ }
+ }
+ error != null && categories.isEmpty() -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("TV niet beschikbaar", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
+ Text(error!!, color = McColors.Danger, fontSize = 15.sp)
+ }
+ }
+ }
+ else -> {
+ Row(Modifier.fillMaxSize()) {
+ CategorySidebar(
+ categories = categories,
+ selectedId = selectedCategoryId,
+ onSelect = { selectedCategoryId = it },
+ onFocused = onContentFocused,
+ )
+ Column(
+ Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .padding(start = 8.dp, end = 40.dp, top = 8.dp, bottom = 16.dp),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().padding(bottom = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column {
+ Text(
+ selectedCategory?.name ?: "Live TV",
+ color = Color.White,
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ when {
+ loadingChannels -> "Zenders laden…"
+ else -> "${channels.size} zenders"
+ },
+ color = McColors.TextDim,
+ fontSize = 14.sp,
+ )
+ }
+ }
+
+ if (channels.isEmpty() && !loadingChannels) {
+ Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
+ Text("Geen zenders in deze categorie", color = McColors.TextDim, fontSize = 16.sp)
+ }
+ } else {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(5),
+ modifier = Modifier.weight(1f),
+ contentPadding = PaddingValues(vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(channels, key = { it.streamId }) { ch ->
+ ChannelTile(
+ channel = ch,
+ onClick = { onPlayChannel(ch.streamId) },
+ onFocused = {
+ onContentFocused()
+ loadEpg(ch.streamId)
+ },
+ )
+ }
+ }
+ }
+
+ EpgBar(epg = focusedEpg)
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun CategorySidebar(
+ categories: List,
+ selectedId: String?,
+ onSelect: (String) -> Unit,
+ onFocused: () -> Unit,
+) {
+ LazyColumn(
+ modifier = Modifier
+ .width(260.dp)
+ .fillMaxHeight()
+ .background(Color(0xFF121212))
+ .padding(vertical = 12.dp),
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ item {
+ Text(
+ "Categorieën",
+ color = McColors.TextDim,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(start = 8.dp, bottom = 8.dp),
+ )
+ }
+ items(categories, key = { it.id }) { cat ->
+ val active = cat.id == selectedId
+ Surface(
+ onClick = { onSelect(cat.id) },
+ modifier = Modifier.onFocusChanged { if (it.isFocused) onFocused() },
+ shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = if (active) Color(0x33FFFFFF) else Color.Transparent,
+ focusedContainerColor = Color(0x44FFFFFF),
+ contentColor = if (active) Color.White else Color(0xFFB0B0B0),
+ focusedContentColor = Color.White,
+ ),
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ cat.name,
+ color = Color.Unspecified,
+ fontSize = 15.sp,
+ fontWeight = if (active) FontWeight.Bold else FontWeight.Medium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ "${cat.channelCount}",
+ color = McColors.TextDim,
+ fontSize = 12.sp,
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun ChannelTile(
+ channel: IptvChannel,
+ onClick: () -> Unit,
+ onFocused: () -> Unit,
+) {
+ Surface(
+ onClick = onClick,
+ modifier = Modifier.onFocusChanged { if (it.isFocused) onFocused() },
+ shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = Color(0xFF1A1A1A),
+ focusedContainerColor = Color(0xFF2A2A2A),
+ contentColor = Color.White,
+ focusedContentColor = Color.White,
+ ),
+ ) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Box(
+ Modifier
+ .size(72.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color(0xFF0D0D0D)),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (!channel.logoUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = channel.logoUrl,
+ contentDescription = channel.name,
+ modifier = Modifier.fillMaxSize().padding(6.dp),
+ contentScale = ContentScale.Fit,
+ )
+ } else {
+ Text(
+ channel.name.take(2).uppercase(),
+ color = McColors.Brand,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+ channel.number?.let { num ->
+ Text(
+ "$num",
+ color = McColors.TextDim,
+ fontSize = 11.sp,
+ )
+ }
+ Text(
+ channel.name,
+ color = Color.White,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center,
+ lineHeight = 16.sp,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun EpgBar(epg: IptvEpg?) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .height(72.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(Color(0xFF161616))
+ .padding(horizontal = 20.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ if (epg == null) {
+ Text("Selecteer een zender voor programma-info", color = McColors.TextDim, fontSize = 14.sp)
+ return
+ }
+ if (!epg.logoUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = epg.logoUrl,
+ contentDescription = null,
+ modifier = Modifier.size(40.dp),
+ contentScale = ContentScale.Fit,
+ )
+ }
+ Column(Modifier.weight(1f)) {
+ Text(epg.channelName, color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1)
+ Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
+ epg.now?.let { now ->
+ Text(
+ "Nu: ${now.title}",
+ color = McColors.Brand,
+ fontSize = 14.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ epg.next?.let { next ->
+ Text(
+ "Straks: ${next.title}",
+ color = McColors.TextDim,
+ fontSize = 14.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (epg.now == null && epg.next == null) {
+ Text("Geen programma-info", color = McColors.TextDim, fontSize = 14.sp)
+ }
+ }
+ }
+ }
+}
diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt
index 609f7c4..7360f14 100644
--- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt
+++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/components/TopNav.kt
@@ -34,6 +34,7 @@ enum class MainTab(val label: String) {
Home("Home"),
Movies("Films"),
Series("Series"),
+ Tv("TV"),
Search("Zoeken"),
Settings("Instellingen"),
}
@@ -50,6 +51,7 @@ data class SubNavItem(
fun TopNavBar(
selected: MainTab,
onSelect: (MainTab) -> Unit,
+ visibleTabs: List = MainTab.entries,
subItems: List = emptyList(),
selectedSubId: String? = null,
onSelectSub: (SubNavItem) -> Unit = {},
@@ -70,7 +72,7 @@ fun TopNavBar(
fontWeight = FontWeight.Black,
modifier = Modifier.padding(end = 16.dp),
)
- MainTab.entries.forEach { tab ->
+ visibleTabs.forEach { tab ->
val active = selected == tab
val color by animateColorAsState(
if (active) Color.White else Color(0xFF808080),
diff --git a/apps/master-api/prisma/migrations/20260901180000_viewer_iptv_access/migration.sql b/apps/master-api/prisma/migrations/20260901180000_viewer_iptv_access/migration.sql
new file mode 100644
index 0000000..83541bb
--- /dev/null
+++ b/apps/master-api/prisma/migrations/20260901180000_viewer_iptv_access/migration.sql
@@ -0,0 +1,11 @@
+ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "iptv_access" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "iptv_addon_token_id" TEXT;
+
+DO $$ BEGIN
+ ALTER TABLE "viewer_users"
+ ADD CONSTRAINT "viewer_users_iptv_addon_token_id_fkey"
+ FOREIGN KEY ("iptv_addon_token_id") REFERENCES "addon_tokens"("id")
+ ON DELETE SET NULL ON UPDATE CASCADE;
+EXCEPTION
+ WHEN duplicate_object THEN NULL;
+END $$;
diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma
index a1995be..cb185c4 100644
--- a/apps/master-api/prisma/schema.prisma
+++ b/apps/master-api/prisma/schema.prisma
@@ -74,6 +74,9 @@ model ViewerUser {
name String?
enabled Boolean @default(true)
appAccess Boolean @default(true) @map("app_access")
+ /** Live TV in Android-app (Xtream via gekoppelde addon-token). */
+ iptvAccess Boolean @default(false) @map("iptv_access")
+ iptvAddonTokenId String? @map("iptv_addon_token_id")
/** Google-account voor Play Closed Testing; fallback = email */
googlePlayEmail String? @map("google_play_email")
/** Gewenste Play-distributietoegang (sync met Google Group) */
@@ -90,6 +93,7 @@ model ViewerUser {
shelfPrefs ViewerShelfPreference[]
linkCodes DeviceLinkCode[]
googlePlayAuditLogs GooglePlayAccessAuditLog[]
+ iptvAddonToken AddonToken? @relation(fields: [iptvAddonTokenId], references: [id], onDelete: SetNull)
@@map("viewer_users")
}
@@ -197,6 +201,7 @@ model AddonToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
iptvLine IptvLine?
playbackSessions PlaybackSession[]
+ iptvViewers ViewerUser[]
@@map("addon_tokens")
}
diff --git a/apps/master-api/src/viewer/iptv-client.ts b/apps/master-api/src/viewer/iptv-client.ts
new file mode 100644
index 0000000..aa8ef00
--- /dev/null
+++ b/apps/master-api/src/viewer/iptv-client.ts
@@ -0,0 +1,200 @@
+import { prisma } from "../database/client";
+import type { Config } from "../config";
+import { AppError } from "../security/errors";
+import { loadXtreamForToken } from "../iptv/service";
+import {
+ decodeEpgText,
+ epgToIso,
+ type XtreamLiveStream,
+} from "../iptv/xtream";
+
+/** Maak Xtream-categorienamen leesbaarder voor TV-gebruikers. */
+export function friendlyCategoryName(raw: string): string {
+ let s = raw.trim();
+ s = s.replace(/^[A-Z]{2,3}\s*[|·:]\s*/i, "");
+ s = s.replace(/^[\s★☆●▪•\-–—]+/, "");
+ return s.trim() || raw.trim() || "Overig";
+}
+
+export async function resolveViewerIptvToken(viewerId: string): Promise {
+ const viewer = await prisma.viewerUser.findUnique({
+ where: { id: viewerId },
+ select: { iptvAccess: true, iptvAddonTokenId: true, enabled: true, appAccess: true },
+ });
+ if (!viewer?.enabled || !viewer.appAccess || !viewer.iptvAccess) return null;
+
+ if (viewer.iptvAddonTokenId) {
+ const linked = await prisma.addonToken.findFirst({
+ where: {
+ id: viewer.iptvAddonTokenId,
+ revoked: false,
+ iptvLine: { enableLive: true, lastError: null },
+ },
+ select: { id: true },
+ });
+ if (linked) return linked.id;
+ }
+
+ const fallback = await prisma.iptvLine.findFirst({
+ where: {
+ enableLive: true,
+ lastError: null,
+ addonToken: { revoked: false },
+ },
+ select: { addonTokenId: true },
+ orderBy: { updatedAt: "desc" },
+ });
+ return fallback?.addonTokenId ?? null;
+}
+
+export async function getViewerIptvStatus(viewerId: string, config: Config) {
+ const tokenId = await resolveViewerIptvToken(viewerId);
+ if (!tokenId) return { enabled: false as const };
+
+ const xt = await loadXtreamForToken(tokenId, config);
+ if (!xt?.line.enableLive) return { enabled: false as const };
+
+ const cats = await xt.client.getLiveCategories().catch(() => []);
+ return {
+ enabled: true as const,
+ categoryCount: cats.length,
+ };
+}
+
+export async function getViewerIptvCategories(viewerId: string, config: Config) {
+ const tokenId = await resolveViewerIptvToken(viewerId);
+ if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
+
+ const xt = await loadXtreamForToken(tokenId, config);
+ if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
+
+ const cats = await xt.client.getLiveCategories().catch(() => []);
+ const streams = await xt.client.getLiveStreams().catch(() => []);
+
+ const counts = new Map();
+ for (const s of streams) {
+ const key = String(s.category_id ?? "");
+ counts.set(key, (counts.get(key) ?? 0) + 1);
+ }
+
+ const categories = cats
+ .map((c) => ({
+ id: String(c.category_id),
+ name: friendlyCategoryName(c.category_name),
+ rawName: c.category_name,
+ channelCount: counts.get(String(c.category_id)) ?? 0,
+ }))
+ .filter((c) => c.channelCount > 0)
+ .sort((a, b) => a.name.localeCompare(b.name, "nl"));
+
+ if (streams.length > 0) {
+ categories.unshift({
+ id: "__all__",
+ name: "Alle zenders",
+ rawName: "Alle zenders",
+ channelCount: streams.length,
+ });
+ }
+
+ return { categories };
+}
+
+function sortChannels(streams: XtreamLiveStream[]): XtreamLiveStream[] {
+ return [...streams].sort((a, b) => (a.num ?? a.stream_id) - (b.num ?? b.stream_id));
+}
+
+export async function getViewerIptvChannels(
+ viewerId: string,
+ config: Config,
+ categoryId?: string
+) {
+ const tokenId = await resolveViewerIptvToken(viewerId);
+ if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
+
+ const xt = await loadXtreamForToken(tokenId, config);
+ if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
+
+ let streams =
+ !categoryId || categoryId === "__all__"
+ ? await xt.client.getLiveStreams()
+ : await xt.client.getLiveStreams(categoryId);
+ streams = sortChannels(streams);
+
+ return {
+ channels: streams.map((s) => ({
+ streamId: s.stream_id,
+ name: s.name.trim(),
+ logoUrl: s.stream_icon || null,
+ number: s.num ?? null,
+ categoryId: s.category_id ? String(s.category_id) : null,
+ })),
+ };
+}
+
+export async function getViewerIptvPlayUrl(
+ viewerId: string,
+ config: Config,
+ streamId: number
+) {
+ const tokenId = await resolveViewerIptvToken(viewerId);
+ if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
+
+ const xt = await loadXtreamForToken(tokenId, config);
+ if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
+
+ const streams = await xt.client.getLiveStreams();
+ const ch = streams.find((s) => s.stream_id === streamId);
+ if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
+
+ const resolved = await xt.client.resolveLivePlayUrls(streamId);
+ const url = resolved.hls || resolved.ts || resolved.classic[0];
+ if (!url) throw new AppError("UNAVAILABLE", "Geen afspeel-URL voor deze zender", 503);
+
+ return {
+ streamId,
+ name: ch.name,
+ logoUrl: ch.stream_icon || null,
+ streamUrl: url,
+ format: url.includes(".m3u8") || url.includes("m3u8") ? "hls" : "ts",
+ };
+}
+
+export async function getViewerIptvEpg(viewerId: string, config: Config, streamId: number) {
+ const tokenId = await resolveViewerIptvToken(viewerId);
+ if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
+
+ const xt = await loadXtreamForToken(tokenId, config);
+ if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
+
+ const streams = await xt.client.getLiveStreams();
+ const ch = streams.find((s) => s.stream_id === streamId);
+ if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
+
+ const epg = await xt.client.getShortEpg(streamId, 8);
+ const now = Date.now();
+ let nowProgram: { title: string; start: string; end: string | null } | null = null;
+ let nextProgram: { title: string; start: string; end: string | null } | null = null;
+
+ for (const listing of epg) {
+ const start = epgToIso(listing, "start");
+ const end = epgToIso(listing, "end");
+ if (!start) continue;
+ const title = decodeEpgText(listing.title) || "Programma";
+ const startMs = Date.parse(start);
+ const endMs = end ? Date.parse(end) : startMs + 30 * 60_000;
+
+ if (startMs <= now && now < endMs) {
+ nowProgram = { title, start, end: end ?? null };
+ } else if (startMs > now && !nextProgram) {
+ nextProgram = { title, start, end: end ?? null };
+ }
+ }
+
+ return {
+ streamId,
+ channelName: ch.name,
+ logoUrl: ch.stream_icon || null,
+ now: nowProgram,
+ next: nextProgram,
+ };
+}
diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts
index 8e3cf88..2c002b5 100644
--- a/apps/master-api/src/viewer/routes.ts
+++ b/apps/master-api/src/viewer/routes.ts
@@ -44,6 +44,8 @@ export function registerViewerRoutes(
name?: string | null;
enabled?: boolean;
appAccess?: boolean;
+ iptvAccess?: boolean;
+ iptvAddonTokenId?: string | null;
password?: string;
};
const viewer = await viewers.updateViewer(id, body);
@@ -118,7 +120,13 @@ export function registerViewerRoutes(
// ——— Authenticated client ———
app.get("/api/v1/client/me", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
- return { viewer: { id: auth.viewerId, email: auth.email, name: auth.name }, deviceId: auth.deviceId };
+ const { getViewerIptvStatus } = await import("./iptv-client");
+ const iptv = await getViewerIptvStatus(auth.viewerId, config);
+ return {
+ viewer: { id: auth.viewerId, email: auth.email, name: auth.name },
+ deviceId: auth.deviceId,
+ iptv: { enabled: iptv.enabled },
+ };
});
app.get("/api/v1/client/home", async (request) => {
@@ -218,6 +226,43 @@ export function registerViewerRoutes(
return { shelves: await viewers.setShelfPrefs(auth.viewerId, body.shelves) };
});
+ app.get("/api/v1/client/iptv/status", async (request) => {
+ const auth = await viewers.authFromBearer(request.headers.authorization);
+ const { getViewerIptvStatus } = await import("./iptv-client");
+ return getViewerIptvStatus(auth.viewerId, config);
+ });
+
+ app.get("/api/v1/client/iptv/categories", async (request) => {
+ const auth = await viewers.authFromBearer(request.headers.authorization);
+ const { getViewerIptvCategories } = await import("./iptv-client");
+ return getViewerIptvCategories(auth.viewerId, config);
+ });
+
+ app.get("/api/v1/client/iptv/channels", async (request) => {
+ const auth = await viewers.authFromBearer(request.headers.authorization);
+ const q = request.query as { categoryId?: string };
+ const { getViewerIptvChannels } = await import("./iptv-client");
+ return getViewerIptvChannels(auth.viewerId, config, q.categoryId);
+ });
+
+ app.get("/api/v1/client/iptv/channels/:streamId/play", async (request) => {
+ const auth = await viewers.authFromBearer(request.headers.authorization);
+ const { streamId } = request.params as { streamId: string };
+ const id = parseInt(streamId, 10);
+ if (!Number.isFinite(id)) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400);
+ const { getViewerIptvPlayUrl } = await import("./iptv-client");
+ return getViewerIptvPlayUrl(auth.viewerId, config, id);
+ });
+
+ app.get("/api/v1/client/iptv/channels/:streamId/epg", async (request) => {
+ const auth = await viewers.authFromBearer(request.headers.authorization);
+ const { streamId } = request.params as { streamId: string };
+ const id = parseInt(streamId, 10);
+ if (!Number.isFinite(id)) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400);
+ const { getViewerIptvEpg } = await import("./iptv-client");
+ return getViewerIptvEpg(auth.viewerId, config, id);
+ });
+
app.get("/api/v1/client/subtitles/search", async (request) => {
await viewers.authFromBearer(request.headers.authorization);
const q = request.query as { mediaFileId?: string; languages?: string };
diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts
index f7a6f6a..04afa44 100644
--- a/apps/master-api/src/viewer/service.ts
+++ b/apps/master-api/src/viewer/service.ts
@@ -102,6 +102,8 @@ export class ViewerService {
name: v.name,
enabled: v.enabled,
appAccess: v.appAccess,
+ iptvAccess: v.iptvAccess,
+ iptvAddonTokenId: v.iptvAddonTokenId,
googlePlayEmail: v.googlePlayEmail,
googlePlayAccess: v.googlePlayAccess,
googlePlayMembershipId: v.googlePlayMembershipId,
@@ -121,6 +123,8 @@ export class ViewerService {
name?: string | null;
enabled?: boolean;
appAccess?: boolean;
+ iptvAccess?: boolean;
+ iptvAddonTokenId?: string | null;
password?: string;
}
) {
@@ -128,11 +132,17 @@ export class ViewerService {
name?: string | null;
enabled?: boolean;
appAccess?: boolean;
+ iptvAccess?: boolean;
+ iptvAddonTokenId?: string | null;
passwordHash?: string;
} = {};
if (data.name !== undefined) patch.name = data.name?.trim() || null;
if (data.enabled !== undefined) patch.enabled = data.enabled;
if (data.appAccess !== undefined) patch.appAccess = data.appAccess;
+ if (data.iptvAccess !== undefined) patch.iptvAccess = data.iptvAccess;
+ if (data.iptvAddonTokenId !== undefined) {
+ patch.iptvAddonTokenId = data.iptvAddonTokenId?.trim() || null;
+ }
if (data.password?.trim()) {
if (data.password.length < 8) {
throw new AppError("INVALID_REQUEST", "Wachtwoord minstens 8 tekens", 400);
@@ -148,6 +158,8 @@ export class ViewerService {
name: true,
enabled: true,
appAccess: true,
+ iptvAccess: true,
+ iptvAddonTokenId: true,
createdAt: true,
},
});