Add admin-managed ClearKey/DASH live channel lists for Android TV.

Custom lists merge into IPTV categories per viewer, with encrypted keys and ExoPlayer ClearKey playback.
This commit is contained in:
Jos Vooges | STH 2026-09-18 18:53:04 +02:00
parent 21efba1af1
commit df9782c8f5
20 changed files with 1911 additions and 153 deletions

View file

@ -0,0 +1,569 @@
"use client";
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
type LiveListSummary = {
id: string;
name: string;
slug: string;
sortOrder: number;
enabled: boolean;
channelCount: number;
viewerCount: number;
updatedAt: string;
};
type LiveChannelRow = {
id: string;
name: string;
logoUrl: string | null;
mpdUrl: string;
kid: string;
key: string;
hasKid: boolean;
hasKey: boolean;
epgChannelId: string | null;
sortOrder: number;
enabled: boolean;
};
type LiveListDetail = {
id: string;
name: string;
slug: string;
sortOrder: number;
enabled: boolean;
channels: LiveChannelRow[];
};
type EpgOption = { id: string; name: string };
type ChannelForm = {
name: string;
logoUrl: string;
mpdUrl: string;
kid: string;
key: string;
epgChannelId: string;
enabled: boolean;
};
function emptyChannelForm(): ChannelForm {
return {
name: "",
logoUrl: "",
mpdUrl: "",
kid: "",
key: "",
epgChannelId: "",
enabled: true,
};
}
function formFromChannel(c: LiveChannelRow): ChannelForm {
return {
name: c.name,
logoUrl: c.logoUrl ?? "",
mpdUrl: c.mpdUrl,
kid: "",
key: "",
epgChannelId: c.epgChannelId ?? "",
enabled: c.enabled,
};
}
export default function LiveListsPage() {
useAuth();
const [lists, setLists] = useState<LiveListSummary[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<LiveListDetail | null>(null);
const [epgOptions, setEpgOptions] = useState<EpgOption[]>([]);
const [epgFilter, setEpgFilter] = useState("");
const [newListName, setNewListName] = useState("");
const [listName, setListName] = useState("");
const [listEnabled, setListEnabled] = useState(true);
const [editingChannelId, setEditingChannelId] = useState<string | null>(null);
const [channelForm, setChannelForm] = useState<ChannelForm>(emptyChannelForm());
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const loadLists = useCallback(async () => {
const r = await fetch("/api/v1/admin/live-lists", { credentials: "include" });
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Lijsten laden mislukt");
setLists(d.lists ?? []);
}, []);
const loadDetail = useCallback(async (id: string) => {
const r = await fetch(`/api/v1/admin/live-lists/${id}`, { credentials: "include" });
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Lijst laden mislukt");
setDetail(d);
setListName(d.name ?? "");
setListEnabled(d.enabled !== false);
}, []);
useEffect(() => {
void loadLists().catch((e) => setErr(String(e.message ?? e)));
fetch("/api/v1/admin/live-lists/epg-channels", { credentials: "include" })
.then((r) => r.json())
.then((d) => setEpgOptions(d.channels ?? []))
.catch(() => undefined);
}, [loadLists]);
useEffect(() => {
if (!selectedId) {
setDetail(null);
return;
}
void loadDetail(selectedId).catch((e) => setErr(String(e.message ?? e)));
}, [selectedId, loadDetail]);
useEffect(() => {
if (!selectedId && lists.length > 0) setSelectedId(lists[0].id);
}, [lists, selectedId]);
const filteredEpg = useMemo(() => {
const q = epgFilter.trim().toLowerCase();
if (!q) return epgOptions.slice(0, 200);
return epgOptions
.filter(
(o) =>
o.name.toLowerCase().includes(q) || o.id.toLowerCase().includes(q)
)
.slice(0, 200);
}, [epgOptions, epgFilter]);
async function createList(e: FormEvent) {
e.preventDefault();
const name = newListName.trim();
if (!name) return;
setBusy(true);
setErr(null);
try {
const r = await fetch("/api/v1/admin/live-lists", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Aanmaken mislukt");
setNewListName("");
setMsg(`Lijst “${d.name}” aangemaakt`);
await loadLists();
setSelectedId(d.id);
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function saveListMeta() {
if (!selectedId) return;
setBusy(true);
setErr(null);
try {
const r = await fetch(`/api/v1/admin/live-lists/${selectedId}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: listName.trim(), enabled: listEnabled }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Opslaan mislukt");
setMsg("Lijst opgeslagen");
await loadLists();
await loadDetail(selectedId);
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function deleteList() {
if (!selectedId || !detail) return;
if (!confirm(`Lijst “${detail.name}” en alle zenders verwijderen?`)) return;
setBusy(true);
setErr(null);
try {
const r = await fetch(`/api/v1/admin/live-lists/${selectedId}`, {
method: "DELETE",
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Verwijderen mislukt");
setSelectedId(null);
setDetail(null);
setMsg("Lijst verwijderd");
await loadLists();
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
function startCreateChannel() {
setEditingChannelId("new");
setChannelForm(emptyChannelForm());
}
function startEditChannel(c: LiveChannelRow) {
setEditingChannelId(c.id);
setChannelForm(formFromChannel(c));
}
async function saveChannel(e: FormEvent) {
e.preventDefault();
if (!selectedId) return;
setBusy(true);
setErr(null);
try {
const payload: Record<string, unknown> = {
name: channelForm.name.trim(),
logoUrl: channelForm.logoUrl.trim() || null,
mpdUrl: channelForm.mpdUrl.trim(),
epgChannelId: channelForm.epgChannelId.trim() || null,
enabled: channelForm.enabled,
};
if (channelForm.kid.trim()) payload.kid = channelForm.kid.trim();
if (channelForm.key.trim()) payload.key = channelForm.key.trim();
const isNew = editingChannelId === "new";
if (isNew && (!payload.kid || !payload.key)) {
throw new Error("KID en KEY zijn verplicht voor een nieuwe zender");
}
const r = await fetch(
isNew
? `/api/v1/admin/live-lists/${selectedId}/channels`
: `/api/v1/admin/live-channels/${editingChannelId}`,
{
method: isNew ? "POST" : "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}
);
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Zender opslaan mislukt");
setEditingChannelId(null);
setChannelForm(emptyChannelForm());
setMsg(isNew ? "Zender toegevoegd" : "Zender bijgewerkt");
await loadDetail(selectedId);
await loadLists();
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function deleteChannel(id: string, name: string) {
if (!selectedId) return;
if (!confirm(`Zender “${name}” verwijderen?`)) return;
setBusy(true);
setErr(null);
try {
const r = await fetch(`/api/v1/admin/live-channels/${id}`, {
method: "DELETE",
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Verwijderen mislukt");
if (editingChannelId === id) setEditingChannelId(null);
setMsg("Zender verwijderd");
await loadDetail(selectedId);
await loadLists();
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
return (
<>
<Nav />
<div className="container">
<div className="page-header">
<div>
<div className="page-kicker">Live TV</div>
<h1>Live-lijsten</h1>
</div>
</div>
<p className="muted page-lead">
ClearKey/DASH-zenderlijsten (MPD + KID + KEY). Koppel per gebruiker onder Gebruikers.
EPG komt uit de cluster-XMLTV (Instellingen → TV).
</p>
{msg && (
<p className="ok-banner" role="status">
{msg}
</p>
)}
{err && (
<p className="err-banner" role="alert">
{err}
</p>
)}
<div className="split-layout" style={{ display: "grid", gap: "1.25rem", gridTemplateColumns: "minmax(220px, 280px) 1fr" }}>
<aside className="card">
<h2 style={{ marginTop: 0, fontSize: "1.05rem" }}>Lijsten</h2>
<form onSubmit={createList} className="form-group" style={{ marginBottom: "1rem" }}>
<label className="field">
Nieuwe lijst
<input
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
placeholder="Nederland"
disabled={busy}
/>
</label>
<button type="submit" className="btn-primary" disabled={busy || !newListName.trim()}>
Toevoegen
</button>
</form>
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{lists.map((l) => (
<li key={l.id} style={{ marginBottom: 6 }}>
<button
type="button"
className={selectedId === l.id ? "btn-primary" : "btn"}
style={{ width: "100%", textAlign: "left" }}
onClick={() => setSelectedId(l.id)}
>
{l.name}
<span className="muted" style={{ display: "block", fontSize: "0.8rem" }}>
{l.channelCount} zenders · {l.enabled ? "aan" : "uit"}
</span>
</button>
</li>
))}
{lists.length === 0 && <li className="muted">Nog geen lijsten.</li>}
</ul>
</aside>
<section>
{!detail ? (
<div className="card">
<p className="muted">Selecteer of maak een lijst.</p>
</div>
) : (
<>
<div className="card" style={{ marginBottom: "1rem" }}>
<h2 style={{ marginTop: 0 }}>{detail.name}</h2>
<div className="form-group">
<label className="field">
Naam
<input
value={listName}
onChange={(e) => setListName(e.target.value)}
disabled={busy}
/>
</label>
<label className="field" style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={listEnabled}
onChange={(e) => setListEnabled(e.target.checked)}
disabled={busy}
/>
Lijst actief
</label>
</div>
<div className="detail-actions" style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button type="button" className="btn-primary" disabled={busy} onClick={() => void saveListMeta()}>
Lijst opslaan
</button>
<button type="button" className="danger" disabled={busy} onClick={() => void deleteList()}>
Verwijderen
</button>
<button type="button" disabled={busy} onClick={startCreateChannel}>
Zender toevoegen
</button>
</div>
</div>
{editingChannelId && (
<div className="card" style={{ marginBottom: "1rem" }}>
<h3 style={{ marginTop: 0 }}>
{editingChannelId === "new" ? "Nieuwe zender" : "Zender bewerken"}
</h3>
<form onSubmit={saveChannel} className="form-group">
<label className="field">
Naam
<input
required
value={channelForm.name}
onChange={(e) => setChannelForm((f) => ({ ...f, name: e.target.value }))}
disabled={busy}
/>
</label>
<label className="field">
MPD-URL
<input
required
value={channelForm.mpdUrl}
onChange={(e) => setChannelForm((f) => ({ ...f, mpdUrl: e.target.value }))}
placeholder="https://…/manifest.mpd"
disabled={busy}
/>
</label>
<label className="field">
KID (32 hex)
<input
value={channelForm.kid}
onChange={(e) => setChannelForm((f) => ({ ...f, kid: e.target.value }))}
placeholder={
editingChannelId !== "new" ? "•••••••• (leeg = behouden)" : "ClearKey KID"
}
disabled={busy}
autoComplete="off"
/>
</label>
<label className="field">
KEY (32 hex)
<input
value={channelForm.key}
onChange={(e) => setChannelForm((f) => ({ ...f, key: e.target.value }))}
placeholder={
editingChannelId !== "new" ? "•••••••• (leeg = behouden)" : "ClearKey KEY"
}
disabled={busy}
autoComplete="off"
/>
</label>
<label className="field">
Logo-URL (optioneel)
<input
value={channelForm.logoUrl}
onChange={(e) => setChannelForm((f) => ({ ...f, logoUrl: e.target.value }))}
disabled={busy}
/>
</label>
<label className="field">
EPG-kanaal
<input
list="epg-channel-options"
value={channelForm.epgChannelId}
onChange={(e) =>
setChannelForm((f) => ({ ...f, epgChannelId: e.target.value }))
}
placeholder="Zoek of plak XMLTV channel id"
disabled={busy}
/>
</label>
<label className="field">
Filter EPG-lijst
<input
value={epgFilter}
onChange={(e) => setEpgFilter(e.target.value)}
placeholder="Filter display-name…"
/>
</label>
<datalist id="epg-channel-options">
{filteredEpg.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</datalist>
{filteredEpg.length > 0 && (
<p className="muted field-hint">
{filteredEpg.slice(0, 8).map((o) => `${o.name} (${o.id})`).join(" · ")}
{filteredEpg.length > 8 ? "…" : ""}
</p>
)}
<label className="field" style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={channelForm.enabled}
onChange={(e) =>
setChannelForm((f) => ({ ...f, enabled: e.target.checked }))
}
disabled={busy}
/>
Zender actief
</label>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button type="submit" className="btn-primary" disabled={busy}>
Opslaan
</button>
<button
type="button"
disabled={busy}
onClick={() => {
setEditingChannelId(null);
setChannelForm(emptyChannelForm());
}}
>
Annuleren
</button>
</div>
</form>
</div>
)}
<div className="card">
<h3 style={{ marginTop: 0 }}>Zenders ({detail.channels.length})</h3>
{detail.channels.length === 0 ? (
<p className="muted">Nog geen zenders in deze lijst.</p>
) : (
<div className="desktop-table">
<table>
<thead>
<tr>
<th>Naam</th>
<th>EPG</th>
<th>Keys</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{detail.channels.map((c) => (
<tr key={c.id}>
<td>
<strong>{c.name}</strong>
<div className="muted" style={{ fontSize: "0.8rem", wordBreak: "break-all" }}>
{c.mpdUrl}
</div>
</td>
<td>{c.epgChannelId || "—"}</td>
<td>{c.hasKid && c.hasKey ? "opgeslagen" : "ontbreekt"}</td>
<td>{c.enabled ? "aan" : "uit"}</td>
<td style={{ whiteSpace: "nowrap" }}>
<button type="button" disabled={busy} onClick={() => startEditChannel(c)}>
Bewerken
</button>{" "}
<button
type="button"
className="danger"
disabled={busy}
onClick={() => void deleteChannel(c.id, c.name)}
>
Verwijderen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
)}
</section>
</div>
</div>
</>
);
}

View file

@ -42,6 +42,13 @@ type AddonTokenRow = {
hasOwnIptv: boolean;
};
type LiveListOption = {
id: string;
name: string;
enabled: boolean;
channelCount?: number;
};
type Viewer = {
id: string;
email: string;
@ -50,6 +57,8 @@ type Viewer = {
appAccess: boolean;
iptvAccess: boolean;
iptvAddonTokenId: string | null;
liveListIds?: string[];
liveLists?: Array<{ id: string; name: string; enabled: boolean }>;
googlePlayEmail: string | null;
googlePlayAccess: boolean;
googlePlayMembershipId: string | null;
@ -168,6 +177,8 @@ export default function ViewersPage() {
const [claimCodes, setClaimCodes] = useState<Record<string, string>>({});
const [newPasswords, setNewPasswords] = useState<Record<string, string>>({});
const [editEmails, setEditEmails] = useState<Record<string, string>>({});
const [allLiveLists, setAllLiveLists] = useState<LiveListOption[]>([]);
const [liveListDrafts, setLiveListDrafts] = useState<Record<string, string[]>>({});
const load = useCallback(() => {
fetch("/api/v1/admin/viewers", { credentials: "include" })
@ -182,6 +193,13 @@ export default function ViewersPage() {
}
return next;
});
setLiveListDrafts((prev) => {
const next = { ...prev };
for (const v of list) {
if (next[v.id] === undefined) next[v.id] = v.liveListIds ?? [];
}
return next;
});
})
.catch(() => setErr("Kon gebruikers niet laden"));
}, []);
@ -192,6 +210,10 @@ export default function ViewersPage() {
.then((r) => r.json())
.then((d) => setPlayConfig(d.config ?? null))
.catch(() => undefined);
fetch("/api/v1/admin/live-lists", { credentials: "include" })
.then((r) => r.json())
.then((d) => setAllLiveLists(d.lists ?? []))
.catch(() => undefined);
}, [load]);
// Live updates via SSE (device link, login lock, CRUD). Geen interval-poll.
@ -431,6 +453,39 @@ export default function ViewersPage() {
}
}
function toggleLiveListDraft(viewerId: string, listId: string) {
setLiveListDrafts((prev) => {
const cur = prev[viewerId] ?? [];
const next = cur.includes(listId) ? cur.filter((id) => id !== listId) : [...cur, listId];
return { ...prev, [viewerId]: next };
});
}
async function saveLiveLists(v: Viewer) {
setActionId(v.id);
setMsg(null);
setErr(null);
try {
const listIds = liveListDrafts[v.id] ?? v.liveListIds ?? [];
const res = await fetch(`/api/v1/admin/viewers/${v.id}/live-lists`, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ listIds }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Live-lijsten opslaan mislukt");
return;
}
setLiveListDrafts((prev) => ({ ...prev, [v.id]: data.listIds ?? listIds }));
setMsg("Live-lijsten opgeslagen");
load();
} finally {
setActionId(null);
}
}
async function createAddonToken(v: Viewer) {
setActionId(v.id);
setMsg(null);
@ -1308,6 +1363,52 @@ export default function ViewersPage() {
</div>
</article>
<article className="block">
<header className="block-head">
<h3>Live-lijsten (ClearKey/DASH)</h3>
</header>
<p className="muted detail-copy">
Extra TV-categorieën naast XUI. Beheer zenders onder{" "}
<Link href="/live-lists">Live-lijsten</Link>.
</p>
{allLiveLists.length === 0 ? (
<p className="muted">Nog geen live-lijsten aangemaakt.</p>
) : (
<div className="check-rows">
{allLiveLists.map((l) => {
const checked = (liveListDrafts[selected.id] ?? selected.liveListIds ?? []).includes(
l.id
);
return (
<label key={l.id} className="field" style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
type="checkbox"
checked={checked}
disabled={actionId === selected.id}
onChange={() => toggleLiveListDraft(selected.id, l.id)}
/>
<span>
{l.name}
{!l.enabled ? " (uit)" : ""}
{typeof l.channelCount === "number" ? ` · ${l.channelCount} zenders` : ""}
</span>
</label>
);
})}
</div>
)}
<div className="detail-actions">
<button
type="button"
className="btn-primary"
disabled={actionId === selected.id || allLiveLists.length === 0}
onClick={() => void saveLiveLists(selected)}
>
Live-lijsten opslaan
</button>
</div>
</article>
<article className="block">
<header className="block-head">
<h3>Stremio-addon</h3>

View file

@ -13,6 +13,7 @@ const desktopLinks = [
{ href: "/pending", label: "Te koppelen" },
{ href: "/downloads", label: "Downloads" },
{ href: "/streams", label: "Streams" },
{ href: "/live-lists", label: "Live-lijsten" },
{ href: "/viewers", label: "Gebruikers" },
{ href: "/settings", label: "Instellingen" },
];

View file

@ -15,7 +15,7 @@ fun BindCastPlayerRemote(
player: Player,
title: String?,
mediaFileId: String? = null,
streamId: Int? = null,
streamId: String? = null,
mediaType: String? = null,
onStop: () -> Unit,
) {

View file

@ -25,7 +25,7 @@ fun CastReceiverHost(
startMs: Long,
title: String?,
) -> Unit,
onPlayLive: (streamId: Int) -> Unit,
onPlayLive: (streamId: String) -> Unit,
) {
LaunchedEffect(enabled) {
if (!enabled) return@LaunchedEffect
@ -59,8 +59,15 @@ fun CastReceiverHost(
}
}
"play_live" -> {
val streamId = cmd.payload.optInt("streamId", -1)
if (streamId > 0) onPlayLive(streamId)
val raw = cmd.payload.opt("streamId")
val streamId = when (raw) {
is Number -> raw.toLong().toString()
is String -> raw.trim()
else -> cmd.payload.optString("streamId").trim()
}
if (streamId.isNotBlank() && streamId != "null" && streamId != "-1") {
onPlayLive(streamId)
}
}
else -> {
CastCommandBus.fromRemote(cmd.type, cmd.payload)?.let(CastCommandBus::emit)

View file

@ -525,7 +525,7 @@ class ApiClient(
val c = arr.getJSONObject(i)
add(
IptvChannel(
streamId = c.getInt("streamId"),
streamId = c.optStreamIdString(),
name = c.getString("name"),
logoUrl = c.optString("logoUrl").ifBlank { null },
number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") },
@ -540,23 +540,42 @@ class ApiClient(
}
}
suspend fun iptvPlayUrl(streamId: Int): IptvPlayInfo = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/play").get().build()
suspend fun iptvPlayUrl(streamId: String): IptvPlayInfo = withContext(Dispatchers.IO) {
val enc = android.net.Uri.encode(streamId)
val req = authed("$baseUrl/api/v1/client/iptv/channels/$enc/play").get().build()
parseObject(execute(req)) { o ->
val drmObj = o.optJSONObject("drm")
val drm = if (drmObj != null) {
val keysArr = drmObj.optJSONArray("keys") ?: JSONArray()
val keys = buildList {
for (i in 0 until keysArr.length()) {
val k = keysArr.optJSONObject(i) ?: continue
val kid = k.optString("kid").ifBlank { null } ?: continue
val key = k.optString("key").ifBlank { null } ?: continue
add(ClearKeyEntry(kid = kid, key = key))
}
}
DrmInfo(
type = drmObj.optString("type").ifBlank { "clearkey" },
keys = keys,
).takeIf { it.keys.isNotEmpty() }
} else null
IptvPlayInfo(
streamId = o.getInt("streamId"),
streamId = o.optStreamIdString(),
name = o.getString("name"),
logoUrl = o.optString("logoUrl").ifBlank { null },
streamUrl = o.getString("streamUrl"),
format = o.optString("format").ifBlank { "hls" },
fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null },
fallbackFormat = o.optString("fallbackFormat").ifBlank { null },
drm = drm,
)
}
}
suspend fun iptvEpg(streamId: Int): IptvEpg = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/epg").get().build()
suspend fun iptvEpg(streamId: String): IptvEpg = withContext(Dispatchers.IO) {
val enc = android.net.Uri.encode(streamId)
val req = authed("$baseUrl/api/v1/client/iptv/channels/$enc/epg").get().build()
parseObject(execute(req)) { o ->
val scheduleArr = o.optJSONArray("schedule")
val schedule = buildList {
@ -567,7 +586,7 @@ class ApiClient(
}
}
IptvEpg(
streamId = o.getInt("streamId"),
streamId = o.optStreamIdString(),
channelName = o.getString("channelName"),
logoUrl = o.optString("logoUrl").ifBlank { null },
now = o.optJSONObject("now")?.toEpgProgram(),
@ -604,7 +623,7 @@ class ApiClient(
}
add(
IptvGuideChannel(
streamId = c.getInt("streamId"),
streamId = c.optStreamIdString(),
name = c.getString("name"),
logoUrl = c.optClean("logoUrl"),
number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") },
@ -629,8 +648,8 @@ class ApiClient(
}
/** Vul ontbrekende now-titels aan (server-batch of parallel per zender). */
suspend fun fetchMissingNowTitles(channels: List<IptvChannel>): Map<Int, String> {
val result = linkedMapOf<Int, String>()
suspend fun fetchMissingNowTitles(channels: List<IptvChannel>): Map<String, String> {
val result = linkedMapOf<String, String>()
channels.forEach { ch ->
ch.nowTitle?.takeIf { it.isNotBlank() }?.let { result[ch.streamId] = it }
}
@ -691,7 +710,7 @@ class ApiClient(
positionMs: Long = 0,
durationMs: Long = 0,
mediaFileId: String? = null,
streamId: Int? = null,
streamId: String? = null,
mediaType: String? = null,
) = withContext(Dispatchers.IO) {
val body = JSONObject()
@ -731,6 +750,14 @@ class ApiClient(
return raw
}
private fun JSONObject.optStreamIdString(key: String = "streamId"): String {
if (!has(key) || isNull(key)) return ""
return when (val v = opt(key)) {
is Number -> v.toLong().toString()
else -> optString(key).trim()
}
}
private fun authed(url: String): Request.Builder {
val b = Request.Builder().url(url)
val t = token
@ -924,7 +951,7 @@ data class IptvCategory(
)
data class IptvChannel(
val streamId: Int,
val streamId: String,
val name: String,
val logoUrl: String? = null,
val number: Int? = null,
@ -934,14 +961,25 @@ data class IptvChannel(
val nowImageUrl: String? = null,
)
data class ClearKeyEntry(
val kid: String,
val key: String,
)
data class DrmInfo(
val type: String,
val keys: List<ClearKeyEntry>,
)
data class IptvPlayInfo(
val streamId: Int,
val streamId: String,
val name: String,
val logoUrl: String? = null,
val streamUrl: String,
val format: String,
val fallbackStreamUrl: String? = null,
val fallbackFormat: String? = null,
val drm: DrmInfo? = null,
)
data class EpgProgram(
@ -956,7 +994,7 @@ data class EpgProgram(
)
data class IptvEpg(
val streamId: Int,
val streamId: String,
val channelName: String,
val logoUrl: String? = null,
val now: EpgProgram? = null,
@ -975,7 +1013,7 @@ data class IptvGuide(
)
data class IptvGuideChannel(
val streamId: Int,
val streamId: String,
val name: String,
val logoUrl: String? = null,
val number: Int? = null,

View file

@ -24,7 +24,8 @@ class SessionStore(private val context: Context) {
private val homeCacheKey = stringPreferencesKey("home_cache_json")
private val tvFavoritesKey = stringPreferencesKey("tv_favorites")
private val tvLastCategoryKey = stringPreferencesKey("tv_last_category")
private val tvLastStreamKey = intPreferencesKey("tv_last_stream")
private val tvLastStreamKey = stringPreferencesKey("tv_last_stream_id")
private val tvLastStreamKeyLegacy = intPreferencesKey("tv_last_stream")
/** Lokaal per device — niet gesynchroniseerd met account. */
private val audioPassthroughKey = stringPreferencesKey("audio_passthrough")
@ -58,16 +59,17 @@ class SessionStore(private val context: Context) {
val homeCacheJson: Flow<String?> = context.dataStore.data.map { it[homeCacheKey] }
val favoriteStreamIds: Flow<Set<Int>> = context.dataStore.data.map { prefs ->
parseIntSet(prefs[tvFavoritesKey])
val favoriteStreamIds: Flow<Set<String>> = context.dataStore.data.map { prefs ->
parseIdSet(prefs[tvFavoritesKey])
}
val tvLastCategoryId: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[tvLastCategoryKey]?.takeIf { it.isNotBlank() }
}
val tvLastStreamId: Flow<Int?> = context.dataStore.data.map { prefs ->
prefs[tvLastStreamKey]
val tvLastStreamId: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[tvLastStreamKey]?.takeIf { it.isNotBlank() }
?: prefs[tvLastStreamKeyLegacy]?.toString()
}
/**
@ -155,10 +157,10 @@ class SessionStore(private val context: Context) {
context.dataStore.edit { it.remove(recentSearchKey) }
}
suspend fun toggleFavorite(streamId: Int): Boolean {
suspend fun toggleFavorite(streamId: String): Boolean {
var nowFavorite = false
context.dataStore.edit { prefs ->
val set = parseIntSet(prefs[tvFavoritesKey]).toMutableSet()
val set = parseIdSet(prefs[tvFavoritesKey]).toMutableSet()
if (set.contains(streamId)) {
set.remove(streamId)
nowFavorite = false
@ -171,11 +173,12 @@ class SessionStore(private val context: Context) {
return nowFavorite
}
suspend fun setLastTv(categoryId: String?, streamId: Int?) {
suspend fun setLastTv(categoryId: String?, streamId: String?) {
context.dataStore.edit { prefs ->
if (categoryId.isNullOrBlank()) prefs.remove(tvLastCategoryKey)
else prefs[tvLastCategoryKey] = categoryId
if (streamId == null) prefs.remove(tvLastStreamKey)
prefs.remove(tvLastStreamKeyLegacy)
if (streamId.isNullOrBlank()) prefs.remove(tvLastStreamKey)
else prefs[tvLastStreamKey] = streamId
}
}
@ -186,14 +189,16 @@ class SessionStore(private val context: Context) {
}
}
private fun parseIntSet(raw: String?): Set<Int> {
private fun parseIdSet(raw: String?): Set<String> {
if (raw.isNullOrBlank()) return emptySet()
return runCatching {
val arr = JSONArray(raw)
buildSet {
for (i in 0 until arr.length()) {
val v = arr.optInt(i, Int.MIN_VALUE)
if (v != Int.MIN_VALUE) add(v)
when (val v = arr.opt(i)) {
is Number -> add(v.toLong().toString())
is String -> if (v.isNotBlank()) add(v.trim())
}
}
}
}.getOrDefault(emptySet())

View file

@ -227,10 +227,22 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer {
.also { it.volume = 1f }
}
/** TS = geen live-offset (laagste latency). HLS-fallback ~2s achter live-rand. */
fun liveMediaItem(url: String, format: String): MediaItem {
val isHls = format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true)
val mime = if (isHls) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP2T
/** TS / HLS / DASH(+ClearKey). */
fun liveMediaItem(
url: String,
format: String,
drm: nl.vonas.mediacluster.tv.data.DrmInfo? = null,
): MediaItem {
val isDash = format.equals("dash", ignoreCase = true) ||
url.contains(".mpd", ignoreCase = true)
val isHls = !isDash && (
format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true)
)
val mime = when {
isDash -> MimeTypes.APPLICATION_MPD
isHls -> MimeTypes.APPLICATION_M3U8
else -> MimeTypes.VIDEO_MP2T
}
val builder = MediaItem.Builder().setUri(url).setMimeType(mime)
if (isHls) {
builder.setLiveConfiguration(
@ -241,9 +253,63 @@ fun liveMediaItem(url: String, format: String): MediaItem {
.build(),
)
}
if (isDash) {
builder.setLiveConfiguration(
MediaItem.LiveConfiguration.Builder()
.setTargetOffsetMs(3_000)
.setMinOffsetMs(1_500)
.setMaxOffsetMs(8_000)
.build(),
)
}
val clearKeys = drm?.takeIf { it.type.equals("clearkey", ignoreCase = true) }?.keys.orEmpty()
if (clearKeys.isNotEmpty()) {
val licenseJson = buildClearKeyLicenseJson(clearKeys)
val licenseUri =
"data:application/json;base64," +
android.util.Base64.encodeToString(
licenseJson.toByteArray(Charsets.UTF_8),
android.util.Base64.NO_WRAP,
)
builder.setDrmConfiguration(
MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID)
.setLicenseUri(licenseUri)
.setForceDefaultLicenseUri(true)
.build(),
)
}
return builder.build()
}
private fun buildClearKeyLicenseJson(keys: List<nl.vonas.mediacluster.tv.data.ClearKeyEntry>): String {
val arr = org.json.JSONArray()
for (k in keys) {
arr.put(
org.json.JSONObject()
.put("kty", "oct")
.put("kid", hexToBase64Url(k.kid))
.put("k", hexToBase64Url(k.key)),
)
}
return org.json.JSONObject()
.put("keys", arr)
.put("type", "temporary")
.toString()
}
/** ClearKey JSON uses URL-safe base64 without padding. */
private fun hexToBase64Url(hex: String): String {
val clean = hex.trim().removePrefix("0x").replace(Regex("[\\s:\\-]"), "").lowercase()
require(clean.length % 2 == 0) { "ongeldige hex key" }
val bytes = ByteArray(clean.length / 2) { i ->
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
return android.util.Base64.encodeToString(
bytes,
android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING,
)
}
data class PlayerTrack(val groupIndex: Int, val trackIndex: Int, val label: String, val type: Int)
fun ExoPlayer.listTracks(type: Int, embeddedHints: List<nl.vonas.mediacluster.tv.data.EmbeddedTrackInfo> = emptyList()): List<PlayerTrack> {

View file

@ -81,7 +81,7 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
private data class MenuCategoryCache(
val channels: List<IptvChannel>,
val epgTitles: Map<Int, String>,
val epgTitles: Map<String, String>,
val loadedAt: Long,
)
@ -91,10 +91,10 @@ private const val MENU_CACHE_TTL_MS = 90_000L
@Composable
fun LiveTvPlayerScreen(
api: ApiClient,
streamId: Int,
streamId: String,
categoryId: String?,
onCategoryChange: (String) -> Unit,
onStreamChange: (Int) -> Unit,
onStreamChange: (String) -> Unit,
onBack: () -> Unit,
) {
val context = LocalContext.current
@ -102,7 +102,7 @@ fun LiveTvPlayerScreen(
val playerFocus = remember { FocusRequester() }
val menuScope = rememberCoroutineScope()
var currentStreamId by remember { mutableIntStateOf(streamId) }
var currentStreamId by remember { mutableStateOf(streamId) }
var loading by remember { mutableStateOf(true) }
var switching by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
@ -125,9 +125,9 @@ fun LiveTvPlayerScreen(
var zapChannels by remember { mutableStateOf<List<IptvChannel>>(emptyList()) }
var menuLoadGen by remember { mutableIntStateOf(0) }
val menuCache = remember { mutableStateMapOf<String, MenuCategoryCache>() }
val menuEpgNow = remember { mutableStateMapOf<Int, String>() }
val menuEpgNow = remember { mutableStateMapOf<String, String>() }
val menuListState = rememberLazyListState()
val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
val menuChannelFocus = remember { mutableStateMapOf<String, FocusRequester>() }
BindCastPlayerRemote(
api = api,
@ -138,13 +138,13 @@ fun LiveTvPlayerScreen(
onStop = onBack,
)
fun startStream(url: String, format: String) {
player.setMediaItem(liveMediaItem(url, format))
fun startStream(url: String, format: String, drm: nl.vonas.mediacluster.tv.data.DrmInfo? = null) {
player.setMediaItem(liveMediaItem(url, format, drm))
player.prepare()
player.playWhenReady = true
}
suspend fun loadAndPlay(streamIdToPlay: Int, isSwitch: Boolean) {
suspend fun loadAndPlay(streamIdToPlay: String, isSwitch: Boolean) {
if (isSwitch) switching = true else loading = true
error = null
triedFallback = false
@ -152,17 +152,24 @@ fun LiveTvPlayerScreen(
val play = api.iptvPlayUrl(streamIdToPlay)
channelName = play.name
logoUrl = play.logoUrl
val isDash = play.format.equals("dash", ignoreCase = true)
val tsUrl = when {
isDash -> null
play.format.equals("ts", ignoreCase = true) -> play.streamUrl
play.fallbackFormat?.equals("ts", ignoreCase = true) == true -> play.fallbackStreamUrl
else -> null
}
val hlsUrl = when {
isDash -> null
play.format.equals("hls", ignoreCase = true) -> play.streamUrl
play.fallbackFormat?.equals("hls", ignoreCase = true) == true -> play.fallbackStreamUrl
else -> null
}
if (!tsUrl.isNullOrBlank()) {
if (isDash) {
fallbackUrl = null
fallbackFormat = null
startStream(play.streamUrl, play.format, play.drm)
} else if (!tsUrl.isNullOrBlank()) {
fallbackUrl = hlsUrl ?: play.fallbackStreamUrl
fallbackFormat = (hlsUrl ?: play.fallbackStreamUrl)?.let {
if (it.contains("m3u8", true)) "hls" else play.fallbackFormat
@ -171,7 +178,7 @@ fun LiveTvPlayerScreen(
} else {
fallbackUrl = play.fallbackStreamUrl
fallbackFormat = play.fallbackFormat
startStream(play.streamUrl, play.format)
startStream(play.streamUrl, play.format, play.drm)
}
onStreamChange(streamIdToPlay)
} catch (e: Exception) {
@ -207,7 +214,7 @@ fun LiveTvPlayerScreen(
}
}
fun applyMenuEpg(titles: Map<Int, String>) {
fun applyMenuEpg(titles: Map<String, String>) {
for ((id, title) in titles) {
menuEpgNow[id] = title
}
@ -241,7 +248,7 @@ fun LiveTvPlayerScreen(
api.iptvChannels(includeEpg = true)
}
if (gen != menuLoadGen) return
val titles = linkedMapOf<Int, String>()
val titles = linkedMapOf<String, String>()
withEpg.forEach { ch -> ch.nowTitle?.let { titles[ch.streamId] = it } }
applyMenuEpg(titles)
menuCache[key] = MenuCategoryCache(withEpg, titles, System.currentTimeMillis())
@ -376,7 +383,7 @@ fun LiveTvPlayerScreen(
onBack()
}
fun selectChannel(newStreamId: Int) {
fun selectChannel(newStreamId: String) {
if (newStreamId == currentStreamId) return
currentStreamId = newStreamId
infoOverlay = true
@ -631,13 +638,13 @@ private fun LiveChannelSwitcher(
categoryIndex: Int,
categoryCount: Int,
channels: List<IptvChannel>,
epgNow: Map<Int, String>,
currentStreamId: Int,
epgNow: Map<String, String>,
currentStreamId: String,
refreshing: Boolean,
switching: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
channelFocus: MutableMap<Int, FocusRequester>,
onSelect: (Int) -> Unit,
channelFocus: MutableMap<String, FocusRequester>,
onSelect: (String) -> Unit,
onCategoryShift: (Int) -> Unit,
modifier: Modifier = Modifier,
) {

View file

@ -126,7 +126,7 @@ fun McNav() {
// Blijft behouden als main uit compositie gaat (bijv. live player)
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) }
var tvLastStreamId by rememberSaveable { mutableStateOf<Int?>(null) }
var tvLastStreamId by rememberSaveable { mutableStateOf<String?>(null) }
var tvFocusNonce by remember { mutableIntStateOf(0) }
val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet())
val hubTab = remember(hubTabName) {
@ -161,7 +161,7 @@ fun McNav() {
onPlayLive = { streamId ->
tvLastStreamId = streamId
hubTabName = MainTab.Tv.name
nav.navigate("live/$streamId") {
nav.navigate("live/${android.net.Uri.encode(streamId)}") {
launchSingleTop = true
}
},
@ -339,7 +339,7 @@ fun McNav() {
onPlayLive = { streamId ->
tvLastStreamId = streamId
if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name
nav.navigate("live/$streamId")
nav.navigate("live/${android.net.Uri.encode(streamId)}")
},
onSwitchProfile = {
scope.launch {
@ -446,9 +446,11 @@ fun McNav() {
}
composable(
"live/{streamId}",
arguments = listOf(navArgument("streamId") { type = NavType.IntType }),
arguments = listOf(navArgument("streamId") { type = NavType.StringType }),
) { entry ->
val streamId = entry.arguments?.getInt("streamId") ?: return@composable
val streamId = entry.arguments?.getString("streamId")
?.let { android.net.Uri.decode(it) }
?: return@composable
DisposableEffect(Unit) {
onDispose { tvFocusNonce++ }
}
@ -476,10 +478,10 @@ private fun MainHub(
watchlistGridState: androidx.compose.foundation.lazy.grid.LazyGridState,
tvCategoryId: String?,
onTvCategoryChange: (String?) -> Unit,
tvLastStreamId: Int?,
onTvLastStreamChange: (Int?) -> Unit,
favoriteIds: Set<Int>,
onToggleFavorite: (Int) -> Unit,
tvLastStreamId: String?,
onTvLastStreamChange: (String?) -> Unit,
favoriteIds: Set<String>,
onToggleFavorite: (String) -> Unit,
tvFocusNonce: Int,
onTvFocusContent: () -> Unit,
profileAvatarId: String?,
@ -490,7 +492,7 @@ private fun MainHub(
onOpenSeries: (String) -> Unit,
onBrowse: (catalogId: String, title: String) -> Unit,
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
onPlayLive: (Int) -> Unit,
onPlayLive: (String) -> Unit,
onSwitchProfile: () -> Unit,
onManageProfiles: () -> Unit,
onLogout: () -> Unit,

View file

@ -108,12 +108,12 @@ fun TvGuideScreen(
api: ApiClient,
selectedCategoryId: String?,
onCategoryChange: (String?) -> Unit,
favoriteIds: Set<Int> = emptySet(),
onToggleFavorite: (Int) -> Unit = {},
favoriteIds: Set<String> = emptySet(),
onToggleFavorite: (String) -> Unit = {},
focusContentNonce: Int = 0,
onAtTopBack: () -> Unit,
onPlayChannel: (streamId: Int) -> Unit,
onOpenLiveTv: (streamId: Int) -> Unit = {},
onPlayChannel: (streamId: String) -> Unit,
onOpenLiveTv: (streamId: String) -> Unit = {},
onContentFocused: () -> Unit = {},
) {
var categories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }

View file

@ -74,13 +74,13 @@ fun TvScreen(
api: ApiClient,
selectedCategoryId: String?,
onCategoryChange: (String?) -> Unit,
lastStreamId: Int? = null,
onLastStreamChange: (Int?) -> Unit = {},
favoriteIds: Set<Int> = emptySet(),
onToggleFavorite: (Int) -> Unit = {},
lastStreamId: String? = null,
onLastStreamChange: (String?) -> Unit = {},
favoriteIds: Set<String> = emptySet(),
onToggleFavorite: (String) -> Unit = {},
focusContentNonce: Int = 0,
onAtTopBack: () -> Unit,
onPlayChannel: (streamId: Int) -> Unit,
onPlayChannel: (streamId: String) -> Unit,
onContentFocused: () -> Unit = {},
) {
var categories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }
@ -90,7 +90,7 @@ fun TvScreen(
var error by remember { mutableStateOf<String?>(null) }
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
var epgLoading by remember { mutableStateOf(false) }
var focusedStreamId by remember { mutableStateOf<Int?>(null) }
var focusedStreamId by remember { mutableStateOf<String?>(null) }
var epgJob by remember { mutableStateOf<Job?>(null) }
var focusInList by remember { mutableStateOf(false) }
val channelCache = remember { mutableStateMapOf<String, List<IptvChannel>>() }
@ -98,9 +98,9 @@ fun TvScreen(
val categoryRowState = rememberLazyListState()
val scope = rememberCoroutineScope()
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
val channelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
val epgNowTitles = remember { mutableStateMapOf<Int, String>() }
val epgNextTitles = remember { mutableStateMapOf<Int, String>() }
val channelFocus = remember { mutableStateMapOf<String, FocusRequester>() }
val epgNowTitles = remember { mutableStateMapOf<String, String>() }
val epgNextTitles = remember { mutableStateMapOf<String, String>() }
val displayCategories = remember(categories, favoriteIds) {
val fav = IptvCategory(
@ -294,7 +294,7 @@ fun TvScreen(
onAtTopBack()
}
fun loadEpg(streamId: Int) {
fun loadEpg(streamId: String) {
focusedStreamId = streamId
epgJob?.cancel()
epgJob = scope.launch {

View file

@ -0,0 +1,71 @@
-- Custom ClearKey/DASH live channel lists
CREATE TABLE IF NOT EXISTS "live_channel_lists" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "live_channel_lists_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "live_channel_lists_slug_key" ON "live_channel_lists"("slug");
CREATE INDEX IF NOT EXISTS "live_channel_lists_sort_order_idx" ON "live_channel_lists"("sort_order");
CREATE TABLE IF NOT EXISTS "live_channels" (
"id" TEXT NOT NULL,
"list_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"logo_url" TEXT,
"mpd_url" TEXT NOT NULL,
"kid_enc" TEXT NOT NULL,
"key_enc" TEXT NOT NULL,
"epg_channel_id" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "live_channels_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "live_channels_list_id_sort_order_idx" ON "live_channels"("list_id", "sort_order");
DO $$ BEGIN
ALTER TABLE "live_channels"
ADD CONSTRAINT "live_channels_list_id_fkey"
FOREIGN KEY ("list_id") REFERENCES "live_channel_lists"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE TABLE IF NOT EXISTS "viewer_live_channel_lists" (
"id" TEXT NOT NULL,
"viewer_user_id" TEXT NOT NULL,
"list_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "viewer_live_channel_lists_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "viewer_live_channel_lists_viewer_user_id_list_id_key"
ON "viewer_live_channel_lists"("viewer_user_id", "list_id");
CREATE INDEX IF NOT EXISTS "viewer_live_channel_lists_viewer_user_id_idx"
ON "viewer_live_channel_lists"("viewer_user_id");
CREATE INDEX IF NOT EXISTS "viewer_live_channel_lists_list_id_idx"
ON "viewer_live_channel_lists"("list_id");
DO $$ BEGIN
ALTER TABLE "viewer_live_channel_lists"
ADD CONSTRAINT "viewer_live_channel_lists_viewer_user_id_fkey"
FOREIGN KEY ("viewer_user_id") REFERENCES "viewer_users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "viewer_live_channel_lists"
ADD CONSTRAINT "viewer_live_channel_lists_list_id_fkey"
FOREIGN KEY ("list_id") REFERENCES "live_channel_lists"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

View file

@ -97,6 +97,7 @@ model ViewerUser {
iptvAddonToken AddonToken? @relation("ViewerLegacyIptvToken", fields: [iptvAddonTokenId], references: [id], onDelete: SetNull)
iptvLine IptvLine?
addonTokens AddonToken[] @relation("ViewerAddonTokens")
liveChannelLists ViewerLiveChannelList[]
@@map("viewer_users")
}
@ -299,6 +300,62 @@ model IptvLine {
@@map("iptv_lines")
}
/** Admin-beheerde ClearKey/DASH-zenderlijst (bijv. "Nederland"). */
model LiveChannelList {
id String @id @default(uuid())
name String
slug String @unique
sortOrder Int @default(0) @map("sort_order")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
channels LiveChannel[]
viewers ViewerLiveChannelList[]
@@index([sortOrder])
@@map("live_channel_lists")
}
model LiveChannel {
id String @id @default(uuid())
listId String @map("list_id")
name String
logoUrl String? @map("logo_url")
mpdUrl String @map("mpd_url") @db.Text
/** AES-GCM ciphertext (base64) — ClearKey KID */
kidEnc String @map("kid_enc") @db.Text
/** AES-GCM ciphertext (base64) — ClearKey KEY */
keyEnc String @map("key_enc") @db.Text
/** XMLTV channel id voor EPG-mapping */
epgChannelId String? @map("epg_channel_id")
sortOrder Int @default(0) @map("sort_order")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
list LiveChannelList @relation(fields: [listId], references: [id], onDelete: Cascade)
@@index([listId, sortOrder])
@@map("live_channels")
}
/** Welke custom live-lijsten een kijker mag zien. */
model ViewerLiveChannelList {
id String @id @default(uuid())
viewerUserId String @map("viewer_user_id")
listId String @map("list_id")
createdAt DateTime @default(now()) @map("created_at")
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
list LiveChannelList @relation(fields: [listId], references: [id], onDelete: Cascade)
@@unique([viewerUserId, listId])
@@index([viewerUserId])
@@index([listId])
@@map("viewer_live_channel_lists")
}
model Location {
id String @id @default(uuid())
name String @unique

View file

@ -1555,6 +1555,80 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
const movie = await prisma.movie.create({ data: body });
return { movie };
});
// --- Custom ClearKey/DASH live channel lists ---
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels");
return { lists: await listLiveChannelListsAdmin() };
});
app.get("/api/v1/admin/live-lists/epg-channels", { preHandler: requireAdmin }, async () => {
const { listEpgChannelOptions } = await import("../viewer/live-channels");
return { channels: await listEpgChannelOptions(config) };
});
app.get("/api/v1/admin/live-lists/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const { getLiveChannelListAdmin } = await import("../viewer/live-channels");
return getLiveChannelListAdmin(id);
});
app.post("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async (request) => {
const body = request.body as { name?: string; sortOrder?: number; enabled?: boolean };
const { createLiveChannelList } = await import("../viewer/live-channels");
return createLiveChannelList(body);
});
app.patch("/api/v1/admin/live-lists/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const body = request.body as { name?: string; sortOrder?: number; enabled?: boolean };
const { updateLiveChannelList } = await import("../viewer/live-channels");
return updateLiveChannelList(id, body);
});
app.delete("/api/v1/admin/live-lists/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const { deleteLiveChannelList } = await import("../viewer/live-channels");
return deleteLiveChannelList(id);
});
app.post("/api/v1/admin/live-lists/:id/channels", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const body = request.body as {
name?: string;
logoUrl?: string | null;
mpdUrl?: string;
kid?: string;
key?: string;
epgChannelId?: string | null;
sortOrder?: number;
enabled?: boolean;
};
const { createLiveChannel } = await import("../viewer/live-channels");
return createLiveChannel(id, body, config.SESSION_SECRET);
});
app.patch("/api/v1/admin/live-channels/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const body = request.body as {
name?: string;
logoUrl?: string | null;
mpdUrl?: string;
kid?: string;
key?: string;
epgChannelId?: string | null;
sortOrder?: number;
enabled?: boolean;
};
const { updateLiveChannel } = await import("../viewer/live-channels");
return updateLiveChannel(id, body, config.SESSION_SECRET);
});
app.delete("/api/v1/admin/live-channels/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const { deleteLiveChannel } = await import("../viewer/live-channels");
return deleteLiveChannel(id);
});
}
export async function rotateNodeCredentials(nodeId: string): Promise<string> {

View file

@ -1014,7 +1014,33 @@ export function mergeXmltvGuides(
return out;
}
async function fetchXmltvText(urls: string[]): Promise<string> {
/** Channel id → display-name from `<channel>` tags (for admin EPG picker). */
export function parseXmltvChannelIndex(xml: string): Map<string, string> {
const out = new Map<string, string>();
const re = /<channel\b([^>]*)>([\s\S]*?)<\/channel>/gi;
let match: RegExpExecArray | null;
while ((match = re.exec(xml)) !== null) {
const id = match[1].match(/\bid="([^"]+)"/i)?.[1]?.trim();
if (!id) continue;
const name =
firstXmlTagText(match[2], "display-name")?.trim() ||
firstXmlTagText(match[2], "display-name") ||
id;
if (!out.has(id)) out.set(id, name || id);
const lower = id.toLowerCase();
if (lower !== id && !out.has(lower)) out.set(lower, name || id);
}
// Also collect programme channel ids without a <channel> block
const progRe = /<programme\b([^>]*)>/gi;
while ((match = progRe.exec(xml)) !== null) {
const id = match[1].match(/\bchannel="([^"]+)"/i)?.[1]?.trim();
if (!id || out.has(id)) continue;
out.set(id, id);
}
return out;
}
export async function fetchXmltvText(urls: string[]): Promise<string> {
let lastErr: unknown;
for (const url of urls) {
try {
@ -1028,7 +1054,7 @@ async function fetchXmltvText(urls: string[]): Promise<string> {
});
if (!res.ok) throw new Error(`XMLTV HTTP ${res.status}`);
const xml = await res.text();
if (xml.includes("<programme") || xml.includes("<tv")) return xml;
if (xml.includes("<programme") || xml.includes("<tv") || xml.includes("<channel")) return xml;
} catch (err) {
lastErr = err;
}

View file

@ -52,72 +52,106 @@ export function friendlyCategoryName(raw: string): string {
}
export async function getViewerIptvStatus(viewerId: string, config: Config) {
const { countCustomLiveForViewer } = await import("./live-channels");
const customCount = await countCustomLiveForViewer(viewerId);
const xt = await loadXtreamForViewer(viewerId, config);
if (!xt?.line.enableLive) return { enabled: false as const };
if (!xt?.line.enableLive) {
if (customCount > 0) {
return { enabled: true as const, categoryCount: 0, customChannelCount: customCount };
}
return { enabled: false as const };
}
const cats = await xt.client.getLiveCategories().catch(() => []);
return {
enabled: true as const,
categoryCount: cats.length,
customChannelCount: customCount,
};
}
export async function getViewerIptvCategories(viewerId: string, config: Config) {
const {
listAssignedLiveListsForViewer,
viewerHasCustomLive,
} = await import("./live-channels");
const hasCustom = await viewerHasCustomLive(viewerId);
const xt = await loadXtreamForViewer(viewerId, config);
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
// Categorieën + (korte) zenderlijst parallel — categorieën nooit laten falen op trage streams
const [catsRaw, streamsRaw, iptvPrefs] = await Promise.all([
xt.client.getLiveCategories().catch(() => [] as Awaited<ReturnType<typeof xt.client.getLiveCategories>>),
Promise.race([
xt.client.getLiveStreams().catch(() => [] as XtreamLiveStream[]),
new Promise<XtreamLiveStream[]>((resolve) => setTimeout(() => resolve([]), 6_000)),
]),
getIptvSettings(config),
]);
const cats = Array.isArray(catsRaw) ? catsRaw : [];
const streams: XtreamLiveStream[] = Array.isArray(streamsRaw) ? streamsRaw : [];
const counts = new Map<string, number>();
for (const s of streams) {
const key = normalizeIptvCategoryId(s.category_id);
if (!key) continue;
counts.set(key, (counts.get(key) ?? 0) + 1);
if (!xt?.line.enableLive && !hasCustom) {
throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
}
let categories = cats
.map((c) => {
const id = normalizeIptvCategoryId(c.category_id);
const rawName = String(c.category_name ?? "");
return {
let categories: Array<{
id: string;
name: string;
rawName: string;
channelCount: number;
}> = [];
if (xt?.line.enableLive) {
const [catsRaw, streamsRaw, iptvPrefs] = await Promise.all([
xt.client.getLiveCategories().catch(() => [] as Awaited<ReturnType<typeof xt.client.getLiveCategories>>),
Promise.race([
xt.client.getLiveStreams().catch(() => [] as XtreamLiveStream[]),
new Promise<XtreamLiveStream[]>((resolve) => setTimeout(() => resolve([]), 6_000)),
]),
getIptvSettings(config),
]);
const cats = Array.isArray(catsRaw) ? catsRaw : [];
const streams: XtreamLiveStream[] = Array.isArray(streamsRaw) ? streamsRaw : [];
const counts = new Map<string, number>();
for (const s of streams) {
const key = normalizeIptvCategoryId(s.category_id);
if (!key) continue;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
categories = cats
.map((c) => {
const id = normalizeIptvCategoryId(c.category_id);
const rawName = String(c.category_name ?? "");
return {
id,
name: friendlyCategoryName(rawName),
rawName,
channelCount: counts.get(id) ?? 0,
};
})
.filter((c) => c.id.length > 0 && !isAggregateCategoryName(c.rawName));
if (categories.length === 0 && counts.size > 0) {
categories = [...counts.entries()].map(([id, channelCount]) => ({
id,
name: friendlyCategoryName(rawName),
rawName,
channelCount: counts.get(id) ?? 0,
};
})
// Alleen pure provider-dump "Alles/All" weg — niet "NL | ALL" landpakketten
.filter((c) => c.id.length > 0 && !isAggregateCategoryName(c.rawName));
name: `Groep ${id}`,
rawName: `Groep ${id}`,
channelCount,
}));
}
// Fallback: bouquets uit zenders afleiden als get_live_categories leeg/fout is
if (categories.length === 0 && counts.size > 0) {
categories = [...counts.entries()].map(([id, channelCount]) => ({
id,
name: `Groep ${id}`,
rawName: `Groep ${id}`,
channelCount,
}));
const prefs = iptvPrefs ?? (await getIptvSettings(config));
categories = applyIptvCategoryOrder(categories, prefs.categoryOrder);
if (prefs.showAlleZenders) {
categories.unshift({
id: "__all__",
name: "Alle zenders",
rawName: "Alle zenders",
channelCount: streams.length,
});
}
}
categories = applyIptvCategoryOrder(categories, iptvPrefs.categoryOrder);
if (iptvPrefs.showAlleZenders) {
categories.unshift({
id: "__all__",
name: "Alle zenders",
rawName: "Alle zenders",
channelCount: streams.length,
const customLists = await listAssignedLiveListsForViewer(viewerId);
for (const list of customLists) {
categories.push({
id: list.id,
name: list.name,
rawName: list.name,
channelCount: list.channelCount,
});
}
@ -212,6 +246,12 @@ export async function getViewerIptvChannels(
categoryId?: string,
includeEpg = false
) {
const { isCustomCategoryId, getCustomChannelsForCategory } = await import("./live-channels");
if (categoryId && isCustomCategoryId(categoryId)) {
const channels = await getCustomChannelsForCategory(viewerId, categoryId, config);
return { channels };
}
const xt = await loadXtreamForViewer(viewerId, config);
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
@ -236,7 +276,7 @@ export async function getViewerIptvChannels(
const pair = epgTitles?.get(s.stream_id);
const cached = getCachedStreamEpg(xt.cacheKey, s.stream_id);
return {
streamId: s.stream_id,
streamId: String(s.stream_id),
name: s.name.trim(),
logoUrl: s.stream_icon || null,
number: s.num ?? null,
@ -258,9 +298,41 @@ export async function getViewerIptvGuide(
categoryId?: string,
opts: { limit?: number; offset?: number } = {}
) {
const {
isCustomCategoryId,
getCustomGuideChannels,
listAssignedLiveListsForViewer,
} = await import("./live-channels");
if (categoryId && isCustomCategoryId(categoryId)) {
const custom = await getCustomGuideChannels(viewerId, categoryId, config, opts);
const nowMs = Date.now();
return {
generatedAt: new Date(nowMs).toISOString(),
windowStart: new Date(nowMs - 2 * 60 * 60_000).toISOString(),
windowEnd: new Date(nowMs + 24 * 60 * 60_000).toISOString(),
total: custom.total,
offset: custom.offset,
limit: custom.limit,
channels: custom.channels,
};
}
const xt = await loadXtreamForViewer(viewerId, config);
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
if (!xt?.line.enableLive) {
// Alleen custom lijsten
const custom = await getCustomGuideChannels(viewerId, undefined, config, opts);
const nowMs = Date.now();
return {
generatedAt: new Date(nowMs).toISOString(),
windowStart: new Date(nowMs - 2 * 60 * 60_000).toISOString(),
windowEnd: new Date(nowMs + 24 * 60 * 60_000).toISOString(),
total: custom.total,
offset: custom.offset,
limit: custom.limit,
channels: custom.channels,
};
}
await applyIptvEpgUrlsToRuntime(config);
@ -307,7 +379,7 @@ export async function getViewerIptvGuide(
}
return {
streamId: s.stream_id,
streamId: String(s.stream_id),
name: s.name.trim(),
logoUrl: s.stream_icon || null,
number: s.num ?? null,
@ -318,11 +390,20 @@ export async function getViewerIptvGuide(
};
});
// Append custom lists when browsing "all" / no category
if (!categoryId || categoryId === "__all__") {
const custom = await getCustomGuideChannels(viewerId, undefined, config, {
limit: 150,
offset: 0,
});
channels.push(...custom.channels);
}
return {
generatedAt: new Date(nowMs).toISOString(),
windowStart: new Date(nowMs - 2 * 60 * 60_000).toISOString(),
windowEnd: new Date(nowMs + 24 * 60 * 60_000).toISOString(),
total: streams.length,
total: streams.length + (await listAssignedLiveListsForViewer(viewerId)).length,
offset,
limit,
channels,
@ -332,17 +413,26 @@ export async function getViewerIptvGuide(
export async function getViewerIptvPlayUrl(
viewerId: string,
config: Config,
streamId: number
streamId: string | number
) {
const idStr = String(streamId);
const { isCustomStreamId, getCustomPlayUrl } = await import("./live-channels");
if (isCustomStreamId(idStr)) {
return getCustomPlayUrl(viewerId, idStr, config.SESSION_SECRET);
}
const id = typeof streamId === "number" ? streamId : parseInt(idStr, 10);
if (!Number.isFinite(id)) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400);
const xt = await loadXtreamForViewer(viewerId, config);
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
const streams = await xt.client.getLiveStreams();
const ch = streams.find((s) => s.stream_id === streamId);
const ch = streams.find((s) => s.stream_id === id);
if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
const resolved = await xt.client.resolveLivePlayUrls(streamId);
const resolved = await xt.client.resolveLivePlayUrls(id);
const classicTs = resolved.classic.find((u) => /\.ts$/i.test(u));
const classicHls = resolved.classic.find((u) => /\.m3u8$/i.test(u) || u.includes("m3u8"));
@ -362,22 +452,36 @@ export async function getViewerIptvPlayUrl(
void import("../iptv/active-watches")
.then(({ touchIptvWatchForViewer }) =>
touchIptvWatchForViewer(viewerId, "live", streamId, ch.name)
touchIptvWatchForViewer(viewerId, "live", id, ch.name)
)
.catch(() => undefined);
return {
streamId,
streamId: String(id),
name: ch.name,
logoUrl: ch.stream_icon || null,
streamUrl: primary,
format: formatFor(primary),
fallbackStreamUrl: fallback ?? null,
fallbackFormat: fallback ? formatFor(fallback) : null,
drm: null,
};
}
export async function getViewerIptvEpg(viewerId: string, config: Config, streamId: number) {
export async function getViewerIptvEpg(
viewerId: string,
config: Config,
streamId: string | number
) {
const idStr = String(streamId);
const { isCustomStreamId, getCustomEpg } = await import("./live-channels");
if (isCustomStreamId(idStr)) {
return getCustomEpg(viewerId, idStr, config);
}
const id = typeof streamId === "number" ? streamId : parseInt(idStr, 10);
if (!Number.isFinite(id)) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400);
const xt = await loadXtreamForViewer(viewerId, config);
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
@ -385,19 +489,19 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
await applyIptvEpgUrlsToRuntime(config);
const streams = await xt.client.getLiveStreams();
const ch = streams.find((s) => s.stream_id === streamId);
const ch = streams.find((s) => s.stream_id === id);
if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
void import("../iptv/active-watches")
.then(({ refreshIptvWatch }) =>
refreshIptvWatch({ iptvLineId: xt.cacheKey, streamId, title: ch.name })
refreshIptvWatch({ iptvLineId: xt.cacheKey, streamId: id, title: ch.name })
)
.catch(() => undefined);
const cached = getCachedStreamEpg(xt.cacheKey, streamId);
const cached = getCachedStreamEpg(xt.cacheKey, id);
if (cached && (cached.now || cached.next || cached.schedule.length > 0)) {
return {
streamId,
streamId: String(id),
channelName: ch.name,
logoUrl: ch.stream_icon || null,
now: cached.now,
@ -416,9 +520,9 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
guides.get(epgId.toLowerCase()) ??
guides.get(epgId.toUpperCase());
if (hit && (hit.now || hit.next || hit.schedule.length > 0)) {
setCachedStreamEpg(xt.cacheKey, streamId, hit.now, hit.next, hit.schedule);
setCachedStreamEpg(xt.cacheKey, id, hit.now, hit.next, hit.schedule);
return {
streamId,
streamId: String(id),
channelName: ch.name,
logoUrl: ch.stream_icon || null,
now: hit.now,
@ -431,12 +535,12 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
}
}
const epg = await xt.client.getShortEpg(streamId, 48);
const epg = await xt.client.getShortEpg(id, 48);
const { now: nowProgram, next: nextProgram, schedule } = parseRichShortEpg(epg);
setCachedStreamEpg(xt.cacheKey, streamId, nowProgram, nextProgram, schedule);
setCachedStreamEpg(xt.cacheKey, id, nowProgram, nextProgram, schedule);
return {
streamId,
streamId: String(id),
channelName: ch.name,
logoUrl: ch.stream_icon || null,
now: nowProgram,

View file

@ -0,0 +1,597 @@
import { prisma } from "../database/client";
import type { Config } from "../config";
import { AppError } from "../security/errors";
import { decryptSecret, encryptSecret } from "../security/crypto";
import { applyIptvEpgUrlsToRuntime, getIptvSettings } from "../settings/iptv";
import {
fetchXmltvText,
mergeXmltvGuides,
parseXmltvChannelIndex,
parseXmltvGuides,
type XmltvChannelGuide,
} from "../iptv/xtream";
import { loadXtreamByLineId } from "../iptv/service";
export const CUSTOM_CAT_PREFIX = "custom:";
export const CUSTOM_STREAM_PREFIX = "custom:";
export function isCustomCategoryId(id: string | undefined | null): boolean {
return !!id && id.startsWith(CUSTOM_CAT_PREFIX);
}
export function isCustomStreamId(id: string | undefined | null): boolean {
return !!id && id.startsWith(CUSTOM_STREAM_PREFIX);
}
export function customCategoryId(listId: string): string {
return `${CUSTOM_CAT_PREFIX}${listId}`;
}
export function customStreamId(channelId: string): string {
return `${CUSTOM_STREAM_PREFIX}${channelId}`;
}
export function parseCustomListId(categoryId: string): string | null {
if (!isCustomCategoryId(categoryId)) return null;
return categoryId.slice(CUSTOM_CAT_PREFIX.length) || null;
}
export function parseCustomChannelId(streamId: string): string | null {
if (!isCustomStreamId(streamId)) return null;
return streamId.slice(CUSTOM_STREAM_PREFIX.length) || null;
}
function slugify(name: string): string {
const base = name
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
return base || "lijst";
}
async function uniqueSlug(name: string, excludeId?: string): Promise<string> {
const base = slugify(name);
let slug = base;
let n = 2;
for (;;) {
const hit = await prisma.liveChannelList.findUnique({ where: { slug } });
if (!hit || hit.id === excludeId) return slug;
slug = `${base}-${n}`;
n += 1;
}
}
function normalizeHexKey(raw: string, label: string): string {
const s = raw.trim().replace(/^0x/i, "").replace(/[\s:-]/g, "").toLowerCase();
if (!/^[0-9a-f]{32}$/.test(s)) {
throw new AppError(
"INVALID_REQUEST",
`${label} moet 32 hex-tekens zijn (16 bytes ClearKey)`,
400
);
}
return s;
}
function maskSecret(hasValue: boolean): string {
return hasValue ? "********" : "";
}
export async function listLiveChannelListsAdmin() {
const lists = await prisma.liveChannelList.findMany({
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
include: {
_count: { select: { channels: true, viewers: true } },
},
});
return lists.map((l) => ({
id: l.id,
name: l.name,
slug: l.slug,
sortOrder: l.sortOrder,
enabled: l.enabled,
channelCount: l._count.channels,
viewerCount: l._count.viewers,
updatedAt: l.updatedAt.toISOString(),
}));
}
export async function getLiveChannelListAdmin(listId: string) {
const list = await prisma.liveChannelList.findUnique({
where: { id: listId },
include: {
channels: { orderBy: [{ sortOrder: "asc" }, { name: "asc" }] },
},
});
if (!list) throw new AppError("NOT_FOUND", "Lijst niet gevonden", 404);
return {
id: list.id,
name: list.name,
slug: list.slug,
sortOrder: list.sortOrder,
enabled: list.enabled,
channels: list.channels.map((c) => ({
id: c.id,
name: c.name,
logoUrl: c.logoUrl,
mpdUrl: c.mpdUrl,
kid: maskSecret(!!c.kidEnc),
key: maskSecret(!!c.keyEnc),
hasKid: !!c.kidEnc,
hasKey: !!c.keyEnc,
epgChannelId: c.epgChannelId,
sortOrder: c.sortOrder,
enabled: c.enabled,
})),
};
}
export async function createLiveChannelList(
body: { name?: string; sortOrder?: number; enabled?: boolean }
) {
const name = body.name?.trim();
if (!name) throw new AppError("INVALID_REQUEST", "name verplicht", 400);
const slug = await uniqueSlug(name);
const maxOrder = await prisma.liveChannelList.aggregate({ _max: { sortOrder: true } });
const list = await prisma.liveChannelList.create({
data: {
name,
slug,
sortOrder: body.sortOrder ?? (maxOrder._max.sortOrder ?? 0) + 10,
enabled: body.enabled !== false,
},
});
return getLiveChannelListAdmin(list.id);
}
export async function updateLiveChannelList(
listId: string,
body: { name?: string; sortOrder?: number; enabled?: boolean }
) {
const existing = await prisma.liveChannelList.findUnique({ where: { id: listId } });
if (!existing) throw new AppError("NOT_FOUND", "Lijst niet gevonden", 404);
const name = body.name?.trim();
const slug =
name && name !== existing.name ? await uniqueSlug(name, listId) : undefined;
await prisma.liveChannelList.update({
where: { id: listId },
data: {
...(name ? { name, slug } : {}),
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
},
});
return getLiveChannelListAdmin(listId);
}
export async function deleteLiveChannelList(listId: string) {
const existing = await prisma.liveChannelList.findUnique({ where: { id: listId } });
if (!existing) throw new AppError("NOT_FOUND", "Lijst niet gevonden", 404);
await prisma.liveChannelList.delete({ where: { id: listId } });
return { ok: true };
}
export async function createLiveChannel(
listId: string,
body: {
name?: string;
logoUrl?: string | null;
mpdUrl?: string;
kid?: string;
key?: string;
epgChannelId?: string | null;
sortOrder?: number;
enabled?: boolean;
},
sessionSecret: string
) {
const list = await prisma.liveChannelList.findUnique({ where: { id: listId } });
if (!list) throw new AppError("NOT_FOUND", "Lijst niet gevonden", 404);
const name = body.name?.trim();
const mpdUrl = body.mpdUrl?.trim();
const kid = body.kid?.trim();
const key = body.key?.trim();
if (!name || !mpdUrl || !kid || !key) {
throw new AppError("INVALID_REQUEST", "name, mpdUrl, kid en key verplicht", 400);
}
const kidNorm = normalizeHexKey(kid, "kid");
const keyNorm = normalizeHexKey(key, "key");
const maxOrder = await prisma.liveChannel.aggregate({
where: { listId },
_max: { sortOrder: true },
});
await prisma.liveChannel.create({
data: {
listId,
name,
logoUrl: body.logoUrl?.trim() || null,
mpdUrl,
kidEnc: encryptSecret(kidNorm, sessionSecret),
keyEnc: encryptSecret(keyNorm, sessionSecret),
epgChannelId: body.epgChannelId?.trim() || null,
sortOrder: body.sortOrder ?? (maxOrder._max.sortOrder ?? 0) + 10,
enabled: body.enabled !== false,
},
});
return getLiveChannelListAdmin(listId);
}
export async function updateLiveChannel(
channelId: string,
body: {
name?: string;
logoUrl?: string | null;
mpdUrl?: string;
kid?: string;
key?: string;
epgChannelId?: string | null;
sortOrder?: number;
enabled?: boolean;
},
sessionSecret: string
) {
const existing = await prisma.liveChannel.findUnique({ where: { id: channelId } });
if (!existing) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
const kidRaw = body.kid?.trim();
const keyRaw = body.key?.trim();
const kidEnc =
kidRaw && kidRaw !== "********"
? encryptSecret(normalizeHexKey(kidRaw, "kid"), sessionSecret)
: undefined;
const keyEnc =
keyRaw && keyRaw !== "********"
? encryptSecret(normalizeHexKey(keyRaw, "key"), sessionSecret)
: undefined;
await prisma.liveChannel.update({
where: { id: channelId },
data: {
...(body.name?.trim() ? { name: body.name.trim() } : {}),
...(body.logoUrl !== undefined ? { logoUrl: body.logoUrl?.trim() || null } : {}),
...(body.mpdUrl?.trim() ? { mpdUrl: body.mpdUrl.trim() } : {}),
...(kidEnc ? { kidEnc } : {}),
...(keyEnc ? { keyEnc } : {}),
...(body.epgChannelId !== undefined
? { epgChannelId: body.epgChannelId?.trim() || null }
: {}),
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
},
});
return getLiveChannelListAdmin(existing.listId);
}
export async function deleteLiveChannel(channelId: string) {
const existing = await prisma.liveChannel.findUnique({ where: { id: channelId } });
if (!existing) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
await prisma.liveChannel.delete({ where: { id: channelId } });
return getLiveChannelListAdmin(existing.listId);
}
export async function getViewerLiveListIds(viewerId: string): Promise<string[]> {
const rows = await prisma.viewerLiveChannelList.findMany({
where: { viewerUserId: viewerId },
select: { listId: true },
});
return rows.map((r) => r.listId);
}
export async function setViewerLiveLists(viewerId: string, listIds: string[]) {
const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId } });
if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404);
const unique = [...new Set(listIds.map((id) => id.trim()).filter(Boolean))];
if (unique.length) {
const found = await prisma.liveChannelList.findMany({
where: { id: { in: unique } },
select: { id: true },
});
if (found.length !== unique.length) {
throw new AppError("INVALID_REQUEST", "Onbekende live-lijst", 400);
}
}
await prisma.$transaction([
prisma.viewerLiveChannelList.deleteMany({ where: { viewerUserId: viewerId } }),
...(unique.length
? [
prisma.viewerLiveChannelList.createMany({
data: unique.map((listId) => ({ viewerUserId: viewerId, listId })),
}),
]
: []),
]);
return { listIds: unique };
}
export async function listAssignedLiveListsForViewer(viewerId: string) {
const rows = await prisma.viewerLiveChannelList.findMany({
where: {
viewerUserId: viewerId,
list: { enabled: true },
},
include: {
list: {
include: {
_count: { select: { channels: { where: { enabled: true } } } },
},
},
},
orderBy: { list: { sortOrder: "asc" } },
});
return rows.map((r) => ({
id: customCategoryId(r.list.id),
listId: r.list.id,
name: r.list.name,
channelCount: r.list._count.channels,
}));
}
export async function viewerHasCustomLive(viewerId: string): Promise<boolean> {
const n = await prisma.viewerLiveChannelList.count({
where: {
viewerUserId: viewerId,
list: { enabled: true, channels: { some: { enabled: true } } },
},
});
return n > 0;
}
export async function getCustomChannelsForCategory(
viewerId: string,
categoryId: string,
config: Config
) {
const listId = parseCustomListId(categoryId);
if (!listId) throw new AppError("NOT_FOUND", "Categorie niet gevonden", 404);
const assigned = await prisma.viewerLiveChannelList.findFirst({
where: { viewerUserId: viewerId, listId, list: { enabled: true } },
});
if (!assigned) throw new AppError("FORBIDDEN", "Geen toegang tot deze lijst", 403);
const channels = await prisma.liveChannel.findMany({
where: { listId, enabled: true },
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
});
await applyIptvEpgUrlsToRuntime(config);
const guides = await loadXmltvGuidesForEpg(config).catch(() => new Map<string, XmltvChannelGuide>());
return channels.map((c, idx) => {
const epgId = c.epgChannelId?.trim();
const hit = epgId
? guides.get(epgId) ??
guides.get(epgId.toLowerCase()) ??
guides.get(epgId.toUpperCase())
: undefined;
return {
streamId: customStreamId(c.id),
name: c.name,
logoUrl: c.logoUrl,
number: idx + 1,
categoryId: customCategoryId(listId),
nowTitle: hit?.now?.title ?? null,
nextTitle: hit?.next?.title ?? null,
nowImageUrl: hit?.now?.imageUrl ?? null,
};
});
}
export async function getCustomPlayUrl(
viewerId: string,
streamId: string,
sessionSecret: string
) {
const channelId = parseCustomChannelId(streamId);
if (!channelId) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
const channel = await prisma.liveChannel.findFirst({
where: {
id: channelId,
enabled: true,
list: {
enabled: true,
viewers: { some: { viewerUserId: viewerId } },
},
},
});
if (!channel) throw new AppError("FORBIDDEN", "Geen toegang tot deze zender", 403);
const kid = decryptSecret(channel.kidEnc, sessionSecret);
const key = decryptSecret(channel.keyEnc, sessionSecret);
return {
streamId: customStreamId(channel.id),
name: channel.name,
logoUrl: channel.logoUrl,
streamUrl: channel.mpdUrl,
format: "dash" as const,
fallbackStreamUrl: null,
fallbackFormat: null,
drm: {
type: "clearkey" as const,
keys: [{ kid, key }],
},
};
}
export async function getCustomEpg(
viewerId: string,
streamId: string,
config: Config
) {
const channelId = parseCustomChannelId(streamId);
if (!channelId) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
const channel = await prisma.liveChannel.findFirst({
where: {
id: channelId,
enabled: true,
list: {
enabled: true,
viewers: { some: { viewerUserId: viewerId } },
},
},
});
if (!channel) throw new AppError("FORBIDDEN", "Geen toegang tot deze zender", 403);
await applyIptvEpgUrlsToRuntime(config);
const guides = await loadXmltvGuidesForEpg(config).catch(() => new Map<string, XmltvChannelGuide>());
const epgId = channel.epgChannelId?.trim();
const hit = epgId
? guides.get(epgId) ??
guides.get(epgId.toLowerCase()) ??
guides.get(epgId.toUpperCase())
: undefined;
return {
streamId: customStreamId(channel.id),
channelName: channel.name,
logoUrl: channel.logoUrl,
now: hit?.now ?? null,
next: hit?.next ?? null,
schedule: hit?.schedule ?? [],
};
}
export async function getCustomGuideChannels(
viewerId: string,
categoryId: string | undefined,
config: Config,
opts: { limit?: number; offset?: number } = {}
) {
const lists = await listAssignedLiveListsForViewer(viewerId);
const targetLists = categoryId && isCustomCategoryId(categoryId)
? lists.filter((l) => l.id === categoryId)
: categoryId
? []
: lists;
if (!targetLists.length && categoryId && isCustomCategoryId(categoryId)) {
throw new AppError("FORBIDDEN", "Geen toegang tot deze lijst", 403);
}
await applyIptvEpgUrlsToRuntime(config);
const guides = await loadXmltvGuidesForEpg(config).catch(() => new Map<string, XmltvChannelGuide>());
const allChannels = [];
for (const list of targetLists) {
const listId = parseCustomListId(list.id)!;
const channels = await prisma.liveChannel.findMany({
where: { listId, enabled: true },
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
});
for (const c of channels) {
const epgId = c.epgChannelId?.trim();
const hit = epgId
? guides.get(epgId) ??
guides.get(epgId.toLowerCase()) ??
guides.get(epgId.toUpperCase())
: undefined;
allChannels.push({
streamId: customStreamId(c.id),
name: c.name,
logoUrl: c.logoUrl,
number: null as number | null,
categoryId: list.id,
now: hit?.now ?? null,
next: hit?.next ?? null,
schedule: hit?.schedule ?? [],
});
}
}
const offset = Math.max(0, opts.offset ?? 0);
const limit = Math.min(Math.max(1, opts.limit ?? 80), 150);
return {
total: allChannels.length,
offset,
limit,
channels: allChannels.slice(offset, offset + limit),
};
}
/** Admin EPG-picker: XMLTV channel id + display name. */
export async function listEpgChannelOptions(config: Config) {
await applyIptvEpgUrlsToRuntime(config);
const index = await loadXmltvChannelIndex(config);
return [...index.entries()]
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name, "nl"));
}
async function loadXmltvGuidesForEpg(
config: Config
): Promise<Map<string, XmltvChannelGuide>> {
const line = await prisma.iptvLine.findFirst({
where: { enableLive: true },
orderBy: { updatedAt: "desc" },
select: { id: true },
});
if (line) {
const xt = await loadXtreamByLineId(line.id, config);
if (xt) {
try {
return await xt.client.getXmltvGuidesByChannelId();
} catch {
/* fall through to external-only */
}
}
}
const settings = await getIptvSettings(config);
if (!settings.epgXmlUrls.length) return new Map();
const maps = await Promise.all(
settings.epgXmlUrls.map((url) =>
fetchXmltvText([url])
.then((xml) => parseXmltvGuides(xml))
.catch(() => new Map<string, XmltvChannelGuide>())
)
);
let merged = new Map<string, XmltvChannelGuide>();
for (const m of maps) merged = mergeXmltvGuides(merged, m);
return merged;
}
async function loadXmltvChannelIndex(config: Config): Promise<Map<string, string>> {
const settings = await getIptvSettings(config);
const urls = [...settings.epgXmlUrls];
const index = new Map<string, string>();
for (const url of urls) {
try {
const xml = await fetchXmltvText([url]);
const parsed = parseXmltvChannelIndex(xml);
for (const [id, name] of parsed) {
if (!index.has(id)) index.set(id, name);
}
} catch {
/* skip */
}
}
// Fallback: keys from guides without nice names
if (index.size === 0) {
const guides = await loadXmltvGuidesForEpg(config).catch(() => new Map());
for (const id of guides.keys()) {
index.set(id, id);
}
}
return index;
}
/** Lightweight check used by IPTV status. */
export async function countCustomLiveForViewer(viewerId: string): Promise<number> {
return prisma.liveChannel.count({
where: {
enabled: true,
list: {
enabled: true,
viewers: { some: { viewerUserId: viewerId } },
},
},
});
}

View file

@ -295,6 +295,32 @@ export function registerViewerRoutes(
return { ok: true };
});
app.get("/api/v1/admin/viewers/:id/live-lists", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const { getViewerLiveListIds, listLiveChannelListsAdmin } = await import("./live-channels");
const [listIds, all] = await Promise.all([
getViewerLiveListIds(id),
listLiveChannelListsAdmin(),
]);
return { listIds, lists: all };
});
app.put("/api/v1/admin/viewers/:id/live-lists", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const body = request.body as { listIds?: string[] };
const { setViewerLiveLists } = await import("./live-channels");
const result = await setViewerLiveLists(id, Array.isArray(body.listIds) ? body.listIds : []);
// Enable IPTV access when custom lists are assigned (TV tab)
if (result.listIds.length > 0) {
const { prisma } = await import("../database/client");
await prisma.viewerUser.update({
where: { id },
data: { iptvAccess: true },
});
}
return result;
});
app.post("/api/v1/admin/viewers/:id/addon-tokens", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const admin = (request as AuthenticatedRequest).user;
@ -791,19 +817,17 @@ export function registerViewerRoutes(
app.get("/api/v1/client/iptv/channels/:streamId/play", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const { streamId } = request.params as { streamId: string };
const id = parseInt(streamId, 10);
if (!Number.isFinite(id)) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400);
if (!streamId?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400);
const { getViewerIptvPlayUrl } = await import("./iptv-client");
return getViewerIptvPlayUrl(auth.viewerId, config, id);
return getViewerIptvPlayUrl(auth.viewerId, config, decodeURIComponent(streamId));
});
app.get("/api/v1/client/iptv/channels/:streamId/epg", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);
const { streamId } = request.params as { streamId: string };
const id = parseInt(streamId, 10);
if (!Number.isFinite(id)) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400);
if (!streamId?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400);
const { getViewerIptvEpg } = await import("./iptv-client");
return getViewerIptvEpg(auth.viewerId, config, id);
return getViewerIptvEpg(auth.viewerId, config, decodeURIComponent(streamId));
});
app.get("/api/v1/client/iptv/guide", async (request) => {

View file

@ -148,6 +148,9 @@ export class ViewerService {
lastError: true,
},
},
liveChannelLists: {
select: { listId: true, list: { select: { id: true, name: true, enabled: true } } },
},
addonTokens: {
orderBy: { createdAt: "desc" },
select: {
@ -184,6 +187,12 @@ export class ViewerService {
appAccess: v.appAccess,
iptvAccess: v.iptvAccess,
iptvAddonTokenId: v.iptvAddonTokenId,
liveListIds: v.liveChannelLists.map((r) => r.listId),
liveLists: v.liveChannelLists.map((r) => ({
id: r.list.id,
name: r.list.name,
enabled: r.list.enabled,
})),
iptv: v.iptvLine
? {
id: v.iptvLine.id,