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( const [tvLiveEventsUrl, setTvLiveEventsUrl] = useState(
"http://epg.faabserver.nl/api/export/live-events?horizon_days=3" "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 [tvShowAlle, setTvShowAlle] = useState(true);
const [tvCategories, setTvCategories] = useState< const [tvCategories, setTvCategories] = useState<
Array<{ id: string; name: string; channelCount: number }> Array<{ id: string; name: string; channelCount: number }>
@ -262,8 +266,18 @@ export default function SettingsPage() {
? s.liveEventsUrl ? s.liveEventsUrl
: "http://epg.faabserver.nl/api/export/live-events?horizon_days=3" : "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 : []); setTvCategories(Array.isArray(d.categories) ? d.categories : []);
setTvLiveEventProviders(
Array.isArray(d.liveEventProviders)
? d.liveEventProviders.map((p: string) => String(p).toLowerCase())
: []
);
}) })
.catch(() => undefined); .catch(() => undefined);
}, []); }, []);
@ -568,6 +582,7 @@ export default function SettingsPage() {
categoryOrder: tvCategories.map((c) => c.id), categoryOrder: tvCategories.map((c) => c.id),
showAlleZenders: tvShowAlle, showAlleZenders: tvShowAlle,
liveEventsUrl: tvLiveEventsUrl.trim(), liveEventsUrl: tvLiveEventsUrl.trim(),
liveEventsDisabledProviders: tvDisabledProviders,
}), }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@ -586,8 +601,18 @@ export default function SettingsPage() {
? data.settings.liveEventsUrl ? 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.categories)) setTvCategories(data.categories);
if (Array.isArray(data.liveEventProviders)) {
setTvLiveEventProviders(
data.liveEventProviders.map((p: string) => String(p).toLowerCase())
);
}
} finally { } finally {
setTvBusy(false); 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. JSON-export (FAAB) voor de Events-tab in Android TV. Leeg laten schakelt de tab uit.
Default: FAAB live-events met 3 dagen horizon. Default: FAAB live-events met 3 dagen horizon.
</p> </p>
<label style={{ display: "block" }}> <label style={{ display: "block", marginBottom: "0.75rem" }}>
Feed-URL Feed-URL
<input <input
type="url" type="url"
@ -947,6 +972,45 @@ export default function SettingsPage() {
autoComplete="off" autoComplete="off"
/> />
</label> </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>
<div className="card"> <div className="card">

View file

@ -56,6 +56,9 @@ type Viewer = {
enabled: boolean; enabled: boolean;
appAccess: boolean; appAccess: boolean;
iptvAccess: boolean; iptvAccess: boolean;
liveEventsEnabled: boolean;
/** null = alle globaal-enabled providers */
liveEventsProviders: string[] | null;
iptvAddonTokenId: string | null; iptvAddonTokenId: string | null;
liveListIds?: string[]; liveListIds?: string[];
liveLists?: Array<{ id: string; name: string; enabled: boolean }>; liveLists?: Array<{ id: string; name: string; enabled: boolean }>;
@ -179,6 +182,15 @@ export default function ViewersPage() {
const [editEmails, setEditEmails] = useState<Record<string, string>>({}); const [editEmails, setEditEmails] = useState<Record<string, string>>({});
const [allLiveLists, setAllLiveLists] = useState<LiveListOption[]>([]); const [allLiveLists, setAllLiveLists] = useState<LiveListOption[]>([]);
const [liveListDrafts, setLiveListDrafts] = useState<Record<string, string[]>>({}); 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(() => { const load = useCallback(() => {
fetch("/api/v1/admin/viewers", { credentials: "include" }) fetch("/api/v1/admin/viewers", { credentials: "include" })
@ -200,6 +212,15 @@ export default function ViewersPage() {
} }
return next; 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")); .catch(() => setErr("Kon gebruikers niet laden"));
}, []); }, []);
@ -214,6 +235,20 @@ export default function ViewersPage() {
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setAllLiveLists(d.lists ?? [])) .then((d) => setAllLiveLists(d.lists ?? []))
.catch(() => undefined); .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]); }, [load]);
// Live updates via SSE (device link, login lock, CRUD). Geen interval-poll. // 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); setActionId(v.id);
setErr(null); setErr(null);
try { try {
const current =
field === "liveEventsEnabled" ? v.liveEventsEnabled !== false : Boolean(v[field]);
const res = await fetch(`/api/v1/admin/viewers/${v.id}`, { const res = await fetch(`/api/v1/admin/viewers/${v.id}`, {
method: "PATCH", method: "PATCH",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [field]: !v[field] }), body: JSON.stringify({ [field]: !current }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); 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) { async function saveIptv(v: Viewer) {
const form = iptvForms[v.id] ?? emptyIptvForm(); const form = iptvForms[v.id] ?? emptyIptvForm();
setActionId(v.id); setActionId(v.id);
@ -1229,6 +1326,21 @@ export default function ViewersPage() {
<span /> <span />
</button> </button>
</div> </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>
<div className="detail-actions"> <div className="detail-actions">
<button <button
@ -1409,6 +1521,77 @@ export default function ViewersPage() {
</div> </div>
</article> </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"> <article className="block">
<header className="block-head"> <header className="block-head">
<h3>Stremio-addon</h3> <h3>Stremio-addon</h3>

View file

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

View file

@ -426,8 +426,10 @@ private fun hexToBytes(hex: String): ByteArray {
.replace("-", "") .replace("-", "")
.replace(Regex("[\\s:]"), "") .replace(Regex("[\\s:]"), "")
.lowercase() .lowercase()
require(clean.length == 32 && clean.all { it in "0123456789abcdef" }) { if (clean.length != 32 || clean.any { it !in "0123456789abcdef" }) {
"ongeldige ClearKey hex (verwacht 32 tekens): ${hex.take(20)}" throw IllegalArgumentException(
"ongeldige ClearKey hex (verwacht 32 tekens): ${hex.take(20)}",
)
} }
return ByteArray(16) { i -> return ByteArray(16) { i ->
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte() 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.activity.compose.BackHandler
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -17,7 +18,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import nl.vonas.mediacluster.tv.data.ApiClient import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer import nl.vonas.mediacluster.tv.playback.buildLiveTvPlayer
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
import nl.vonas.mediacluster.tv.playback.liveMediaItem import nl.vonas.mediacluster.tv.playback.liveMediaItem
import nl.vonas.mediacluster.tv.ui.theme.McColors 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) @OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalTvMaterial3Api::class) @kotlin.OptIn(ExperimentalTvMaterial3Api::class)
@ -45,28 +60,78 @@ fun EventPlayerScreen(
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val context = LocalContext.current 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 title by remember { mutableStateOf("Live event") }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var showChrome by remember { mutableStateOf(true) } var showChrome by remember { mutableStateOf(true) }
fun safeStopAndClear() {
runCatching {
player.playWhenReady = false
player.stop()
player.clearMediaItems()
}
}
LaunchedEffect(eventId) { LaunchedEffect(eventId) {
loading = true loading = true
error = null error = null
reachedReady.set(false)
showChrome = true
try { try {
val play = api.scheduleEventPlay(eventId) val play = api.scheduleEventPlay(eventId)
if (!isActive) return@LaunchedEffect
title = play.name title = play.name
player.setMediaItem(liveMediaItem(play.streamUrl, play.format, play.drm)) val url = play.streamUrl.trim()
player.prepare() if (url.isEmpty()) {
player.playWhenReady = true 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) { } catch (e: Exception) {
error = e.message ?: "Kon event niet afspelen" error = e.message ?: "Kon event niet afspelen"
safeStopAndClear()
} finally { } finally {
loading = false 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) { LaunchedEffect(showChrome, error, loading) {
if (showChrome && error == null && !loading) { if (showChrome && error == null && !loading) {
delay(4_000) delay(4_000)
@ -74,20 +139,45 @@ fun EventPlayerScreen(
} }
} }
LaunchedEffect(Unit) {
delay(80)
runCatching { rootFocus.requestFocus() }
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
val listener = object : Player.Listener { val listener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) { 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) { 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) player.addListener(listener)
onDispose { onDispose {
player.removeListener(listener) player.removeListener(listener)
player.release() runCatching {
player.playWhenReady = false
player.stop()
player.clearMediaItems()
}
runCatching { player.release() }
playerRef.compareAndSet(player, null)
} }
} }
@ -96,22 +186,38 @@ fun EventPlayerScreen(
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black), .background(Color.Black)
) { .focusRequester(rootFocus)
AndroidView( .focusable()
factory = { ctx -> .onPreviewKeyEvent { ev ->
PlayerView(ctx).apply { if (ev.type == KeyEventType.KeyDown &&
useController = false (ev.key == Key.Back || ev.key == Key.Escape)
layoutParams = ViewGroup.LayoutParams( ) {
ViewGroup.LayoutParams.MATCH_PARENT, onBack()
ViewGroup.LayoutParams.MATCH_PARENT, true
) } else {
this.player = player false
} }
}, },
update = { it.player = player }, ) {
modifier = Modifier.fillMaxSize(), if (error == null) {
) AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
useController = false
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
this.player = playerRef.get()
}
},
update = { view ->
view.player = playerRef.get()
},
modifier = Modifier.fillMaxSize(),
)
}
if (loading) { if (loading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -121,9 +227,24 @@ fun EventPlayerScreen(
if (error != null) { if (error != null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Kan event niet afspelen", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 22.sp) Text(
Text(error!!, color = McColors.Danger, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp)) "Kan event niet afspelen",
Text("Druk op Terug", color = McColors.TextDim, fontSize = 14.sp, modifier = Modifier.padding(top = 12.dp)) 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) { } else if (showChrome) {

View file

@ -128,6 +128,9 @@ fun McNav() {
var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) } var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) }
var tvLastStreamId by rememberSaveable { mutableStateOf<String?>(null) } var tvLastStreamId by rememberSaveable { mutableStateOf<String?>(null) }
var tvFocusNonce by remember { mutableIntStateOf(0) } 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 favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet())
val hubTab = remember(hubTabName) { val hubTab = remember(hubTabName) {
MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home MainTab.entries.firstOrNull { it.name == hubTabName } ?: MainTab.Home
@ -321,6 +324,10 @@ fun McNav() {
onToggleFavorite = { id -> scope.launch { store.toggleFavorite(id) } }, onToggleFavorite = { id -> scope.launch { store.toggleFavorite(id) } },
tvFocusNonce = tvFocusNonce, tvFocusNonce = tvFocusNonce,
onTvFocusContent = { tvFocusNonce++ }, onTvFocusContent = { tvFocusNonce++ },
eventsReturnNonce = eventsReturnNonce,
restoreEventId = lastOpenedEventId,
pendingEventsFocusRestore = pendingEventsFocusRestore,
onEventsFocusRestoreConsumed = { pendingEventsFocusRestore = false },
profileAvatarId = profileAvatarId, profileAvatarId = profileAvatarId,
profileShowAvatar = profileShowAvatar, profileShowAvatar = profileShowAvatar,
profileAvatarUrl = profileAvatarUrl, profileAvatarUrl = profileAvatarUrl,
@ -342,10 +349,14 @@ fun McNav() {
nav.navigate("live/${android.net.Uri.encode(streamId)}") nav.navigate("live/${android.net.Uri.encode(streamId)}")
}, },
onOpenEvent = { eventId -> onOpenEvent = { eventId ->
lastOpenedEventId = eventId
nav.navigate("event/${android.net.Uri.encode(eventId)}") nav.navigate("event/${android.net.Uri.encode(eventId)}")
}, },
onPlayEvent = { 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 = { onSwitchProfile = {
scope.launch { scope.launch {
@ -476,12 +487,21 @@ fun McNav() {
val eventId = entry.arguments?.getString("eventId") val eventId = entry.arguments?.getString("eventId")
?.let { android.net.Uri.decode(it) } ?.let { android.net.Uri.decode(it) }
?: return@composable ?: return@composable
DisposableEffect(Unit) {
onDispose {
pendingEventsFocusRestore = true
eventsReturnNonce++
}
}
EventDetailScreen( EventDetailScreen(
api = api, api = api,
eventId = eventId, eventId = eventId,
onBack = { nav.popBackStack() }, onBack = { nav.popBackStack() },
onPlay = { id -> 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") val eventId = entry.arguments?.getString("eventId")
?.let { android.net.Uri.decode(it) } ?.let { android.net.Uri.decode(it) }
?: return@composable ?: return@composable
DisposableEffect(Unit) {
onDispose {
pendingEventsFocusRestore = true
eventsReturnNonce++
}
}
EventPlayerScreen( EventPlayerScreen(
api = api, api = api,
eventId = eventId, eventId = eventId,
@ -519,6 +545,10 @@ private fun MainHub(
onToggleFavorite: (String) -> Unit, onToggleFavorite: (String) -> Unit,
tvFocusNonce: Int, tvFocusNonce: Int,
onTvFocusContent: () -> Unit, onTvFocusContent: () -> Unit,
eventsReturnNonce: Int = 0,
restoreEventId: String? = null,
pendingEventsFocusRestore: Boolean = false,
onEventsFocusRestoreConsumed: () -> Unit = {},
profileAvatarId: String?, profileAvatarId: String?,
profileShowAvatar: Boolean = true, profileShowAvatar: Boolean = true,
profileAvatarUrl: String? = null, profileAvatarUrl: String? = null,
@ -544,6 +574,7 @@ private fun MainHub(
var coldStartNavFocus by rememberSaveable { mutableStateOf(true) } var coldStartNavFocus by rememberSaveable { mutableStateOf(true) }
val navFocus = remember { FocusRequester() } val navFocus = remember { FocusRequester() }
val homeContentFocus = remember { FocusRequester() } val homeContentFocus = remember { FocusRequester() }
val eventsContentFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() } val exitFocus = remember { FocusRequester() }
var exitReady by remember { mutableStateOf(false) } var exitReady by remember { mutableStateOf(false) }
var prevTabName by rememberSaveable { mutableStateOf<String?>(null) } var prevTabName by rememberSaveable { mutableStateOf<String?>(null) }
@ -733,10 +764,18 @@ private fun MainHub(
exitConfirm = false exitConfirm = false
homeChromeCompact = false homeChromeCompact = false
when (it) { when (it) {
MainTab.Tv, MainTab.TvGuide, MainTab.Events -> { MainTab.Tv, MainTab.TvGuide -> {
navFocused = false navFocused = false
onTvFocusContent() onTvFocusContent()
} }
MainTab.Events -> {
navFocused = false
onTvFocusContent()
hubScope.launch {
delay(120)
runCatching { eventsContentFocus.requestFocus() }
}
}
MainTab.Home -> { MainTab.Home -> {
navFocused = false navFocused = false
hubScope.launch { hubScope.launch {
@ -763,7 +802,11 @@ private fun MainHub(
else -> Unit else -> Unit
} }
}, },
contentDownFocus = if (tab == MainTab.Home) homeContentFocus else null, contentDownFocus = when (tab) {
MainTab.Home -> homeContentFocus
MainTab.Events -> eventsContentFocus
else -> null
},
subItems = subItems, subItems = subItems,
selectedSubId = selectedSubId, selectedSubId = selectedSubId,
focusSelectedToken = topNavFocusToken, focusSelectedToken = topNavFocusToken,
@ -870,6 +913,12 @@ private fun MainHub(
) )
MainTab.Events -> EventsScreen( MainTab.Events -> EventsScreen(
api = api, api = api,
contentFocusRequester = eventsContentFocus,
navFocusRequester = navFocus,
focusRestoreNonce = eventsReturnNonce,
restoreEventId = restoreEventId,
pendingFocusRestore = pendingEventsFocusRestore,
onFocusRestoreConsumed = onEventsFocusRestoreConsumed,
onOpenEvent = onOpenEvent, onOpenEvent = onOpenEvent,
onPlayEvent = onPlayEvent, onPlayEvent = onPlayEvent,
onContentFocused = { navFocused = false }, 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") appAccess Boolean @default(true) @map("app_access")
/** Live TV in de apps (Xtream-lijn op deze gebruiker). */ /** Live TV in de apps (Xtream-lijn op deze gebruiker). */
iptvAccess Boolean @default(false) @map("iptv_access") 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. */ /** @deprecated Legacy: IPTV via addon-token; voorkeur = iptvLine op deze user. */
iptvAddonTokenId String? @map("iptv_addon_token_id") iptvAddonTokenId String? @map("iptv_addon_token_id")
/** Google-account voor Play Closed Testing; fallback = email */ /** Google-account voor Play Closed Testing; fallback = email */

View file

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

View file

@ -87,7 +87,20 @@ export function registerSettingsRoutes(
getIptvSettings(config), getIptvSettings(config),
listIptvCategoriesForAdmin(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) => { app.put("/api/v1/admin/settings/iptv", { preHandler: requireAdmin }, async (request) => {
@ -96,10 +109,23 @@ export function registerSettingsRoutes(
categoryOrder?: string[]; categoryOrder?: string[];
showAlleZenders?: boolean; showAlleZenders?: boolean;
liveEventsUrl?: string; liveEventsUrl?: string;
liveEventsDisabledProviders?: string[];
}; };
const settings = await upsertIptvSettings(config, body); const settings = await upsertIptvSettings(config, body);
const categories = await listIptvCategoriesForAdmin(config); 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 () => { app.post("/api/v1/admin/settings/iptv/refresh-epg", { preHandler: requireAdmin }, async () => {

View file

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

View file

@ -1,6 +1,7 @@
import type { Config } from "../config"; import type { Config } from "../config";
import { AppError } from "../security/errors"; 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 CACHE_TTL_MS = 60_000;
const FETCH_TIMEOUT_MS = 20_000; const FETCH_TIMEOUT_MS = 20_000;
@ -32,6 +33,17 @@ export type ScheduleEventPublic = {
audioLanguages: string[]; 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 RawKid = { kid?: unknown; key?: unknown };
type RawEvent = { type RawEvent = {
id?: unknown; id?: unknown;
@ -116,10 +128,18 @@ function pickImages(raw: RawEvent): string[] {
function scoreLandscape(url: string): number { function scoreLandscape(url: string): number {
const u = url.toLowerCase(); const u = url.toLowerCase();
let score = 0; 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("_l.") || u.includes("_l?")) score += 50;
if (u.includes("width=960") && u.includes("height=540")) score += 40; 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("width=1280") || u.includes("height=720")) score += 30;
if (u.includes(".jpg") || u.includes(".jpeg") || u.includes(".webp")) score += 5; 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("logo")) score -= 40;
if (u.includes("_c.") || u.includes("_c?")) score -= 20; if (u.includes("_c.") || u.includes("_c?")) score -= 20;
if (u.includes(".png") && u.includes("logo")) score -= 50; if (u.includes(".png") && u.includes("logo")) score -= 50;
@ -131,7 +151,8 @@ function scorePortrait(url: string): number {
let score = 0; let score = 0;
if (u.includes("_c.") || u.includes("_c?")) score += 50; if (u.includes("_c.") || u.includes("_c?")) score += 50;
if (u.includes("width=199") || u.includes("height=298")) score += 30; 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; if (u.includes("_l.")) score -= 10;
return score; return score;
} }
@ -139,13 +160,89 @@ function scorePortrait(url: string): number {
function scoreLogo(url: string): number { function scoreLogo(url: string): number {
const u = url.toLowerCase(); const u = url.toLowerCase();
let score = 0; 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("logo")) score += 60;
if (u.includes(".png")) score += 20; if (u.includes(".png")) score += 20;
if (u.includes("cropped")) score += 10; 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; if (u.includes("_l.") || u.includes("_c.")) score -= 30;
return score; 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 { function bestByScore(urls: string[], scorer: (u: string) => number): string | null {
let best: string | null = null; let best: string | null = null;
let bestScore = -Infinity; let bestScore = -Infinity;
@ -231,6 +328,9 @@ function toPublic(raw: RawEvent, nowMs: number): ScheduleEventPublic | null {
const isLiveFlag = raw.is_live === true; const isLiveFlag = raw.is_live === true;
const phase = phaseFor(start, stop, isLiveFlag, nowMs); 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 { return {
id, id,
name, name,
@ -238,12 +338,12 @@ function toPublic(raw: RawEvent, nowMs: number): ScheduleEventPublic | null {
str(raw.description) || str(raw.description) ||
str(raw.epg?.description) || str(raw.epg?.description) ||
null, null,
provider: str(raw.provider) || "unknown", provider: (str(raw.provider) || "unknown").toLowerCase(),
category: str(raw.category) || str(raw.epg?.category) || null, category: str(raw.category) || str(raw.epg?.category) || null,
eventType: str(meta.type) || null, eventType: str(meta.type) || null,
subtitle: str(meta["originele titel"]) || str(raw.epg?.subtitle) || null, subtitle: str(meta["originele titel"]) || str(raw.epg?.subtitle) || null,
channel: str(raw.epg?.channel) || str(meta.zender) || null, channel,
network: str(raw.epg?.network) || null, network,
isLive: phase === "live", isLive: phase === "live",
phase, phase,
playable: isPlayable(raw) && phase === "live", playable: isPlayable(raw) && phase === "live",
@ -274,6 +374,78 @@ export async function eventsFeedEnabled(config: Config): Promise<boolean> {
return !!settings.liveEventsUrl?.trim(); 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> { async function loadRawEvents(config: Config): Promise<CacheEntry> {
const url = await resolveLiveEventsUrl(config); const url = await resolveLiveEventsUrl(config);
if (cache && cache.url === url && Date.now() - cache.at < CACHE_TTL_MS) { if (cache && cache.url === url && Date.now() - cache.at < CACHE_TTL_MS) {
@ -323,14 +495,36 @@ export function invalidateScheduleEventsCache() {
cache = null; 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; generatedAt: string | null;
events: ScheduleEventPublic[]; events: ScheduleEventPublic[];
providers: string[]; providers: string[];
}> { }> {
assertAccessEnabled(access);
const raw = await loadRawEvents(config); const raw = await loadRawEvents(config);
const nowMs = Date.now(); const nowMs = Date.now();
const events = raw.events const mapped = raw.events
.map((e) => toPublic(e, nowMs)) .map((e) => toPublic(e, nowMs))
.filter((e): e is ScheduleEventPublic => e != null) .filter((e): e is ScheduleEventPublic => e != null)
.filter((e) => e.phase !== "ended") .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; if (b.phase === "live" && a.phase !== "live") return 1;
return Date.parse(a.start) - Date.parse(b.start); 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") a.localeCompare(b, "nl")
); );
@ -353,24 +549,35 @@ export async function listScheduleEvents(config: Config): Promise<{
export async function getScheduleEvent( export async function getScheduleEvent(
config: Config, config: Config,
eventId: string eventId: string,
access?: LiveEventsAccessFilter | null
): Promise<ScheduleEventPublic> { ): Promise<ScheduleEventPublic> {
assertAccessEnabled(access);
const id = str(eventId); const id = str(eventId);
const listed = await listScheduleEvents(config); const listed = await listScheduleEvents(config, access);
const hit = listed.events.find((e) => e.id === id); const hit = listed.events.find((e) => e.id === id);
if (hit) return hit; 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 raw = await loadRawEvents(config);
const nowMs = Date.now(); const nowMs = Date.now();
const found = raw.events.find((e) => str(e.id) === id); const found = raw.events.find((e) => str(e.id) === id);
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404); if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
const pub = toPublic(found, nowMs); const pub = toPublic(found, nowMs);
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404); 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; 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 id = str(eventId);
const raw = await loadRawEvents(config); const raw = await loadRawEvents(config);
const found = raw.events.find((e) => str(e.id) === id); 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 nowMs = Date.now();
const pub = toPublic(found, nowMs); const pub = toPublic(found, nowMs);
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404); 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") { if (pub.phase === "ended") {
throw new AppError("GONE", "Dit event is afgelopen", 410); throw new AppError("GONE", "Dit event is afgelopen", 410);
} }

View file

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