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;
|
hasOwnIptv: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LiveListOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
channelCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type Viewer = {
|
type Viewer = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -50,6 +57,8 @@ type Viewer = {
|
||||||
appAccess: boolean;
|
appAccess: boolean;
|
||||||
iptvAccess: boolean;
|
iptvAccess: boolean;
|
||||||
iptvAddonTokenId: string | null;
|
iptvAddonTokenId: string | null;
|
||||||
|
liveListIds?: string[];
|
||||||
|
liveLists?: Array<{ id: string; name: string; enabled: boolean }>;
|
||||||
googlePlayEmail: string | null;
|
googlePlayEmail: string | null;
|
||||||
googlePlayAccess: boolean;
|
googlePlayAccess: boolean;
|
||||||
googlePlayMembershipId: string | null;
|
googlePlayMembershipId: string | null;
|
||||||
|
|
@ -168,6 +177,8 @@ export default function ViewersPage() {
|
||||||
const [claimCodes, setClaimCodes] = useState<Record<string, string>>({});
|
const [claimCodes, setClaimCodes] = useState<Record<string, string>>({});
|
||||||
const [newPasswords, setNewPasswords] = useState<Record<string, string>>({});
|
const [newPasswords, setNewPasswords] = useState<Record<string, string>>({});
|
||||||
const [editEmails, setEditEmails] = 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(() => {
|
const load = useCallback(() => {
|
||||||
fetch("/api/v1/admin/viewers", { credentials: "include" })
|
fetch("/api/v1/admin/viewers", { credentials: "include" })
|
||||||
|
|
@ -182,6 +193,13 @@ export default function ViewersPage() {
|
||||||
}
|
}
|
||||||
return next;
|
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"));
|
.catch(() => setErr("Kon gebruikers niet laden"));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -192,6 +210,10 @@ export default function ViewersPage() {
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setPlayConfig(d.config ?? null))
|
.then((d) => setPlayConfig(d.config ?? null))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
fetch("/api/v1/admin/live-lists", { credentials: "include" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => setAllLiveLists(d.lists ?? []))
|
||||||
|
.catch(() => undefined);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
// Live updates via SSE (device link, login lock, CRUD). Geen interval-poll.
|
// Live updates via SSE (device link, login lock, CRUD). Geen interval-poll.
|
||||||
|
|
@ -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) {
|
async function createAddonToken(v: Viewer) {
|
||||||
setActionId(v.id);
|
setActionId(v.id);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
|
|
@ -1308,6 +1363,52 @@ export default function ViewersPage() {
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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">
|
<article className="block">
|
||||||
<header className="block-head">
|
<header className="block-head">
|
||||||
<h3>Stremio-addon</h3>
|
<h3>Stremio-addon</h3>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const desktopLinks = [
|
||||||
{ href: "/pending", label: "Te koppelen" },
|
{ href: "/pending", label: "Te koppelen" },
|
||||||
{ href: "/downloads", label: "Downloads" },
|
{ href: "/downloads", label: "Downloads" },
|
||||||
{ href: "/streams", label: "Streams" },
|
{ href: "/streams", label: "Streams" },
|
||||||
|
{ href: "/live-lists", label: "Live-lijsten" },
|
||||||
{ href: "/viewers", label: "Gebruikers" },
|
{ href: "/viewers", label: "Gebruikers" },
|
||||||
{ href: "/settings", label: "Instellingen" },
|
{ href: "/settings", label: "Instellingen" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ fun BindCastPlayerRemote(
|
||||||
player: Player,
|
player: Player,
|
||||||
title: String?,
|
title: String?,
|
||||||
mediaFileId: String? = null,
|
mediaFileId: String? = null,
|
||||||
streamId: Int? = null,
|
streamId: String? = null,
|
||||||
mediaType: String? = null,
|
mediaType: String? = null,
|
||||||
onStop: () -> Unit,
|
onStop: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ fun CastReceiverHost(
|
||||||
startMs: Long,
|
startMs: Long,
|
||||||
title: String?,
|
title: String?,
|
||||||
) -> Unit,
|
) -> Unit,
|
||||||
onPlayLive: (streamId: Int) -> Unit,
|
onPlayLive: (streamId: String) -> Unit,
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(enabled) {
|
LaunchedEffect(enabled) {
|
||||||
if (!enabled) return@LaunchedEffect
|
if (!enabled) return@LaunchedEffect
|
||||||
|
|
@ -59,8 +59,15 @@ fun CastReceiverHost(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"play_live" -> {
|
"play_live" -> {
|
||||||
val streamId = cmd.payload.optInt("streamId", -1)
|
val raw = cmd.payload.opt("streamId")
|
||||||
if (streamId > 0) onPlayLive(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 -> {
|
else -> {
|
||||||
CastCommandBus.fromRemote(cmd.type, cmd.payload)?.let(CastCommandBus::emit)
|
CastCommandBus.fromRemote(cmd.type, cmd.payload)?.let(CastCommandBus::emit)
|
||||||
|
|
|
||||||
|
|
@ -525,7 +525,7 @@ class ApiClient(
|
||||||
val c = arr.getJSONObject(i)
|
val c = arr.getJSONObject(i)
|
||||||
add(
|
add(
|
||||||
IptvChannel(
|
IptvChannel(
|
||||||
streamId = c.getInt("streamId"),
|
streamId = c.optStreamIdString(),
|
||||||
name = c.getString("name"),
|
name = c.getString("name"),
|
||||||
logoUrl = c.optString("logoUrl").ifBlank { null },
|
logoUrl = c.optString("logoUrl").ifBlank { null },
|
||||||
number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") },
|
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) {
|
suspend fun iptvPlayUrl(streamId: String): IptvPlayInfo = withContext(Dispatchers.IO) {
|
||||||
val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/play").get().build()
|
val enc = android.net.Uri.encode(streamId)
|
||||||
|
val req = authed("$baseUrl/api/v1/client/iptv/channels/$enc/play").get().build()
|
||||||
parseObject(execute(req)) { o ->
|
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(
|
IptvPlayInfo(
|
||||||
streamId = o.getInt("streamId"),
|
streamId = o.optStreamIdString(),
|
||||||
name = o.getString("name"),
|
name = o.getString("name"),
|
||||||
logoUrl = o.optString("logoUrl").ifBlank { null },
|
logoUrl = o.optString("logoUrl").ifBlank { null },
|
||||||
streamUrl = o.getString("streamUrl"),
|
streamUrl = o.getString("streamUrl"),
|
||||||
format = o.optString("format").ifBlank { "hls" },
|
format = o.optString("format").ifBlank { "hls" },
|
||||||
fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null },
|
fallbackStreamUrl = o.optString("fallbackStreamUrl").ifBlank { null },
|
||||||
fallbackFormat = o.optString("fallbackFormat").ifBlank { null },
|
fallbackFormat = o.optString("fallbackFormat").ifBlank { null },
|
||||||
|
drm = drm,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun iptvEpg(streamId: Int): IptvEpg = withContext(Dispatchers.IO) {
|
suspend fun iptvEpg(streamId: String): IptvEpg = withContext(Dispatchers.IO) {
|
||||||
val req = authed("$baseUrl/api/v1/client/iptv/channels/$streamId/epg").get().build()
|
val enc = android.net.Uri.encode(streamId)
|
||||||
|
val req = authed("$baseUrl/api/v1/client/iptv/channels/$enc/epg").get().build()
|
||||||
parseObject(execute(req)) { o ->
|
parseObject(execute(req)) { o ->
|
||||||
val scheduleArr = o.optJSONArray("schedule")
|
val scheduleArr = o.optJSONArray("schedule")
|
||||||
val schedule = buildList {
|
val schedule = buildList {
|
||||||
|
|
@ -567,7 +586,7 @@ class ApiClient(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IptvEpg(
|
IptvEpg(
|
||||||
streamId = o.getInt("streamId"),
|
streamId = o.optStreamIdString(),
|
||||||
channelName = o.getString("channelName"),
|
channelName = o.getString("channelName"),
|
||||||
logoUrl = o.optString("logoUrl").ifBlank { null },
|
logoUrl = o.optString("logoUrl").ifBlank { null },
|
||||||
now = o.optJSONObject("now")?.toEpgProgram(),
|
now = o.optJSONObject("now")?.toEpgProgram(),
|
||||||
|
|
@ -604,7 +623,7 @@ class ApiClient(
|
||||||
}
|
}
|
||||||
add(
|
add(
|
||||||
IptvGuideChannel(
|
IptvGuideChannel(
|
||||||
streamId = c.getInt("streamId"),
|
streamId = c.optStreamIdString(),
|
||||||
name = c.getString("name"),
|
name = c.getString("name"),
|
||||||
logoUrl = c.optClean("logoUrl"),
|
logoUrl = c.optClean("logoUrl"),
|
||||||
number = c.optInt("number").takeIf { c.has("number") && !c.isNull("number") },
|
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). */
|
/** Vul ontbrekende now-titels aan (server-batch of parallel per zender). */
|
||||||
suspend fun fetchMissingNowTitles(channels: List<IptvChannel>): Map<Int, String> {
|
suspend fun fetchMissingNowTitles(channels: List<IptvChannel>): Map<String, String> {
|
||||||
val result = linkedMapOf<Int, String>()
|
val result = linkedMapOf<String, String>()
|
||||||
channels.forEach { ch ->
|
channels.forEach { ch ->
|
||||||
ch.nowTitle?.takeIf { it.isNotBlank() }?.let { result[ch.streamId] = it }
|
ch.nowTitle?.takeIf { it.isNotBlank() }?.let { result[ch.streamId] = it }
|
||||||
}
|
}
|
||||||
|
|
@ -691,7 +710,7 @@ class ApiClient(
|
||||||
positionMs: Long = 0,
|
positionMs: Long = 0,
|
||||||
durationMs: Long = 0,
|
durationMs: Long = 0,
|
||||||
mediaFileId: String? = null,
|
mediaFileId: String? = null,
|
||||||
streamId: Int? = null,
|
streamId: String? = null,
|
||||||
mediaType: String? = null,
|
mediaType: String? = null,
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(Dispatchers.IO) {
|
||||||
val body = JSONObject()
|
val body = JSONObject()
|
||||||
|
|
@ -731,6 +750,14 @@ class ApiClient(
|
||||||
return raw
|
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 {
|
private fun authed(url: String): Request.Builder {
|
||||||
val b = Request.Builder().url(url)
|
val b = Request.Builder().url(url)
|
||||||
val t = token
|
val t = token
|
||||||
|
|
@ -924,7 +951,7 @@ data class IptvCategory(
|
||||||
)
|
)
|
||||||
|
|
||||||
data class IptvChannel(
|
data class IptvChannel(
|
||||||
val streamId: Int,
|
val streamId: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val logoUrl: String? = null,
|
val logoUrl: String? = null,
|
||||||
val number: Int? = null,
|
val number: Int? = null,
|
||||||
|
|
@ -934,14 +961,25 @@ data class IptvChannel(
|
||||||
val nowImageUrl: String? = null,
|
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(
|
data class IptvPlayInfo(
|
||||||
val streamId: Int,
|
val streamId: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val logoUrl: String? = null,
|
val logoUrl: String? = null,
|
||||||
val streamUrl: String,
|
val streamUrl: String,
|
||||||
val format: String,
|
val format: String,
|
||||||
val fallbackStreamUrl: String? = null,
|
val fallbackStreamUrl: String? = null,
|
||||||
val fallbackFormat: String? = null,
|
val fallbackFormat: String? = null,
|
||||||
|
val drm: DrmInfo? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EpgProgram(
|
data class EpgProgram(
|
||||||
|
|
@ -956,7 +994,7 @@ data class EpgProgram(
|
||||||
)
|
)
|
||||||
|
|
||||||
data class IptvEpg(
|
data class IptvEpg(
|
||||||
val streamId: Int,
|
val streamId: String,
|
||||||
val channelName: String,
|
val channelName: String,
|
||||||
val logoUrl: String? = null,
|
val logoUrl: String? = null,
|
||||||
val now: EpgProgram? = null,
|
val now: EpgProgram? = null,
|
||||||
|
|
@ -975,7 +1013,7 @@ data class IptvGuide(
|
||||||
)
|
)
|
||||||
|
|
||||||
data class IptvGuideChannel(
|
data class IptvGuideChannel(
|
||||||
val streamId: Int,
|
val streamId: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val logoUrl: String? = null,
|
val logoUrl: String? = null,
|
||||||
val number: Int? = null,
|
val number: Int? = null,
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@ class SessionStore(private val context: Context) {
|
||||||
private val homeCacheKey = stringPreferencesKey("home_cache_json")
|
private val homeCacheKey = stringPreferencesKey("home_cache_json")
|
||||||
private val tvFavoritesKey = stringPreferencesKey("tv_favorites")
|
private val tvFavoritesKey = stringPreferencesKey("tv_favorites")
|
||||||
private val tvLastCategoryKey = stringPreferencesKey("tv_last_category")
|
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. */
|
/** Lokaal per device — niet gesynchroniseerd met account. */
|
||||||
private val audioPassthroughKey = stringPreferencesKey("audio_passthrough")
|
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 homeCacheJson: Flow<String?> = context.dataStore.data.map { it[homeCacheKey] }
|
||||||
|
|
||||||
val favoriteStreamIds: Flow<Set<Int>> = context.dataStore.data.map { prefs ->
|
val favoriteStreamIds: Flow<Set<String>> = context.dataStore.data.map { prefs ->
|
||||||
parseIntSet(prefs[tvFavoritesKey])
|
parseIdSet(prefs[tvFavoritesKey])
|
||||||
}
|
}
|
||||||
|
|
||||||
val tvLastCategoryId: Flow<String?> = context.dataStore.data.map { prefs ->
|
val tvLastCategoryId: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||||
prefs[tvLastCategoryKey]?.takeIf { it.isNotBlank() }
|
prefs[tvLastCategoryKey]?.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
val tvLastStreamId: Flow<Int?> = context.dataStore.data.map { prefs ->
|
val tvLastStreamId: Flow<String?> = context.dataStore.data.map { prefs ->
|
||||||
prefs[tvLastStreamKey]
|
prefs[tvLastStreamKey]?.takeIf { it.isNotBlank() }
|
||||||
|
?: prefs[tvLastStreamKeyLegacy]?.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -155,10 +157,10 @@ class SessionStore(private val context: Context) {
|
||||||
context.dataStore.edit { it.remove(recentSearchKey) }
|
context.dataStore.edit { it.remove(recentSearchKey) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun toggleFavorite(streamId: Int): Boolean {
|
suspend fun toggleFavorite(streamId: String): Boolean {
|
||||||
var nowFavorite = false
|
var nowFavorite = false
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val set = parseIntSet(prefs[tvFavoritesKey]).toMutableSet()
|
val set = parseIdSet(prefs[tvFavoritesKey]).toMutableSet()
|
||||||
if (set.contains(streamId)) {
|
if (set.contains(streamId)) {
|
||||||
set.remove(streamId)
|
set.remove(streamId)
|
||||||
nowFavorite = false
|
nowFavorite = false
|
||||||
|
|
@ -171,11 +173,12 @@ class SessionStore(private val context: Context) {
|
||||||
return nowFavorite
|
return nowFavorite
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setLastTv(categoryId: String?, streamId: Int?) {
|
suspend fun setLastTv(categoryId: String?, streamId: String?) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
if (categoryId.isNullOrBlank()) prefs.remove(tvLastCategoryKey)
|
if (categoryId.isNullOrBlank()) prefs.remove(tvLastCategoryKey)
|
||||||
else prefs[tvLastCategoryKey] = categoryId
|
else prefs[tvLastCategoryKey] = categoryId
|
||||||
if (streamId == null) prefs.remove(tvLastStreamKey)
|
prefs.remove(tvLastStreamKeyLegacy)
|
||||||
|
if (streamId.isNullOrBlank()) prefs.remove(tvLastStreamKey)
|
||||||
else prefs[tvLastStreamKey] = streamId
|
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()
|
if (raw.isNullOrBlank()) return emptySet()
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val arr = JSONArray(raw)
|
val arr = JSONArray(raw)
|
||||||
buildSet {
|
buildSet {
|
||||||
for (i in 0 until arr.length()) {
|
for (i in 0 until arr.length()) {
|
||||||
val v = arr.optInt(i, Int.MIN_VALUE)
|
when (val v = arr.opt(i)) {
|
||||||
if (v != Int.MIN_VALUE) add(v)
|
is Number -> add(v.toLong().toString())
|
||||||
|
is String -> if (v.isNotBlank()) add(v.trim())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.getOrDefault(emptySet())
|
}.getOrDefault(emptySet())
|
||||||
|
|
|
||||||
|
|
@ -227,10 +227,22 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer {
|
||||||
.also { it.volume = 1f }
|
.also { it.volume = 1f }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TS = geen live-offset (laagste latency). HLS-fallback ~2s achter live-rand. */
|
/** TS / HLS / DASH(+ClearKey). */
|
||||||
fun liveMediaItem(url: String, format: String): MediaItem {
|
fun liveMediaItem(
|
||||||
val isHls = format.equals("hls", ignoreCase = true) || url.contains("m3u8", ignoreCase = true)
|
url: String,
|
||||||
val mime = if (isHls) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP2T
|
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)
|
val builder = MediaItem.Builder().setUri(url).setMimeType(mime)
|
||||||
if (isHls) {
|
if (isHls) {
|
||||||
builder.setLiveConfiguration(
|
builder.setLiveConfiguration(
|
||||||
|
|
@ -241,9 +253,63 @@ fun liveMediaItem(url: String, format: String): MediaItem {
|
||||||
.build(),
|
.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()
|
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)
|
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> {
|
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(
|
private data class MenuCategoryCache(
|
||||||
val channels: List<IptvChannel>,
|
val channels: List<IptvChannel>,
|
||||||
val epgTitles: Map<Int, String>,
|
val epgTitles: Map<String, String>,
|
||||||
val loadedAt: Long,
|
val loadedAt: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -91,10 +91,10 @@ private const val MENU_CACHE_TTL_MS = 90_000L
|
||||||
@Composable
|
@Composable
|
||||||
fun LiveTvPlayerScreen(
|
fun LiveTvPlayerScreen(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
streamId: Int,
|
streamId: String,
|
||||||
categoryId: String?,
|
categoryId: String?,
|
||||||
onCategoryChange: (String) -> Unit,
|
onCategoryChange: (String) -> Unit,
|
||||||
onStreamChange: (Int) -> Unit,
|
onStreamChange: (String) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -102,7 +102,7 @@ fun LiveTvPlayerScreen(
|
||||||
val playerFocus = remember { FocusRequester() }
|
val playerFocus = remember { FocusRequester() }
|
||||||
val menuScope = rememberCoroutineScope()
|
val menuScope = rememberCoroutineScope()
|
||||||
|
|
||||||
var currentStreamId by remember { mutableIntStateOf(streamId) }
|
var currentStreamId by remember { mutableStateOf(streamId) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var switching by remember { mutableStateOf(false) }
|
var switching by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
@ -125,9 +125,9 @@ fun LiveTvPlayerScreen(
|
||||||
var zapChannels by remember { mutableStateOf<List<IptvChannel>>(emptyList()) }
|
var zapChannels by remember { mutableStateOf<List<IptvChannel>>(emptyList()) }
|
||||||
var menuLoadGen by remember { mutableIntStateOf(0) }
|
var menuLoadGen by remember { mutableIntStateOf(0) }
|
||||||
val menuCache = remember { mutableStateMapOf<String, MenuCategoryCache>() }
|
val menuCache = remember { mutableStateMapOf<String, MenuCategoryCache>() }
|
||||||
val menuEpgNow = remember { mutableStateMapOf<Int, String>() }
|
val menuEpgNow = remember { mutableStateMapOf<String, String>() }
|
||||||
val menuListState = rememberLazyListState()
|
val menuListState = rememberLazyListState()
|
||||||
val menuChannelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
|
val menuChannelFocus = remember { mutableStateMapOf<String, FocusRequester>() }
|
||||||
|
|
||||||
BindCastPlayerRemote(
|
BindCastPlayerRemote(
|
||||||
api = api,
|
api = api,
|
||||||
|
|
@ -138,13 +138,13 @@ fun LiveTvPlayerScreen(
|
||||||
onStop = onBack,
|
onStop = onBack,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun startStream(url: String, format: String) {
|
fun startStream(url: String, format: String, drm: nl.vonas.mediacluster.tv.data.DrmInfo? = null) {
|
||||||
player.setMediaItem(liveMediaItem(url, format))
|
player.setMediaItem(liveMediaItem(url, format, drm))
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.playWhenReady = true
|
player.playWhenReady = true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadAndPlay(streamIdToPlay: Int, isSwitch: Boolean) {
|
suspend fun loadAndPlay(streamIdToPlay: String, isSwitch: Boolean) {
|
||||||
if (isSwitch) switching = true else loading = true
|
if (isSwitch) switching = true else loading = true
|
||||||
error = null
|
error = null
|
||||||
triedFallback = false
|
triedFallback = false
|
||||||
|
|
@ -152,17 +152,24 @@ fun LiveTvPlayerScreen(
|
||||||
val play = api.iptvPlayUrl(streamIdToPlay)
|
val play = api.iptvPlayUrl(streamIdToPlay)
|
||||||
channelName = play.name
|
channelName = play.name
|
||||||
logoUrl = play.logoUrl
|
logoUrl = play.logoUrl
|
||||||
|
val isDash = play.format.equals("dash", ignoreCase = true)
|
||||||
val tsUrl = when {
|
val tsUrl = when {
|
||||||
|
isDash -> null
|
||||||
play.format.equals("ts", ignoreCase = true) -> play.streamUrl
|
play.format.equals("ts", ignoreCase = true) -> play.streamUrl
|
||||||
play.fallbackFormat?.equals("ts", ignoreCase = true) == true -> play.fallbackStreamUrl
|
play.fallbackFormat?.equals("ts", ignoreCase = true) == true -> play.fallbackStreamUrl
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
val hlsUrl = when {
|
val hlsUrl = when {
|
||||||
|
isDash -> null
|
||||||
play.format.equals("hls", ignoreCase = true) -> play.streamUrl
|
play.format.equals("hls", ignoreCase = true) -> play.streamUrl
|
||||||
play.fallbackFormat?.equals("hls", ignoreCase = true) == true -> play.fallbackStreamUrl
|
play.fallbackFormat?.equals("hls", ignoreCase = true) == true -> play.fallbackStreamUrl
|
||||||
else -> null
|
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
|
fallbackUrl = hlsUrl ?: play.fallbackStreamUrl
|
||||||
fallbackFormat = (hlsUrl ?: play.fallbackStreamUrl)?.let {
|
fallbackFormat = (hlsUrl ?: play.fallbackStreamUrl)?.let {
|
||||||
if (it.contains("m3u8", true)) "hls" else play.fallbackFormat
|
if (it.contains("m3u8", true)) "hls" else play.fallbackFormat
|
||||||
|
|
@ -171,7 +178,7 @@ fun LiveTvPlayerScreen(
|
||||||
} else {
|
} else {
|
||||||
fallbackUrl = play.fallbackStreamUrl
|
fallbackUrl = play.fallbackStreamUrl
|
||||||
fallbackFormat = play.fallbackFormat
|
fallbackFormat = play.fallbackFormat
|
||||||
startStream(play.streamUrl, play.format)
|
startStream(play.streamUrl, play.format, play.drm)
|
||||||
}
|
}
|
||||||
onStreamChange(streamIdToPlay)
|
onStreamChange(streamIdToPlay)
|
||||||
} catch (e: Exception) {
|
} 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) {
|
for ((id, title) in titles) {
|
||||||
menuEpgNow[id] = title
|
menuEpgNow[id] = title
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +248,7 @@ fun LiveTvPlayerScreen(
|
||||||
api.iptvChannels(includeEpg = true)
|
api.iptvChannels(includeEpg = true)
|
||||||
}
|
}
|
||||||
if (gen != menuLoadGen) return
|
if (gen != menuLoadGen) return
|
||||||
val titles = linkedMapOf<Int, String>()
|
val titles = linkedMapOf<String, String>()
|
||||||
withEpg.forEach { ch -> ch.nowTitle?.let { titles[ch.streamId] = it } }
|
withEpg.forEach { ch -> ch.nowTitle?.let { titles[ch.streamId] = it } }
|
||||||
applyMenuEpg(titles)
|
applyMenuEpg(titles)
|
||||||
menuCache[key] = MenuCategoryCache(withEpg, titles, System.currentTimeMillis())
|
menuCache[key] = MenuCategoryCache(withEpg, titles, System.currentTimeMillis())
|
||||||
|
|
@ -376,7 +383,7 @@ fun LiveTvPlayerScreen(
|
||||||
onBack()
|
onBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectChannel(newStreamId: Int) {
|
fun selectChannel(newStreamId: String) {
|
||||||
if (newStreamId == currentStreamId) return
|
if (newStreamId == currentStreamId) return
|
||||||
currentStreamId = newStreamId
|
currentStreamId = newStreamId
|
||||||
infoOverlay = true
|
infoOverlay = true
|
||||||
|
|
@ -631,13 +638,13 @@ private fun LiveChannelSwitcher(
|
||||||
categoryIndex: Int,
|
categoryIndex: Int,
|
||||||
categoryCount: Int,
|
categoryCount: Int,
|
||||||
channels: List<IptvChannel>,
|
channels: List<IptvChannel>,
|
||||||
epgNow: Map<Int, String>,
|
epgNow: Map<String, String>,
|
||||||
currentStreamId: Int,
|
currentStreamId: String,
|
||||||
refreshing: Boolean,
|
refreshing: Boolean,
|
||||||
switching: Boolean,
|
switching: Boolean,
|
||||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||||
channelFocus: MutableMap<Int, FocusRequester>,
|
channelFocus: MutableMap<String, FocusRequester>,
|
||||||
onSelect: (Int) -> Unit,
|
onSelect: (String) -> Unit,
|
||||||
onCategoryShift: (Int) -> Unit,
|
onCategoryShift: (Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ fun McNav() {
|
||||||
// Blijft behouden als main uit compositie gaat (bijv. live player)
|
// Blijft behouden als main uit compositie gaat (bijv. live player)
|
||||||
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
|
var hubTabName by rememberSaveable { mutableStateOf(MainTab.Home.name) }
|
||||||
var tvCategoryId by rememberSaveable { mutableStateOf<String?>(null) }
|
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) }
|
var tvFocusNonce by remember { mutableIntStateOf(0) }
|
||||||
val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet())
|
val favoriteIds by store.favoriteStreamIds.collectAsState(initial = emptySet())
|
||||||
val hubTab = remember(hubTabName) {
|
val hubTab = remember(hubTabName) {
|
||||||
|
|
@ -161,7 +161,7 @@ fun McNav() {
|
||||||
onPlayLive = { streamId ->
|
onPlayLive = { streamId ->
|
||||||
tvLastStreamId = streamId
|
tvLastStreamId = streamId
|
||||||
hubTabName = MainTab.Tv.name
|
hubTabName = MainTab.Tv.name
|
||||||
nav.navigate("live/$streamId") {
|
nav.navigate("live/${android.net.Uri.encode(streamId)}") {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -339,7 +339,7 @@ fun McNav() {
|
||||||
onPlayLive = { streamId ->
|
onPlayLive = { streamId ->
|
||||||
tvLastStreamId = streamId
|
tvLastStreamId = streamId
|
||||||
if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name
|
if (hubTabName != MainTab.Tv.name) hubTabName = MainTab.Tv.name
|
||||||
nav.navigate("live/$streamId")
|
nav.navigate("live/${android.net.Uri.encode(streamId)}")
|
||||||
},
|
},
|
||||||
onSwitchProfile = {
|
onSwitchProfile = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
@ -446,9 +446,11 @@ fun McNav() {
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
"live/{streamId}",
|
"live/{streamId}",
|
||||||
arguments = listOf(navArgument("streamId") { type = NavType.IntType }),
|
arguments = listOf(navArgument("streamId") { type = NavType.StringType }),
|
||||||
) { entry ->
|
) { 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) {
|
DisposableEffect(Unit) {
|
||||||
onDispose { tvFocusNonce++ }
|
onDispose { tvFocusNonce++ }
|
||||||
}
|
}
|
||||||
|
|
@ -476,10 +478,10 @@ private fun MainHub(
|
||||||
watchlistGridState: androidx.compose.foundation.lazy.grid.LazyGridState,
|
watchlistGridState: androidx.compose.foundation.lazy.grid.LazyGridState,
|
||||||
tvCategoryId: String?,
|
tvCategoryId: String?,
|
||||||
onTvCategoryChange: (String?) -> Unit,
|
onTvCategoryChange: (String?) -> Unit,
|
||||||
tvLastStreamId: Int?,
|
tvLastStreamId: String?,
|
||||||
onTvLastStreamChange: (Int?) -> Unit,
|
onTvLastStreamChange: (String?) -> Unit,
|
||||||
favoriteIds: Set<Int>,
|
favoriteIds: Set<String>,
|
||||||
onToggleFavorite: (Int) -> Unit,
|
onToggleFavorite: (String) -> Unit,
|
||||||
tvFocusNonce: Int,
|
tvFocusNonce: Int,
|
||||||
onTvFocusContent: () -> Unit,
|
onTvFocusContent: () -> Unit,
|
||||||
profileAvatarId: String?,
|
profileAvatarId: String?,
|
||||||
|
|
@ -490,7 +492,7 @@ private fun MainHub(
|
||||||
onOpenSeries: (String) -> Unit,
|
onOpenSeries: (String) -> Unit,
|
||||||
onBrowse: (catalogId: String, title: String) -> Unit,
|
onBrowse: (catalogId: String, title: String) -> Unit,
|
||||||
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
|
onPlay: (mediaFileId: String, movieId: String?, episodeId: String?, seriesId: String?, type: String, startMs: Long) -> Unit,
|
||||||
onPlayLive: (Int) -> Unit,
|
onPlayLive: (String) -> Unit,
|
||||||
onSwitchProfile: () -> Unit,
|
onSwitchProfile: () -> Unit,
|
||||||
onManageProfiles: () -> Unit,
|
onManageProfiles: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
|
|
||||||
|
|
@ -108,12 +108,12 @@ fun TvGuideScreen(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
selectedCategoryId: String?,
|
selectedCategoryId: String?,
|
||||||
onCategoryChange: (String?) -> Unit,
|
onCategoryChange: (String?) -> Unit,
|
||||||
favoriteIds: Set<Int> = emptySet(),
|
favoriteIds: Set<String> = emptySet(),
|
||||||
onToggleFavorite: (Int) -> Unit = {},
|
onToggleFavorite: (String) -> Unit = {},
|
||||||
focusContentNonce: Int = 0,
|
focusContentNonce: Int = 0,
|
||||||
onAtTopBack: () -> Unit,
|
onAtTopBack: () -> Unit,
|
||||||
onPlayChannel: (streamId: Int) -> Unit,
|
onPlayChannel: (streamId: String) -> Unit,
|
||||||
onOpenLiveTv: (streamId: Int) -> Unit = {},
|
onOpenLiveTv: (streamId: String) -> Unit = {},
|
||||||
onContentFocused: () -> Unit = {},
|
onContentFocused: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var categories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }
|
var categories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }
|
||||||
|
|
|
||||||
|
|
@ -74,13 +74,13 @@ fun TvScreen(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
selectedCategoryId: String?,
|
selectedCategoryId: String?,
|
||||||
onCategoryChange: (String?) -> Unit,
|
onCategoryChange: (String?) -> Unit,
|
||||||
lastStreamId: Int? = null,
|
lastStreamId: String? = null,
|
||||||
onLastStreamChange: (Int?) -> Unit = {},
|
onLastStreamChange: (String?) -> Unit = {},
|
||||||
favoriteIds: Set<Int> = emptySet(),
|
favoriteIds: Set<String> = emptySet(),
|
||||||
onToggleFavorite: (Int) -> Unit = {},
|
onToggleFavorite: (String) -> Unit = {},
|
||||||
focusContentNonce: Int = 0,
|
focusContentNonce: Int = 0,
|
||||||
onAtTopBack: () -> Unit,
|
onAtTopBack: () -> Unit,
|
||||||
onPlayChannel: (streamId: Int) -> Unit,
|
onPlayChannel: (streamId: String) -> Unit,
|
||||||
onContentFocused: () -> Unit = {},
|
onContentFocused: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var categories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }
|
var categories by remember { mutableStateOf<List<IptvCategory>>(emptyList()) }
|
||||||
|
|
@ -90,7 +90,7 @@ fun TvScreen(
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
|
var focusedEpg by remember { mutableStateOf<IptvEpg?>(null) }
|
||||||
var epgLoading by remember { mutableStateOf(false) }
|
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 epgJob by remember { mutableStateOf<Job?>(null) }
|
||||||
var focusInList by remember { mutableStateOf(false) }
|
var focusInList by remember { mutableStateOf(false) }
|
||||||
val channelCache = remember { mutableStateMapOf<String, List<IptvChannel>>() }
|
val channelCache = remember { mutableStateMapOf<String, List<IptvChannel>>() }
|
||||||
|
|
@ -98,9 +98,9 @@ fun TvScreen(
|
||||||
val categoryRowState = rememberLazyListState()
|
val categoryRowState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
|
val categoryFocus = remember { mutableStateMapOf<String, FocusRequester>() }
|
||||||
val channelFocus = remember { mutableStateMapOf<Int, FocusRequester>() }
|
val channelFocus = remember { mutableStateMapOf<String, FocusRequester>() }
|
||||||
val epgNowTitles = remember { mutableStateMapOf<Int, String>() }
|
val epgNowTitles = remember { mutableStateMapOf<String, String>() }
|
||||||
val epgNextTitles = remember { mutableStateMapOf<Int, String>() }
|
val epgNextTitles = remember { mutableStateMapOf<String, String>() }
|
||||||
|
|
||||||
val displayCategories = remember(categories, favoriteIds) {
|
val displayCategories = remember(categories, favoriteIds) {
|
||||||
val fav = IptvCategory(
|
val fav = IptvCategory(
|
||||||
|
|
@ -294,7 +294,7 @@ fun TvScreen(
|
||||||
onAtTopBack()
|
onAtTopBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadEpg(streamId: Int) {
|
fun loadEpg(streamId: String) {
|
||||||
focusedStreamId = streamId
|
focusedStreamId = streamId
|
||||||
epgJob?.cancel()
|
epgJob?.cancel()
|
||||||
epgJob = scope.launch {
|
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)
|
iptvAddonToken AddonToken? @relation("ViewerLegacyIptvToken", fields: [iptvAddonTokenId], references: [id], onDelete: SetNull)
|
||||||
iptvLine IptvLine?
|
iptvLine IptvLine?
|
||||||
addonTokens AddonToken[] @relation("ViewerAddonTokens")
|
addonTokens AddonToken[] @relation("ViewerAddonTokens")
|
||||||
|
liveChannelLists ViewerLiveChannelList[]
|
||||||
|
|
||||||
@@map("viewer_users")
|
@@map("viewer_users")
|
||||||
}
|
}
|
||||||
|
|
@ -299,6 +300,62 @@ model IptvLine {
|
||||||
@@map("iptv_lines")
|
@@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 {
|
model Location {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
|
|
|
||||||
|
|
@ -1555,6 +1555,80 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
const movie = await prisma.movie.create({ data: body });
|
const movie = await prisma.movie.create({ data: body });
|
||||||
return { movie };
|
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> {
|
export async function rotateNodeCredentials(nodeId: string): Promise<string> {
|
||||||
|
|
|
||||||
|
|
@ -1014,7 +1014,33 @@ export function mergeXmltvGuides(
|
||||||
return out;
|
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;
|
let lastErr: unknown;
|
||||||
for (const url of urls) {
|
for (const url of urls) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1028,7 +1054,7 @@ async function fetchXmltvText(urls: string[]): Promise<string> {
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`XMLTV HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`XMLTV HTTP ${res.status}`);
|
||||||
const xml = await res.text();
|
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) {
|
} catch (err) {
|
||||||
lastErr = err;
|
lastErr = err;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,22 +52,46 @@ export function friendlyCategoryName(raw: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getViewerIptvStatus(viewerId: string, config: Config) {
|
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);
|
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(() => []);
|
const cats = await xt.client.getLiveCategories().catch(() => []);
|
||||||
return {
|
return {
|
||||||
enabled: true as const,
|
enabled: true as const,
|
||||||
categoryCount: cats.length,
|
categoryCount: cats.length,
|
||||||
|
customChannelCount: customCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getViewerIptvCategories(viewerId: string, config: Config) {
|
export async function getViewerIptvCategories(viewerId: string, config: Config) {
|
||||||
const xt = await loadXtreamForViewer(viewerId, config);
|
const {
|
||||||
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
listAssignedLiveListsForViewer,
|
||||||
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
|
viewerHasCustomLive,
|
||||||
|
} = await import("./live-channels");
|
||||||
|
|
||||||
// Categorieën + (korte) zenderlijst parallel — categorieën nooit laten falen op trage streams
|
const hasCustom = await viewerHasCustomLive(viewerId);
|
||||||
|
const xt = await loadXtreamForViewer(viewerId, config);
|
||||||
|
|
||||||
|
if (!xt?.line.enableLive && !hasCustom) {
|
||||||
|
throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
let categories: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rawName: string;
|
||||||
|
channelCount: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (xt?.line.enableLive) {
|
||||||
const [catsRaw, streamsRaw, iptvPrefs] = await Promise.all([
|
const [catsRaw, streamsRaw, iptvPrefs] = await Promise.all([
|
||||||
xt.client.getLiveCategories().catch(() => [] as Awaited<ReturnType<typeof xt.client.getLiveCategories>>),
|
xt.client.getLiveCategories().catch(() => [] as Awaited<ReturnType<typeof xt.client.getLiveCategories>>),
|
||||||
Promise.race([
|
Promise.race([
|
||||||
|
|
@ -86,7 +110,7 @@ export async function getViewerIptvCategories(viewerId: string, config: Config)
|
||||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let categories = cats
|
categories = cats
|
||||||
.map((c) => {
|
.map((c) => {
|
||||||
const id = normalizeIptvCategoryId(c.category_id);
|
const id = normalizeIptvCategoryId(c.category_id);
|
||||||
const rawName = String(c.category_name ?? "");
|
const rawName = String(c.category_name ?? "");
|
||||||
|
|
@ -97,10 +121,8 @@ export async function getViewerIptvCategories(viewerId: string, config: Config)
|
||||||
channelCount: counts.get(id) ?? 0,
|
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));
|
.filter((c) => c.id.length > 0 && !isAggregateCategoryName(c.rawName));
|
||||||
|
|
||||||
// Fallback: bouquets uit zenders afleiden als get_live_categories leeg/fout is
|
|
||||||
if (categories.length === 0 && counts.size > 0) {
|
if (categories.length === 0 && counts.size > 0) {
|
||||||
categories = [...counts.entries()].map(([id, channelCount]) => ({
|
categories = [...counts.entries()].map(([id, channelCount]) => ({
|
||||||
id,
|
id,
|
||||||
|
|
@ -110,9 +132,10 @@ export async function getViewerIptvCategories(viewerId: string, config: Config)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
categories = applyIptvCategoryOrder(categories, iptvPrefs.categoryOrder);
|
const prefs = iptvPrefs ?? (await getIptvSettings(config));
|
||||||
|
categories = applyIptvCategoryOrder(categories, prefs.categoryOrder);
|
||||||
|
|
||||||
if (iptvPrefs.showAlleZenders) {
|
if (prefs.showAlleZenders) {
|
||||||
categories.unshift({
|
categories.unshift({
|
||||||
id: "__all__",
|
id: "__all__",
|
||||||
name: "Alle zenders",
|
name: "Alle zenders",
|
||||||
|
|
@ -120,6 +143,17 @@ export async function getViewerIptvCategories(viewerId: string, config: Config)
|
||||||
channelCount: streams.length,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { categories };
|
return { categories };
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +246,12 @@ export async function getViewerIptvChannels(
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
includeEpg = false
|
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);
|
const xt = await loadXtreamForViewer(viewerId, config);
|
||||||
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
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) 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 pair = epgTitles?.get(s.stream_id);
|
||||||
const cached = getCachedStreamEpg(xt.cacheKey, s.stream_id);
|
const cached = getCachedStreamEpg(xt.cacheKey, s.stream_id);
|
||||||
return {
|
return {
|
||||||
streamId: s.stream_id,
|
streamId: String(s.stream_id),
|
||||||
name: s.name.trim(),
|
name: s.name.trim(),
|
||||||
logoUrl: s.stream_icon || null,
|
logoUrl: s.stream_icon || null,
|
||||||
number: s.num ?? null,
|
number: s.num ?? null,
|
||||||
|
|
@ -258,9 +298,41 @@ export async function getViewerIptvGuide(
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
opts: { limit?: number; offset?: number } = {}
|
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);
|
const xt = await loadXtreamForViewer(viewerId, config);
|
||||||
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
if (!xt?.line.enableLive) {
|
||||||
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
|
// 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);
|
await applyIptvEpgUrlsToRuntime(config);
|
||||||
|
|
||||||
|
|
@ -307,7 +379,7 @@ export async function getViewerIptvGuide(
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
streamId: s.stream_id,
|
streamId: String(s.stream_id),
|
||||||
name: s.name.trim(),
|
name: s.name.trim(),
|
||||||
logoUrl: s.stream_icon || null,
|
logoUrl: s.stream_icon || null,
|
||||||
number: s.num ?? 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 {
|
return {
|
||||||
generatedAt: new Date(nowMs).toISOString(),
|
generatedAt: new Date(nowMs).toISOString(),
|
||||||
windowStart: new Date(nowMs - 2 * 60 * 60_000).toISOString(),
|
windowStart: new Date(nowMs - 2 * 60 * 60_000).toISOString(),
|
||||||
windowEnd: new Date(nowMs + 24 * 60 * 60_000).toISOString(),
|
windowEnd: new Date(nowMs + 24 * 60 * 60_000).toISOString(),
|
||||||
total: streams.length,
|
total: streams.length + (await listAssignedLiveListsForViewer(viewerId)).length,
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
channels,
|
channels,
|
||||||
|
|
@ -332,17 +413,26 @@ export async function getViewerIptvGuide(
|
||||||
export async function getViewerIptvPlayUrl(
|
export async function getViewerIptvPlayUrl(
|
||||||
viewerId: string,
|
viewerId: string,
|
||||||
config: Config,
|
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);
|
const xt = await loadXtreamForViewer(viewerId, config);
|
||||||
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
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) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
|
||||||
|
|
||||||
const streams = await xt.client.getLiveStreams();
|
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);
|
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 classicTs = resolved.classic.find((u) => /\.ts$/i.test(u));
|
||||||
const classicHls = resolved.classic.find((u) => /\.m3u8$/i.test(u) || u.includes("m3u8"));
|
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")
|
void import("../iptv/active-watches")
|
||||||
.then(({ touchIptvWatchForViewer }) =>
|
.then(({ touchIptvWatchForViewer }) =>
|
||||||
touchIptvWatchForViewer(viewerId, "live", streamId, ch.name)
|
touchIptvWatchForViewer(viewerId, "live", id, ch.name)
|
||||||
)
|
)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
streamId,
|
streamId: String(id),
|
||||||
name: ch.name,
|
name: ch.name,
|
||||||
logoUrl: ch.stream_icon || null,
|
logoUrl: ch.stream_icon || null,
|
||||||
streamUrl: primary,
|
streamUrl: primary,
|
||||||
format: formatFor(primary),
|
format: formatFor(primary),
|
||||||
fallbackStreamUrl: fallback ?? null,
|
fallbackStreamUrl: fallback ?? null,
|
||||||
fallbackFormat: fallback ? formatFor(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);
|
const xt = await loadXtreamForViewer(viewerId, config);
|
||||||
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
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) 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);
|
await applyIptvEpgUrlsToRuntime(config);
|
||||||
|
|
||||||
const streams = await xt.client.getLiveStreams();
|
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);
|
if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
|
||||||
|
|
||||||
void import("../iptv/active-watches")
|
void import("../iptv/active-watches")
|
||||||
.then(({ refreshIptvWatch }) =>
|
.then(({ refreshIptvWatch }) =>
|
||||||
refreshIptvWatch({ iptvLineId: xt.cacheKey, streamId, title: ch.name })
|
refreshIptvWatch({ iptvLineId: xt.cacheKey, streamId: id, title: ch.name })
|
||||||
)
|
)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
||||||
const cached = getCachedStreamEpg(xt.cacheKey, streamId);
|
const cached = getCachedStreamEpg(xt.cacheKey, id);
|
||||||
if (cached && (cached.now || cached.next || cached.schedule.length > 0)) {
|
if (cached && (cached.now || cached.next || cached.schedule.length > 0)) {
|
||||||
return {
|
return {
|
||||||
streamId,
|
streamId: String(id),
|
||||||
channelName: ch.name,
|
channelName: ch.name,
|
||||||
logoUrl: ch.stream_icon || null,
|
logoUrl: ch.stream_icon || null,
|
||||||
now: cached.now,
|
now: cached.now,
|
||||||
|
|
@ -416,9 +520,9 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
|
||||||
guides.get(epgId.toLowerCase()) ??
|
guides.get(epgId.toLowerCase()) ??
|
||||||
guides.get(epgId.toUpperCase());
|
guides.get(epgId.toUpperCase());
|
||||||
if (hit && (hit.now || hit.next || hit.schedule.length > 0)) {
|
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 {
|
return {
|
||||||
streamId,
|
streamId: String(id),
|
||||||
channelName: ch.name,
|
channelName: ch.name,
|
||||||
logoUrl: ch.stream_icon || null,
|
logoUrl: ch.stream_icon || null,
|
||||||
now: hit.now,
|
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);
|
const { now: nowProgram, next: nextProgram, schedule } = parseRichShortEpg(epg);
|
||||||
setCachedStreamEpg(xt.cacheKey, streamId, nowProgram, nextProgram, schedule);
|
setCachedStreamEpg(xt.cacheKey, id, nowProgram, nextProgram, schedule);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
streamId,
|
streamId: String(id),
|
||||||
channelName: ch.name,
|
channelName: ch.name,
|
||||||
logoUrl: ch.stream_icon || null,
|
logoUrl: ch.stream_icon || null,
|
||||||
now: nowProgram,
|
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 };
|
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) => {
|
app.post("/api/v1/admin/viewers/:id/addon-tokens", { preHandler: requireAdmin }, async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const admin = (request as AuthenticatedRequest).user;
|
const admin = (request as AuthenticatedRequest).user;
|
||||||
|
|
@ -791,19 +817,17 @@ export function registerViewerRoutes(
|
||||||
app.get("/api/v1/client/iptv/channels/:streamId/play", async (request) => {
|
app.get("/api/v1/client/iptv/channels/:streamId/play", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
const { streamId } = request.params as { streamId: string };
|
const { streamId } = request.params as { streamId: string };
|
||||||
const id = parseInt(streamId, 10);
|
if (!streamId?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400);
|
||||||
if (!Number.isFinite(id)) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400);
|
|
||||||
const { getViewerIptvPlayUrl } = await import("./iptv-client");
|
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) => {
|
app.get("/api/v1/client/iptv/channels/:streamId/epg", async (request) => {
|
||||||
const auth = await viewers.authFromBearer(request.headers.authorization);
|
const auth = await viewers.authFromBearer(request.headers.authorization);
|
||||||
const { streamId } = request.params as { streamId: string };
|
const { streamId } = request.params as { streamId: string };
|
||||||
const id = parseInt(streamId, 10);
|
if (!streamId?.trim()) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400);
|
||||||
if (!Number.isFinite(id)) throw new AppError("INVALID_REQUEST", "Ongeldig streamId", 400);
|
|
||||||
const { getViewerIptvEpg } = await import("./iptv-client");
|
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) => {
|
app.get("/api/v1/client/iptv/guide", async (request) => {
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,9 @@ export class ViewerService {
|
||||||
lastError: true,
|
lastError: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
liveChannelLists: {
|
||||||
|
select: { listId: true, list: { select: { id: true, name: true, enabled: true } } },
|
||||||
|
},
|
||||||
addonTokens: {
|
addonTokens: {
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
select: {
|
select: {
|
||||||
|
|
@ -184,6 +187,12 @@ export class ViewerService {
|
||||||
appAccess: v.appAccess,
|
appAccess: v.appAccess,
|
||||||
iptvAccess: v.iptvAccess,
|
iptvAccess: v.iptvAccess,
|
||||||
iptvAddonTokenId: v.iptvAddonTokenId,
|
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
|
iptv: v.iptvLine
|
||||||
? {
|
? {
|
||||||
id: v.iptvLine.id,
|
id: v.iptvLine.id,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue