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:
parent
21efba1af1
commit
df9782c8f5
20 changed files with 1911 additions and 153 deletions
569
apps/admin-ui/src/app/live-lists/page.tsx
Normal file
569
apps/admin-ui/src/app/live-lists/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ fun BindCastPlayerRemote(
|
|||
player: Player,
|
||||
title: String?,
|
||||
mediaFileId: String? = null,
|
||||
streamId: Int? = null,
|
||||
streamId: String? = null,
|
||||
mediaType: String? = null,
|
||||
onStop: () -> Unit,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 $$;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
597
apps/master-api/src/viewer/live-channels.ts
Normal file
597
apps/master-api/src/viewer/live-channels.ts
Normal 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 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue