Polish TV Live Events focus/playback and add per-viewer provider access in admin/API.

Improves Android TV Events navigation and player UX, and gates schedule streams by viewer Live Events ACL plus prisma migration.
This commit is contained in:
Jos Vooges | STH 2026-09-19 00:39:03 +02:00
parent ac951617cb
commit cbacf1af41
14 changed files with 1540 additions and 184 deletions

View file

@ -164,6 +164,10 @@ export default function SettingsPage() {
const [tvLiveEventsUrl, setTvLiveEventsUrl] = useState(
"http://epg.faabserver.nl/api/export/live-events?horizon_days=3"
);
/** Providers die uit staan (denylist). Ontbrekende = aan. */
const [tvDisabledProviders, setTvDisabledProviders] = useState<string[]>([]);
/** Ontdekt uit feed + bekende disabled. */
const [tvLiveEventProviders, setTvLiveEventProviders] = useState<string[]>([]);
const [tvShowAlle, setTvShowAlle] = useState(true);
const [tvCategories, setTvCategories] = useState<
Array<{ id: string; name: string; channelCount: number }>
@ -262,8 +266,18 @@ export default function SettingsPage() {
? s.liveEventsUrl
: "http://epg.faabserver.nl/api/export/live-events?horizon_days=3"
);
setTvDisabledProviders(
Array.isArray(s.liveEventsDisabledProviders)
? s.liveEventsDisabledProviders.map((p: string) => String(p).toLowerCase())
: []
);
}
setTvCategories(Array.isArray(d.categories) ? d.categories : []);
setTvLiveEventProviders(
Array.isArray(d.liveEventProviders)
? d.liveEventProviders.map((p: string) => String(p).toLowerCase())
: []
);
})
.catch(() => undefined);
}, []);
@ -568,6 +582,7 @@ export default function SettingsPage() {
categoryOrder: tvCategories.map((c) => c.id),
showAlleZenders: tvShowAlle,
liveEventsUrl: tvLiveEventsUrl.trim(),
liveEventsDisabledProviders: tvDisabledProviders,
}),
});
const data = await res.json().catch(() => ({}));
@ -586,8 +601,18 @@ export default function SettingsPage() {
? data.settings.liveEventsUrl
: ""
);
setTvDisabledProviders(
Array.isArray(data.settings.liveEventsDisabledProviders)
? data.settings.liveEventsDisabledProviders.map((p: string) => String(p).toLowerCase())
: []
);
}
if (Array.isArray(data.categories)) setTvCategories(data.categories);
if (Array.isArray(data.liveEventProviders)) {
setTvLiveEventProviders(
data.liveEventProviders.map((p: string) => String(p).toLowerCase())
);
}
} finally {
setTvBusy(false);
}
@ -936,7 +961,7 @@ export default function SettingsPage() {
JSON-export (FAAB) voor de Events-tab in Android TV. Leeg laten schakelt de tab uit.
Default: FAAB live-events met 3 dagen horizon.
</p>
<label style={{ display: "block" }}>
<label style={{ display: "block", marginBottom: "0.75rem" }}>
Feed-URL
<input
type="url"
@ -947,6 +972,45 @@ export default function SettingsPage() {
autoComplete="off"
/>
</label>
<h4 style={{ fontSize: "0.95rem", margin: "0.5rem 0 0.35rem" }}>
Providers (cluster-breed)
</h4>
<p className="muted" style={{ marginBottom: "0.5rem" }}>
Vink aan welke providers zichtbaar zijn. Nieuwe providers die nog niet in de lijst
stonden blijven standaard aan, zodat de feed niet stilvalt.
</p>
{tvLiveEventProviders.length === 0 ? (
<p className="muted">
Nog geen providers ontdekt — controleer de feed-URL en sla op / herlaad.
</p>
) : (
<div className="check-rows">
{tvLiveEventProviders.map((p) => {
const enabled = !tvDisabledProviders.includes(p);
return (
<label
key={p}
className="check-row"
style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}
>
<input
type="checkbox"
checked={enabled}
onChange={(e) => {
setTvDisabledProviders((prev) => {
if (e.target.checked) {
return prev.filter((x) => x !== p);
}
return prev.includes(p) ? prev : [...prev, p];
});
}}
/>
<span style={{ fontFamily: "monospace", fontSize: "0.9rem" }}>{p}</span>
</label>
);
})}
</div>
)}
</div>
<div className="card">

View file

@ -56,6 +56,9 @@ type Viewer = {
enabled: boolean;
appAccess: boolean;
iptvAccess: boolean;
liveEventsEnabled: boolean;
/** null = alle globaal-enabled providers */
liveEventsProviders: string[] | null;
iptvAddonTokenId: string | null;
liveListIds?: string[];
liveLists?: Array<{ id: string; name: string; enabled: boolean }>;
@ -179,6 +182,15 @@ export default function ViewersPage() {
const [editEmails, setEditEmails] = useState<Record<string, string>>({});
const [allLiveLists, setAllLiveLists] = useState<LiveListOption[]>([]);
const [liveListDrafts, setLiveListDrafts] = useState<Record<string, string[]>>({});
/** Globaal beschikbare providers (minus disabled) voor per-user multi-select. */
const [globalEventProviders, setGlobalEventProviders] = useState<string[]>([]);
/**
* Draft per viewer: null = alle providers; string[] = subset.
* undefined in map = nog niet geïnitialiseerd → val terug op viewer.liveEventsProviders.
*/
const [eventProviderDrafts, setEventProviderDrafts] = useState<
Record<string, string[] | null>
>({});
const load = useCallback(() => {
fetch("/api/v1/admin/viewers", { credentials: "include" })
@ -200,6 +212,15 @@ export default function ViewersPage() {
}
return next;
});
setEventProviderDrafts((prev) => {
const next = { ...prev };
for (const v of list) {
if (next[v.id] === undefined) {
next[v.id] = v.liveEventsProviders ?? null;
}
}
return next;
});
})
.catch(() => setErr("Kon gebruikers niet laden"));
}, []);
@ -214,6 +235,20 @@ export default function ViewersPage() {
.then((r) => r.json())
.then((d) => setAllLiveLists(d.lists ?? []))
.catch(() => undefined);
fetch("/api/v1/admin/settings/iptv", { credentials: "include" })
.then((r) => r.json())
.then((d) => {
const all: string[] = Array.isArray(d.liveEventProviders)
? d.liveEventProviders.map((p: string) => String(p).toLowerCase())
: [];
const disabled = new Set(
Array.isArray(d.settings?.liveEventsDisabledProviders)
? d.settings.liveEventsDisabledProviders.map((p: string) => String(p).toLowerCase())
: []
);
setGlobalEventProviders(all.filter((p) => !disabled.has(p)));
})
.catch(() => undefined);
}, [load]);
// Live updates via SSE (device link, login lock, CRUD). Geen interval-poll.
@ -339,15 +374,20 @@ export default function ViewersPage() {
}
}
async function toggle(v: Viewer, field: "enabled" | "appAccess" | "iptvAccess") {
async function toggle(
v: Viewer,
field: "enabled" | "appAccess" | "iptvAccess" | "liveEventsEnabled"
) {
setActionId(v.id);
setErr(null);
try {
const current =
field === "liveEventsEnabled" ? v.liveEventsEnabled !== false : Boolean(v[field]);
const res = await fetch(`/api/v1/admin/viewers/${v.id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [field]: !v[field] }),
body: JSON.stringify({ [field]: !current }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@ -359,6 +399,63 @@ export default function ViewersPage() {
}
}
function toggleEventProviderDraft(viewerId: string, provider: string) {
setEventProviderDrafts((prev) => {
const cur = prev[viewerId];
// null = all → start from full global list then toggle off
const base =
cur === null || cur === undefined
? [...globalEventProviders]
: [...cur];
const next = base.includes(provider)
? base.filter((p) => p !== provider)
: [...base, provider];
// Empty allowlist is stored as "all" — use Live Events toggle for none
if (next.length === 0) {
return prev;
}
// Selecting all again → store null (all)
if (
next.length === globalEventProviders.length &&
globalEventProviders.every((p) => next.includes(p))
) {
return { ...prev, [viewerId]: null };
}
return { ...prev, [viewerId]: next };
});
}
async function saveEventProviders(v: Viewer) {
setActionId(v.id);
setMsg(null);
setErr(null);
try {
const draft =
eventProviderDrafts[v.id] !== undefined
? eventProviderDrafts[v.id]
: v.liveEventsProviders ?? null;
const res = await fetch(`/api/v1/admin/viewers/${v.id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ liveEventsProviders: draft }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Providers opslaan mislukt");
return;
}
setEventProviderDrafts((prev) => ({
...prev,
[v.id]: data.viewer?.liveEventsProviders ?? draft,
}));
setMsg("Live Events-providers opgeslagen");
load();
} finally {
setActionId(null);
}
}
async function saveIptv(v: Viewer) {
const form = iptvForms[v.id] ?? emptyIptvForm();
setActionId(v.id);
@ -1229,6 +1326,21 @@ export default function ViewersPage() {
<span />
</button>
</div>
<div className="toggle-row">
<div>
<strong>Live Events</strong>
<span className="muted">Events-tab in Android TV</span>
</div>
<button
type="button"
className={`switch ${selected.liveEventsEnabled !== false ? "on" : ""}`}
disabled={actionId === selected.id || !selected.appAccess}
onClick={() => void toggle(selected, "liveEventsEnabled")}
aria-pressed={selected.liveEventsEnabled !== false}
>
<span />
</button>
</div>
</div>
<div className="detail-actions">
<button
@ -1409,6 +1521,77 @@ export default function ViewersPage() {
</div>
</article>
<article className="block">
<header className="block-head">
<h3>Live Events-providers</h3>
</header>
<p className="muted detail-copy">
Beperk welke providers deze gebruiker ziet. Alles aangevinkt (of knop
&quot;Alle providers&quot;) = alle globaal ingeschakelde providers.
Cluster-breed uitgeschakelde providers staan hier niet. Voor geen
events: zet Live Events hierboven uit.
</p>
{globalEventProviders.length === 0 ? (
<p className="muted">
Nog geen providers — configureer de Live Events-feed onder{" "}
<Link href="/settings">Instellingen → TV</Link>.
</p>
) : (
<div className="check-rows">
{globalEventProviders.map((p) => {
const draft =
eventProviderDrafts[selected.id] !== undefined
? eventProviderDrafts[selected.id]
: selected.liveEventsProviders ?? null;
const checked = draft === null || (draft?.includes(p) ?? false);
return (
<label
key={p}
className="field"
style={{ display: "flex", gap: 8, alignItems: "center" }}
>
<input
type="checkbox"
checked={checked}
disabled={
actionId === selected.id ||
selected.liveEventsEnabled === false
}
onChange={() => toggleEventProviderDraft(selected.id, p)}
/>
<span style={{ fontFamily: "monospace", fontSize: "0.9rem" }}>
{p}
</span>
</label>
);
})}
</div>
)}
<div className="detail-actions">
<button
type="button"
className="btn-primary"
disabled={
actionId === selected.id ||
globalEventProviders.length === 0 ||
selected.liveEventsEnabled === false
}
onClick={() => void saveEventProviders(selected)}
>
Providers opslaan
</button>
<button
type="button"
disabled={actionId === selected.id}
onClick={() =>
setEventProviderDrafts((prev) => ({ ...prev, [selected.id]: null }))
}
>
Alle providers
</button>
</div>
</article>
<article className="block">
<header className="block-head">
<h3>Stremio-addon</h3>

View file

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

View file

@ -426,8 +426,10 @@ private fun hexToBytes(hex: String): ByteArray {
.replace("-", "")
.replace(Regex("[\\s:]"), "")
.lowercase()
require(clean.length == 32 && clean.all { it in "0123456789abcdef" }) {
"ongeldige ClearKey hex (verwacht 32 tekens): ${hex.take(20)}"
if (clean.length != 32 || clean.any { it !in "0123456789abcdef" }) {
throw IllegalArgumentException(
"ongeldige ClearKey hex (verwacht 32 tekens): ${hex.take(20)}",
)
}
return ByteArray(16) { i ->
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()

View file

@ -4,6 +4,7 @@ import android.view.ViewGroup
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@ -17,7 +18,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@ -26,15 +34,22 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
import nl.vonas.mediacluster.tv.playback.liveMediaItem
import nl.vonas.mediacluster.tv.ui.theme.McColors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
private const val ReadyTimeoutMs = 20_000L
@OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
@ -45,28 +60,78 @@ fun EventPlayerScreen(
onBack: () -> Unit,
) {
val context = LocalContext.current
val player = remember { buildLiveTvPlayer(context) }
val playerRef = remember { AtomicReference<ExoPlayer?>(null) }
val player = remember {
buildLiveTvPlayer(context).also { playerRef.set(it) }
}
val rootFocus = remember { FocusRequester() }
val reachedReady = remember { AtomicBoolean(false) }
var title by remember { mutableStateOf("Live event") }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var showChrome by remember { mutableStateOf(true) }
fun safeStopAndClear() {
runCatching {
player.playWhenReady = false
player.stop()
player.clearMediaItems()
}
}
LaunchedEffect(eventId) {
loading = true
error = null
reachedReady.set(false)
showChrome = true
try {
val play = api.scheduleEventPlay(eventId)
if (!isActive) return@LaunchedEffect
title = play.name
player.setMediaItem(liveMediaItem(play.streamUrl, play.format, play.drm))
val url = play.streamUrl.trim()
if (url.isEmpty()) {
error = "Geen stream-URL beschikbaar"
return@LaunchedEffect
}
if (play.drm == null || play.drm.keys.isEmpty()) {
error = "Encryptiesleutels ontbreken — stream start binnenkort"
return@LaunchedEffect
}
val item = try {
liveMediaItem(url, play.format, play.drm)
} catch (e: Exception) {
error = e.message ?: "Ongeldige DRM-sleutels"
return@LaunchedEffect
}
if (!isActive) return@LaunchedEffect
try {
player.setMediaItem(item)
player.prepare()
player.playWhenReady = true
} catch (e: Exception) {
error = e.message ?: "Kon speler niet starten"
safeStopAndClear()
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
error = e.message ?: "Kon event niet afspelen"
safeStopAndClear()
} finally {
loading = false
}
}
// Geen eindeloze zwart-scherm hang: toon fout als READY uitblijft.
LaunchedEffect(eventId, error, loading) {
if (error != null || loading) return@LaunchedEffect
delay(ReadyTimeoutMs)
if (!reachedReady.get() && error == null) {
error = "Stream start niet (timeout). Druk op Terug."
safeStopAndClear()
}
}
LaunchedEffect(showChrome, error, loading) {
if (showChrome && error == null && !loading) {
delay(4_000)
@ -74,20 +139,45 @@ fun EventPlayerScreen(
}
}
LaunchedEffect(Unit) {
delay(80)
runCatching { rootFocus.requestFocus() }
}
DisposableEffect(Unit) {
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) player.ensureAudioSelected()
when (state) {
Player.STATE_READY -> {
reachedReady.set(true)
runCatching { player.ensureAudioSelected() }
}
else -> Unit
}
}
override fun onPlayerError(e: PlaybackException) {
error = e.message ?: "Afspeelfout"
val cause = generateSequence(e as Throwable?) { it.cause }
.mapNotNull { it.message?.takeIf { m -> m.isNotBlank() } }
.distinct()
.take(3)
.joinToString(" → ")
error = listOfNotNull(e.errorCodeName, cause.takeIf { it.isNotBlank() })
.joinToString(": ")
.ifBlank { e.message ?: "Afspeelfout" }
safeStopAndClear()
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
player.release()
runCatching {
player.playWhenReady = false
player.stop()
player.clearMediaItems()
}
runCatching { player.release() }
playerRef.compareAndSet(player, null)
}
}
@ -96,8 +186,21 @@ fun EventPlayerScreen(
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
.background(Color.Black)
.focusRequester(rootFocus)
.focusable()
.onPreviewKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown &&
(ev.key == Key.Back || ev.key == Key.Escape)
) {
onBack()
true
} else {
false
}
},
) {
if (error == null) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
@ -106,12 +209,15 @@ fun EventPlayerScreen(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
this.player = player
this.player = playerRef.get()
}
},
update = { it.player = player },
update = { view ->
view.player = playerRef.get()
},
modifier = Modifier.fillMaxSize(),
)
}
if (loading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -121,9 +227,24 @@ fun EventPlayerScreen(
if (error != null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Kan event niet afspelen", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 22.sp)
Text(error!!, color = McColors.Danger, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
Text("Druk op Terug", color = McColors.TextDim, fontSize = 14.sp, modifier = Modifier.padding(top = 12.dp))
Text(
"Kan event niet afspelen",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
)
Text(
error!!,
color = McColors.Danger,
fontSize = 15.sp,
modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp),
)
Text(
"Druk op Terug",
color = McColors.TextDim,
fontSize = 14.sp,
modifier = Modifier.padding(top = 12.dp),
)
}
}
} else if (showChrome) {

View file

@ -128,6 +128,9 @@ fun McNav() {
var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) }
var tvLastStreamId by rememberSaveable { mutableStateOf<String?>(null) }
var tvFocusNonce by remember { mutableIntStateOf(0) }
var eventsReturnNonce by remember { mutableIntStateOf(0) }
var lastOpenedEventId by rememberSaveable { mutableStateOf<String?>(null) }
var pendingEventsFocusRestore by remember { mutableStateOf(false) }
val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet())
val hubTab = remember(hubTabName) {
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
@ -321,6 +324,10 @@ fun McNav() {
onToggleFavorite = { id -> scope.launch { store.toggleFavorite(id) } },
tvFocusNonce = tvFocusNonce,
onTvFocusContent = { tvFocusNonce++ },
eventsReturnNonce = eventsReturnNonce,
restoreEventId = lastOpenedEventId,
pendingEventsFocusRestore = pendingEventsFocusRestore,
onEventsFocusRestoreConsumed = { pendingEventsFocusRestore = false },
profileAvatarId = profileAvatarId,
profileShowAvatar = profileShowAvatar,
profileAvatarUrl = profileAvatarUrl,
@ -342,10 +349,14 @@ fun McNav() {
nav.navigate("live/${android.net.Uri.encode(streamId)}")
},
onOpenEvent = { eventId ->
lastOpenedEventId = eventId
nav.navigate("event/${android.net.Uri.encode(eventId)}")
},
onPlayEvent = { eventId ->
nav.navigate("event_play/${android.net.Uri.encode(eventId)}")
lastOpenedEventId = eventId
nav.navigate("event_play/${android.net.Uri.encode(eventId)}") {
launchSingleTop = true
}
},
onSwitchProfile = {
scope.launch {
@ -476,12 +487,21 @@ fun McNav() {
val eventId = entry.arguments?.getString("eventId")
?.let { android.net.Uri.decode(it) }
?: return@composable
DisposableEffect(Unit) {
onDispose {
pendingEventsFocusRestore = true
eventsReturnNonce++
}
}
EventDetailScreen(
api = api,
eventId = eventId,
onBack = { nav.popBackStack() },
onPlay = { id ->
nav.navigate("event_play/${android.net.Uri.encode(id)}")
lastOpenedEventId = id
nav.navigate("event_play/${android.net.Uri.encode(id)}") {
launchSingleTop = true
}
},
)
}
@ -492,6 +512,12 @@ fun McNav() {
val eventId = entry.arguments?.getString("eventId")
?.let { android.net.Uri.decode(it) }
?: return@composable
DisposableEffect(Unit) {
onDispose {
pendingEventsFocusRestore = true
eventsReturnNonce++
}
}
EventPlayerScreen(
api = api,
eventId = eventId,
@ -519,6 +545,10 @@ private fun MainHub(
onToggleFavorite: (String) -> Unit,
tvFocusNonce: Int,
onTvFocusContent: () -> Unit,
eventsReturnNonce: Int = 0,
restoreEventId: String? = null,
pendingEventsFocusRestore: Boolean = false,
onEventsFocusRestoreConsumed: () -> Unit = {},
profileAvatarId: String?,
profileShowAvatar: Boolean = true,
profileAvatarUrl: String? = null,
@ -544,6 +574,7 @@ private fun MainHub(
var coldStartNavFocus by rememberSaveable { mutableStateOf(true) }
val navFocus = remember { FocusRequester() }
val homeContentFocus = remember { FocusRequester() }
val eventsContentFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() }
var exitReady by remember { mutableStateOf(false) }
var prevTabName by rememberSaveable { mutableStateOf<String?>(null) }
@ -733,10 +764,18 @@ private fun MainHub(
exitConfirm = false
homeChromeCompact = false
when (it) {
MainTab.Tv, MainTab.TvGuide, MainTab.Events -> {
MainTab.Tv, MainTab.TvGuide -> {
navFocused = false
onTvFocusContent()
}
MainTab.Events -> {
navFocused = false
onTvFocusContent()
hubScope.launch {
delay(120)
runCatching { eventsContentFocus.requestFocus() }
}
}
MainTab.Home -> {
navFocused = false
hubScope.launch {
@ -763,7 +802,11 @@ private fun MainHub(
else -> Unit
}
},
contentDownFocus = if (tab == MainTab.Home) homeContentFocus else null,
contentDownFocus = when (tab) {
MainTab.Home -> homeContentFocus
MainTab.Events -> eventsContentFocus
else -> null
},
subItems = subItems,
selectedSubId = selectedSubId,
focusSelectedToken = topNavFocusToken,
@ -870,6 +913,12 @@ private fun MainHub(
)
MainTab.Events -> EventsScreen(
api = api,
contentFocusRequester = eventsContentFocus,
navFocusRequester = navFocus,
focusRestoreNonce = eventsReturnNonce,
restoreEventId = restoreEventId,
pendingFocusRestore = pendingEventsFocusRestore,
onFocusRestoreConsumed = onEventsFocusRestoreConsumed,
onOpenEvent = onOpenEvent,
onPlayEvent = onPlayEvent,
onContentFocused = { navFocused = false },

View file

@ -0,0 +1,3 @@
-- Per-user Live Events toggle + optional provider allowlist (JSON text).
ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "live_events_enabled" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "live_events_providers" TEXT;

View file

@ -76,6 +76,13 @@ model ViewerUser {
appAccess Boolean @default(true) @map("app_access")
/** Live TV in de apps (Xtream-lijn op deze gebruiker). */
iptvAccess Boolean @default(false) @map("iptv_access")
/** Live Events-tab in apps (naast cluster-feed + globale provider-toggles). */
liveEventsEnabled Boolean @default(true) @map("live_events_enabled")
/**
* JSON-array van toegestane provider-ids (lowercase).
* null/leeg = alle globaal-ingeschakelde providers.
*/
liveEventsProviders String? @map("live_events_providers") @db.Text
/** @deprecated Legacy: IPTV via addon-token; voorkeur = iptvLine op deze user. */
iptvAddonTokenId String? @map("iptv_addon_token_id")
/** Google-account voor Play Closed Testing; fallback = email */

View file

@ -11,6 +11,11 @@ export type IptvSettingsPublic = {
showAlleZenders: boolean;
/** FAAB (of andere) live-events JSON feed; leeg = default FAAB-URL. */
liveEventsUrl: string;
/**
* Providers die cluster-breed uit staan.
* Nieuwe providers die hier niet in staan blijven aan (default ON).
*/
liveEventsDisabledProviders: string[];
};
type StoredJson = Partial<{
@ -18,6 +23,7 @@ type StoredJson = Partial<{
categoryOrder: unknown;
showAlleZenders: unknown;
liveEventsUrl: unknown;
liveEventsDisabledProviders: unknown;
}>;
type CacheEntry = { at: number; settings: IptvSettingsPublic };
@ -78,6 +84,22 @@ function normalizeIdList(raw: unknown): string[] {
return out;
}
/** Provider-ids: trim + lowercase, uniek, gesorteerd. */
export function normalizeProviderIdList(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const item of raw) {
const id = String(item ?? "")
.trim()
.toLowerCase();
if (!id || seen.has(id)) continue;
seen.add(id);
out.push(id);
}
return out.sort((a, b) => a.localeCompare(b, "nl"));
}
const DEFAULT_LIVE_EVENTS_URL =
"http://epg.faabserver.nl/api/export/live-events?horizon_days=3";
@ -100,6 +122,7 @@ function defaultsFromEnv(config: Config): IptvSettingsPublic {
categoryOrder: [],
showAlleZenders: true,
liveEventsUrl: DEFAULT_LIVE_EVENTS_URL,
liveEventsDisabledProviders: [],
};
}
@ -118,6 +141,9 @@ function fromStored(stored: StoredJson, config: Config): IptvSettingsPublic {
liveEventsUrl: hasLiveEventsUrl
? normalizeSingleUrl(stored.liveEventsUrl, "")
: base.liveEventsUrl,
liveEventsDisabledProviders: normalizeProviderIdList(
stored.liveEventsDisabledProviders
),
};
}
@ -149,6 +175,7 @@ export async function upsertIptvSettings(
categoryOrder?: unknown;
showAlleZenders?: unknown;
liveEventsUrl?: unknown;
liveEventsDisabledProviders?: unknown;
}
): Promise<IptvSettingsPublic> {
const current = await getIptvSettings(config);
@ -169,6 +196,10 @@ export async function upsertIptvSettings(
body.liveEventsUrl !== undefined
? normalizeSingleUrl(body.liveEventsUrl, "")
: current.liveEventsUrl,
liveEventsDisabledProviders:
body.liveEventsDisabledProviders !== undefined
? normalizeProviderIdList(body.liveEventsDisabledProviders)
: current.liveEventsDisabledProviders,
};
if (body.epgXmlUrls !== undefined) {

View file

@ -87,7 +87,20 @@ export function registerSettingsRoutes(
getIptvSettings(config),
listIptvCategoriesForAdmin(config),
]);
return { settings, categories };
let liveEventProviders: string[] = [];
try {
const { discoverScheduleEventProviders } = await import("../viewer/schedule-events");
liveEventProviders = await discoverScheduleEventProviders(config);
} catch {
liveEventProviders = [];
}
// Include known-but-disabled providers so admin can re-enable them
const known = new Set([
...liveEventProviders,
...settings.liveEventsDisabledProviders,
]);
liveEventProviders = [...known].sort((a, b) => a.localeCompare(b, "nl"));
return { settings, categories, liveEventProviders };
});
app.put("/api/v1/admin/settings/iptv", { preHandler: requireAdmin }, async (request) => {
@ -96,10 +109,23 @@ export function registerSettingsRoutes(
categoryOrder?: string[];
showAlleZenders?: boolean;
liveEventsUrl?: string;
liveEventsDisabledProviders?: string[];
};
const settings = await upsertIptvSettings(config, body);
const categories = await listIptvCategoriesForAdmin(config);
return { settings, categories };
let liveEventProviders: string[] = [];
try {
const { discoverScheduleEventProviders } = await import("../viewer/schedule-events");
liveEventProviders = await discoverScheduleEventProviders(config);
} catch {
liveEventProviders = [];
}
const known = new Set([
...liveEventProviders,
...settings.liveEventsDisabledProviders,
]);
liveEventProviders = [...known].sort((a, b) => a.localeCompare(b, "nl"));
return { settings, categories, liveEventProviders };
});
app.post("/api/v1/admin/settings/iptv/refresh-epg", { preHandler: requireAdmin }, async () => {

View file

@ -93,6 +93,8 @@ export function registerViewerRoutes(
enabled?: boolean;
appAccess?: boolean;
iptvAccess?: boolean;
liveEventsEnabled?: boolean;
liveEventsProviders?: string[] | null;
iptvAddonTokenId?: string | null;
password?: string;
};
@ -507,17 +509,17 @@ export function registerViewerRoutes(
app.get("/api/v1/client/me", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const { getViewerIptvStatus } = await import("./iptv-client");
const { eventsFeedEnabled } = await import("./schedule-events");
const [iptv, eventsEnabled] = await Promise.all([
const { resolveViewerLiveEventsAccess } = await import("./schedule-events");
const [iptv, eventsAccess] = await Promise.all([
getViewerIptvStatus(auth.viewerId, config),
eventsFeedEnabled(config),
resolveViewerLiveEventsAccess(auth.viewerId, config),
]);
return {
viewer: { id: auth.viewerId, email: auth.email, name: auth.name },
deviceId: auth.deviceId,
profile: auth.profile,
iptv: { enabled: iptv.enabled },
events: { enabled: eventsEnabled },
events: { enabled: eventsAccess.enabled },
};
});
@ -848,25 +850,28 @@ export function registerViewerRoutes(
});
app.get("/api/v1/client/events", async (request) => {
await viewers.authFromBearer(request.headers.authorization);
const { listScheduleEvents } = await import("./schedule-events");
return listScheduleEvents(config);
const auth = await viewers.authFromBearer(request.headers.authorization);
const { listScheduleEvents, resolveViewerLiveEventsAccess } = await import("./schedule-events");
const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
return listScheduleEvents(config, access);
});
app.get("/api/v1/client/events/:id", async (request) => {
await viewers.authFromBearer(request.headers.authorization);
const auth = await viewers.authFromBearer(request.headers.authorization);
const { id } = request.params as { id: string };
if (!id?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig event-id", 400);
const { getScheduleEvent } = await import("./schedule-events");
return { event: await getScheduleEvent(config, decodeURIComponent(id)) };
const { getScheduleEvent, resolveViewerLiveEventsAccess } = await import("./schedule-events");
const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
return { event: await getScheduleEvent(config, decodeURIComponent(id), access) };
});
app.get("/api/v1/client/events/:id/play", async (request) => {
await viewers.authFromBearer(request.headers.authorization);
const auth = await viewers.authFromBearer(request.headers.authorization);
const { id } = request.params as { id: string };
if (!id?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig event-id", 400);
const { getScheduleEventPlay } = await import("./schedule-events");
return getScheduleEventPlay(config, decodeURIComponent(id));
const { getScheduleEventPlay, resolveViewerLiveEventsAccess } = await import("./schedule-events");
const access = await resolveViewerLiveEventsAccess(auth.viewerId, config);
return getScheduleEventPlay(config, decodeURIComponent(id), access);
});
app.get("/api/v1/client/subtitles/search", async (request) => {

View file

@ -1,6 +1,7 @@
import type { Config } from "../config";
import { AppError } from "../security/errors";
import { getIptvSettings } from "../settings/iptv";
import { getIptvSettings, normalizeProviderIdList } from "../settings/iptv";
import { prisma } from "../database/client";
const CACHE_TTL_MS = 60_000;
const FETCH_TIMEOUT_MS = 20_000;
@ -32,6 +33,17 @@ export type ScheduleEventPublic = {
audioLanguages: string[];
};
/** Viewer + globale provider-filter voor list/detail/play. */
export type LiveEventsAccessFilter = {
/** false → 403 op client-endpoints */
enabled: boolean;
/**
* null/empty = alle globaal-ingeschakelde providers.
* Anders: subset (lowercase ids).
*/
allowedProviders: string[] | null;
};
type RawKid = { kid?: unknown; key?: unknown };
type RawEvent = {
id?: unknown;
@ -116,10 +128,18 @@ function pickImages(raw: RawEvent): string[] {
function scoreLandscape(url: string): number {
const u = url.toLowerCase();
let score = 0;
// Ziggo Sport FAAB live thumbs (akamaized …/live/…/thumb.jpg?scale=640x360)
if (u.includes("thumb")) score += 55;
if (u.includes("akamaized.net/live")) score += 50;
if (u.includes("scale=")) score += 35;
if (u.includes("640x360") || u.includes("scale=640")) score += 30;
if (u.includes("_l.") || u.includes("_l?")) score += 50;
if (u.includes("width=960") && u.includes("height=540")) score += 40;
if (u.includes("width=1280") || u.includes("height=720")) score += 30;
if (u.includes(".jpg") || u.includes(".jpeg") || u.includes(".webp")) score += 5;
// Channel logo PNGs are not landscape artwork
if (u.includes("assets/channels")) score -= 70;
if (u.includes("/channels/") && u.includes(".png")) score -= 50;
if (u.includes("logo")) score -= 40;
if (u.includes("_c.") || u.includes("_c?")) score -= 20;
if (u.includes(".png") && u.includes("logo")) score -= 50;
@ -131,7 +151,8 @@ function scorePortrait(url: string): number {
let score = 0;
if (u.includes("_c.") || u.includes("_c?")) score += 50;
if (u.includes("width=199") || u.includes("height=298")) score += 30;
if (u.includes("logo")) score -= 40;
if (u.includes("assets/channels") || u.includes("logo")) score -= 40;
if (u.includes("thumb") || u.includes("akamaized.net/live")) score -= 20;
if (u.includes("_l.")) score -= 10;
return score;
}
@ -139,13 +160,89 @@ function scorePortrait(url: string): number {
function scoreLogo(url: string): number {
const u = url.toLowerCase();
let score = 0;
if (u.includes("assets/channels")) score += 80;
if (u.includes("ziggo_sport")) score += 45;
if (u.includes("/channels/") && u.includes(".png")) score += 55;
if (u.includes("logo")) score += 60;
if (u.includes(".png")) score += 20;
if (u.includes("cropped")) score += 10;
// Live thumbs are not logos
if (u.includes("thumb") || u.includes("akamaized.net/live") || u.includes("scale=")) score -= 50;
if (u.includes("_l.") || u.includes("_c.")) score -= 30;
return score;
}
/** Viaplay labels like "Stream 10" / "Stream 22 (NEW)" — hide from public meta. */
function isStreamLabel(value: string | null | undefined): boolean {
const s = str(value);
if (!s) return false;
return /^stream\s*\d+/i.test(s);
}
function cleanChannelLabel(value: string | null | undefined): string | null {
const s = str(value);
if (!s || isStreamLabel(s)) return null;
return s;
}
function normalizeChannelKey(value: string | null | undefined): string {
return str(value).toLowerCase().replace(/\s+/g, " ");
}
function donorImageScore(e: ScheduleEventPublic): number {
let s = 0;
if (e.imageLandscape) s += 20;
if (e.images.some((u) => /thumb|akamaized\.net\/live|scale=/i.test(u))) s += 30;
if (e.logoUrl) s += 5;
if (e.images.length) s += Math.min(e.images.length, 5);
if (e.provider === "ziggosport") s += 8;
if (e.playable) s += 3;
return s;
}
/**
* Odido (and others) often publish the same channel without images while
* ziggosport/FAAB has thumbs for that channel. Fill gaps by channel/network key.
*/
function fillMissingImages(events: ScheduleEventPublic[]): ScheduleEventPublic[] {
const donors = new Map<string, ScheduleEventPublic>();
for (const e of events) {
if (!e.imageLandscape && !e.logoUrl && e.images.length === 0) continue;
const keys = [e.channel, e.network]
.map(normalizeChannelKey)
.filter((k) => k.length > 0);
for (const key of keys) {
const existing = donors.get(key);
if (!existing || donorImageScore(e) > donorImageScore(existing)) {
donors.set(key, e);
}
}
}
return events.map((e) => {
if (e.imageLandscape || e.images.length > 0) return e;
const keys = [e.channel, e.network]
.map(normalizeChannelKey)
.filter((k) => k.length > 0);
let donor: ScheduleEventPublic | undefined;
for (const key of keys) {
const hit = donors.get(key);
if (hit && hit.id !== e.id) {
donor = hit;
break;
}
}
if (!donor) return e;
return {
...e,
imageLandscape: e.imageLandscape || donor.imageLandscape,
imagePortrait: e.imagePortrait || donor.imagePortrait,
logoUrl: e.logoUrl || donor.logoUrl,
images: e.images.length ? e.images : [...donor.images],
};
});
}
function bestByScore(urls: string[], scorer: (u: string) => number): string | null {
let best: string | null = null;
let bestScore = -Infinity;
@ -231,6 +328,9 @@ function toPublic(raw: RawEvent, nowMs: number): ScheduleEventPublic | null {
const isLiveFlag = raw.is_live === true;
const phase = phaseFor(start, stop, isLiveFlag, nowMs);
const channel = cleanChannelLabel(str(raw.epg?.channel) || str(meta.zender));
const network = cleanChannelLabel(str(raw.epg?.network));
return {
id,
name,
@ -238,12 +338,12 @@ function toPublic(raw: RawEvent, nowMs: number): ScheduleEventPublic | null {
str(raw.description) ||
str(raw.epg?.description) ||
null,
provider: str(raw.provider) || "unknown",
provider: (str(raw.provider) || "unknown").toLowerCase(),
category: str(raw.category) || str(raw.epg?.category) || null,
eventType: str(meta.type) || null,
subtitle: str(meta["originele titel"]) || str(raw.epg?.subtitle) || null,
channel: str(raw.epg?.channel) || str(meta.zender) || null,
network: str(raw.epg?.network) || null,
channel,
network,
isLive: phase === "live",
phase,
playable: isPlayable(raw) && phase === "live",
@ -274,6 +374,78 @@ export async function eventsFeedEnabled(config: Config): Promise<boolean> {
return !!settings.liveEventsUrl?.trim();
}
/** Parse JSON allowlist; null/empty → null (= alle globaal-enabled). */
export function parseLiveEventsProvidersJson(raw: string | null | undefined): string[] | null {
if (!raw?.trim()) return null;
try {
const parsed = JSON.parse(raw) as unknown;
const list = normalizeProviderIdList(parsed);
return list.length ? list : null;
} catch {
return null;
}
}
export function serializeLiveEventsProviders(providers: string[] | null | undefined): string | null {
if (providers == null) return null;
const list = normalizeProviderIdList(providers);
return list.length ? JSON.stringify(list) : null;
}
export async function resolveViewerLiveEventsAccess(
viewerId: string,
config: Config
): Promise<LiveEventsAccessFilter> {
const feedOn = await eventsFeedEnabled(config);
const viewer = await prisma.viewerUser.findUnique({
where: { id: viewerId },
select: {
enabled: true,
appAccess: true,
liveEventsEnabled: true,
liveEventsProviders: true,
},
});
if (!viewer || !viewer.enabled || !viewer.appAccess || !viewer.liveEventsEnabled || !feedOn) {
return { enabled: false, allowedProviders: null };
}
return {
enabled: true,
allowedProviders: parseLiveEventsProvidersJson(viewer.liveEventsProviders),
};
}
async function globalDisabledProviders(config: Config): Promise<Set<string>> {
const settings = await getIptvSettings(config);
return new Set(settings.liveEventsDisabledProviders.map((p) => p.toLowerCase()));
}
function providerAllowed(
provider: string,
disabledGlobal: Set<string>,
userAllow: string[] | null
): boolean {
const p = provider.trim().toLowerCase() || "unknown";
if (disabledGlobal.has(p)) return false;
if (!userAllow || userAllow.length === 0) return true;
return userAllow.includes(p);
}
function assertAccessEnabled(access?: LiveEventsAccessFilter | null) {
if (access && !access.enabled) {
throw new AppError("FORBIDDEN", "Live Events is uitgeschakeld voor dit account", 403);
}
}
function filterByAccess(
events: ScheduleEventPublic[],
disabledGlobal: Set<string>,
access?: LiveEventsAccessFilter | null
): ScheduleEventPublic[] {
const userAllow = access?.allowedProviders ?? null;
return events.filter((e) => providerAllowed(e.provider, disabledGlobal, userAllow));
}
async function loadRawEvents(config: Config): Promise<CacheEntry> {
const url = await resolveLiveEventsUrl(config);
if (cache && cache.url === url && Date.now() - cache.at < CACHE_TTL_MS) {
@ -323,14 +495,36 @@ export function invalidateScheduleEventsCache() {
cache = null;
}
export async function listScheduleEvents(config: Config): Promise<{
/** Distinct providers uit de ruwe feed (incl. ended) — voor admin toggles. */
export async function discoverScheduleEventProviders(config: Config): Promise<string[]> {
try {
const raw = await loadRawEvents(config);
const seen = new Set<string>();
const out: string[] = [];
for (const e of raw.events) {
const p = (str(e.provider) || "unknown").toLowerCase();
if (seen.has(p)) continue;
seen.add(p);
out.push(p);
}
return out.sort((a, b) => a.localeCompare(b, "nl"));
} catch {
return [];
}
}
export async function listScheduleEvents(
config: Config,
access?: LiveEventsAccessFilter | null
): Promise<{
generatedAt: string | null;
events: ScheduleEventPublic[];
providers: string[];
}> {
assertAccessEnabled(access);
const raw = await loadRawEvents(config);
const nowMs = Date.now();
const events = raw.events
const mapped = raw.events
.map((e) => toPublic(e, nowMs))
.filter((e): e is ScheduleEventPublic => e != null)
.filter((e) => e.phase !== "ended")
@ -339,8 +533,10 @@ export async function listScheduleEvents(config: Config): Promise<{
if (b.phase === "live" && a.phase !== "live") return 1;
return Date.parse(a.start) - Date.parse(b.start);
});
const disabledGlobal = await globalDisabledProviders(config);
const events = filterByAccess(fillMissingImages(mapped), disabledGlobal, access);
const providers = [...new Set(events.map((e) => e.provider))].sort((a, b) =>
const providers = [...new Set(events.map((e) => e.provider.toLowerCase()))].sort((a, b) =>
a.localeCompare(b, "nl")
);
@ -353,24 +549,35 @@ export async function listScheduleEvents(config: Config): Promise<{
export async function getScheduleEvent(
config: Config,
eventId: string
eventId: string,
access?: LiveEventsAccessFilter | null
): Promise<ScheduleEventPublic> {
assertAccessEnabled(access);
const id = str(eventId);
const listed = await listScheduleEvents(config);
const listed = await listScheduleEvents(config, access);
const hit = listed.events.find((e) => e.id === id);
if (hit) return hit;
// Ended events still resolvable for detail (no play)
// Ended events still resolvable for detail (no play) if provider allowed
const raw = await loadRawEvents(config);
const nowMs = Date.now();
const found = raw.events.find((e) => str(e.id) === id);
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
const pub = toPublic(found, nowMs);
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
const disabledGlobal = await globalDisabledProviders(config);
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
}
return pub;
}
export async function getScheduleEventPlay(config: Config, eventId: string) {
export async function getScheduleEventPlay(
config: Config,
eventId: string,
access?: LiveEventsAccessFilter | null
) {
assertAccessEnabled(access);
const id = str(eventId);
const raw = await loadRawEvents(config);
const found = raw.events.find((e) => str(e.id) === id);
@ -379,6 +586,12 @@ export async function getScheduleEventPlay(config: Config, eventId: string) {
const nowMs = Date.now();
const pub = toPublic(found, nowMs);
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
const disabledGlobal = await globalDisabledProviders(config);
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
}
if (pub.phase === "ended") {
throw new AppError("GONE", "Dit event is afgelopen", 410);
}

View file

@ -32,6 +32,11 @@ import {
invalidateHomeForProfile,
} from "./home-cache";
import { compactListImages } from "./image-urls";
import {
parseLiveEventsProvidersJson,
serializeLiveEventsProviders,
} from "./schedule-events";
import { normalizeProviderIdList } from "../settings/iptv";
const CODE_TTL_MS = 10 * 60 * 1000;
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@ -186,6 +191,8 @@ export class ViewerService {
enabled: v.enabled,
appAccess: v.appAccess,
iptvAccess: v.iptvAccess,
liveEventsEnabled: v.liveEventsEnabled,
liveEventsProviders: parseLiveEventsProvidersJson(v.liveEventsProviders),
iptvAddonTokenId: v.iptvAddonTokenId,
liveListIds: v.liveChannelLists.map((r) => r.listId),
liveLists: v.liveChannelLists.map((r) => ({
@ -249,6 +256,8 @@ export class ViewerService {
enabled?: boolean;
appAccess?: boolean;
iptvAccess?: boolean;
liveEventsEnabled?: boolean;
liveEventsProviders?: string[] | null;
iptvAddonTokenId?: string | null;
password?: string;
}
@ -265,6 +274,8 @@ export class ViewerService {
enabled?: boolean;
appAccess?: boolean;
iptvAccess?: boolean;
liveEventsEnabled?: boolean;
liveEventsProviders?: string | null;
iptvAddonTokenId?: string | null;
passwordHash?: string;
} = {};
@ -272,6 +283,15 @@ export class ViewerService {
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.liveEventsEnabled !== undefined) patch.liveEventsEnabled = data.liveEventsEnabled;
if (data.liveEventsProviders !== undefined) {
if (data.liveEventsProviders === null) {
patch.liveEventsProviders = null;
} else {
const list = normalizeProviderIdList(data.liveEventsProviders);
patch.liveEventsProviders = serializeLiveEventsProviders(list);
}
}
if (data.iptvAddonTokenId !== undefined) {
patch.iptvAddonTokenId = data.iptvAddonTokenId?.trim() || null;
}
@ -302,6 +322,8 @@ export class ViewerService {
enabled: true,
appAccess: true,
iptvAccess: true,
liveEventsEnabled: true,
liveEventsProviders: true,
iptvAddonTokenId: true,
createdAt: true,
},
@ -315,7 +337,10 @@ export class ViewerService {
if (Object.keys(patch).length > 0) {
notifyViewersChanged("viewer.updated", { viewerId: updated.id });
}
return updated;
return {
...updated,
liveEventsProviders: parseLiveEventsProvidersJson(updated.liveEventsProviders),
};
}
async revokeDevice(deviceId: string) {