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:
parent
ac951617cb
commit
cbacf1af41
14 changed files with 1540 additions and 184 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"Alle providers") = 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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue