Unify app users with per-user IPTV and drop shared IPTV fallback.

Gebruikers own their Xtream line and optional Stremio addons; admin Accounts merges into Gebruikers so kids can get filtered lines without leaking the latest addon credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-08 00:43:55 +02:00
parent bc63ec11bb
commit a431bebf3b
13 changed files with 1055 additions and 683 deletions

View file

@ -1,575 +1,17 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useEffect } from "react";
import { Nav, useAuth } from "@/components/Nav"; import { useRouter } from "next/navigation";
interface AddonTokenRow {
id: string;
label: string | null;
revoked: boolean;
createdAt: string;
hasIptv?: boolean;
iptvOk?: boolean;
}
interface IptvForm {
label: string;
baseUrl: string;
username: string;
password: string;
enableLive: boolean;
enableVod: boolean;
enableSeries: boolean;
lastOkAt?: string | null;
lastError?: string | null;
hasPassword?: boolean;
}
const emptyIptv = (): IptvForm => ({
label: "",
baseUrl: "",
username: "",
password: "",
enableLive: true,
enableVod: true,
enableSeries: true,
});
const MASTER_URL = "https://master.vonas.nl";
export default function AccountsPage() {
useAuth();
const [tokens, setTokens] = useState<AddonTokenRow[]>([]);
const [addonToken, setAddonToken] = useState("");
const [installUrl, setInstallUrl] = useState("");
const [label, setLabel] = useState("Stremio");
const [iptvTokenId, setIptvTokenId] = useState<string | null>(null);
const [iptv, setIptv] = useState<IptvForm>(emptyIptv());
const [iptvBusy, setIptvBusy] = useState(false);
const [iptvMsg, setIptvMsg] = useState<string | null>(null);
const [iptvErr, setIptvErr] = useState<string | null>(null);
const [adminEmail, setAdminEmail] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [pwBusy, setPwBusy] = useState(false);
const [pwMsg, setPwMsg] = useState<string | null>(null);
const [pwErr, setPwErr] = useState<string | null>(null);
const loadTokens = useCallback(() => {
fetch("/api/v1/admin/addon-tokens", { credentials: "include" })
.then((r) => r.json())
.then((d) => setTokens(d.tokens ?? []));
}, []);
/** Accounts is samengevoegd met Gebruikers (/viewers). */
export default function AccountsRedirectPage() {
const router = useRouter();
useEffect(() => { useEffect(() => {
loadTokens(); router.replace("/viewers");
fetch("/api/v1/auth/me", { credentials: "include" }) }, [router]);
.then((r) => r.json())
.then((d) => setAdminEmail(d.user?.email ?? ""))
.catch(() => undefined);
}, [loadTokens]);
async function changePassword() {
setPwMsg(null);
setPwErr(null);
if (!currentPassword || !newPassword) {
setPwErr("Vul huidig en nieuw wachtwoord in");
return;
}
if (newPassword.length < 8) {
setPwErr("Nieuw wachtwoord moet minstens 8 tekens zijn");
return;
}
if (newPassword !== confirmPassword) {
setPwErr("Nieuwe wachtwoorden komen niet overeen");
return;
}
setPwBusy(true);
try {
const res = await fetch("/api/v1/auth/change-password", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword, confirmPassword }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setPwErr(data.error?.message ?? "Wachtwoord wijzigen mislukt");
return;
}
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setPwMsg("Wachtwoord gewijzigd. Andere sessies zijn uitgelogd.");
} finally {
setPwBusy(false);
}
}
async function createAddonToken() {
const res = await fetch("/api/v1/admin/addon-tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ label }),
});
const data = await res.json();
if (!res.ok) {
alert(data.error?.message ?? "Aanmaken mislukt");
return;
}
setAddonToken(data.token);
setInstallUrl(`${MASTER_URL}/stremio/${data.token}/manifest.json`);
loadTokens();
}
async function revokeToken(id: string) {
if (!confirm("Addon deactiveren? Bestaande Stremio-installaties stoppen met werken.")) return;
await fetch(`/api/v1/admin/addon-tokens/${id}/revoke`, {
method: "POST",
credentials: "include",
});
loadTokens();
}
async function activateToken(id: string) {
await fetch(`/api/v1/admin/addon-tokens/${id}/activate`, {
method: "POST",
credentials: "include",
});
loadTokens();
}
async function deleteToken(id: string) {
if (!confirm("Addon definitief verwijderen? Dit kan niet ongedaan worden gemaakt.")) return;
await fetch(`/api/v1/admin/addon-tokens/${id}`, {
method: "DELETE",
credentials: "include",
});
if (iptvTokenId === id) {
setIptvTokenId(null);
setIptv(emptyIptv());
}
loadTokens();
}
async function openIptv(tokenId: string) {
setIptvTokenId(tokenId);
setIptvMsg(null);
setIptvErr(null);
setIptvBusy(true);
try {
const res = await fetch(`/api/v1/admin/addon-tokens/${tokenId}/iptv`, {
credentials: "include",
});
const data = await res.json();
if (!res.ok) {
setIptvErr(data.error?.message ?? "Laden mislukt");
setIptv(emptyIptv());
return;
}
if (data.iptv) {
setIptv({
label: data.iptv.label ?? "",
baseUrl: data.iptv.baseUrl ?? "",
username: data.iptv.username ?? "",
password: "",
enableLive: data.iptv.enableLive ?? true,
enableVod: data.iptv.enableVod ?? true,
enableSeries: data.iptv.enableSeries ?? true,
lastOkAt: data.iptv.lastOkAt,
lastError: data.iptv.lastError,
hasPassword: data.iptv.hasPassword,
});
} else {
setIptv(emptyIptv());
}
} finally {
setIptvBusy(false);
}
}
async function saveIptv() {
if (!iptvTokenId) return;
setIptvBusy(true);
setIptvMsg(null);
setIptvErr(null);
try {
const res = await fetch(`/api/v1/admin/addon-tokens/${iptvTokenId}/iptv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
label: iptv.label || undefined,
baseUrl: iptv.baseUrl,
username: iptv.username,
password: iptv.password || undefined,
enableLive: iptv.enableLive,
enableVod: iptv.enableVod,
enableSeries: iptv.enableSeries,
}),
});
const data = await res.json();
if (!res.ok) {
setIptvErr(data.error?.message ?? "Opslaan mislukt");
return;
}
setIptvMsg(
`Verbonden · ${data.iptv?.liveCategories ?? 0} live-bouquets. Herlaad de Stremio-addon om Live TV te zien.`
);
setIptv((s) => ({ ...s, password: "", hasPassword: true, lastError: null }));
loadTokens();
await openIptv(iptvTokenId);
} finally {
setIptvBusy(false);
}
}
async function testIptv() {
if (!iptvTokenId) return;
setIptvBusy(true);
setIptvMsg(null);
setIptvErr(null);
try {
const res = await fetch(`/api/v1/admin/addon-tokens/${iptvTokenId}/iptv/test`, {
method: "POST",
credentials: "include",
});
const data = await res.json();
if (!res.ok) {
setIptvErr(data.error?.message ?? "Test mislukt");
return;
}
setIptvMsg(
`OK · status ${data.status}` +
(data.expDate ? ` · exp ${data.expDate}` : "") +
` · live ${data.liveCategories} · films ${data.vodCategories} · series ${data.seriesCategories}`
);
loadTokens();
} finally {
setIptvBusy(false);
}
}
async function deleteIptv() {
if (!iptvTokenId) return;
if (!confirm("IPTV-userline ontkoppelen van deze addon?")) return;
setIptvBusy(true);
setIptvErr(null);
try {
await fetch(`/api/v1/admin/addon-tokens/${iptvTokenId}/iptv`, {
method: "DELETE",
credentials: "include",
});
setIptv(emptyIptv());
setIptvMsg("IPTV ontkoppeld");
loadTokens();
} finally {
setIptvBusy(false);
}
}
const activeToken = tokens.find((t) => t.id === iptvTokenId);
return ( return (
<> <div className="container" style={{ paddingTop: "3rem" }}>
<Nav /> <p className="muted">Doorsturen naar Gebruikers…</p>
<div className="container">
<div className="page-header">
<div>
<div className="page-kicker">Access</div>
<h1>Accounts</h1>
</div> </div>
</div>
<section className="stack-section">
<div className="section-head">
<h2 className="section-title">Admin-login</h2>
<p className="muted">Ingelogd als {adminEmail || "…"}</p>
</div>
<div className="card">
{pwErr && <div className="error">{pwErr}</div>}
{pwMsg && (
<p className="muted" style={{ color: "var(--ok)", marginBottom: "0.75rem" }}>
{pwMsg}
</p>
)}
<div className="form-group">
<label>Huidig wachtwoord</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<div className="form-group">
<label>Nieuw wachtwoord</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="form-group">
<label>Bevestig nieuw wachtwoord</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<button type="button" onClick={changePassword} disabled={pwBusy}>
{pwBusy ? "Bezig…" : "Wachtwoord wijzigen"}
</button>
</div>
</section>
<section className="stack-section">
<div className="section-head">
<h2 className="section-title">Stremio-addons</h2>
<p className="muted">Eén addon = één kijker-label in Streams (Live).</p>
</div>
<div className="card">
<h3 className="card-subtitle">Nieuwe addon</h3>
<div className="form-group">
<label>Label</label>
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="bijv. Jos / Thuis / TV"
/>
</div>
<button onClick={createAddonToken}>Addon aanmaken</button>
{addonToken && (
<>
<p className="muted" style={{ marginTop: "1rem" }}>
Installatie-URL (nu kopiëren — wordt maar één keer getoond):
</p>
<div className="token-display">{installUrl}</div>
</>
)}
</div>
<div className="card">
<h3 className="card-subtitle">Bestaande addons</h3>
<div className="desktop-table">
<table>
<thead>
<tr>
<th>Label</th>
<th>Status</th>
<th>IPTV</th>
<th>Aangemaakt</th>
<th>Acties</th>
</tr>
</thead>
<tbody>
{tokens.map((t) => (
<tr key={t.id} className={iptvTokenId === t.id ? "row-selected" : undefined}>
<td>{t.label || "—"}</td>
<td>
<span className={`status ${t.revoked ? "revoked" : "online"}`}>
{t.revoked ? "Inactief" : "Actief"}
</span>
</td>
<td>
{t.hasIptv ? (
<span className={`status ${t.iptvOk ? "online" : "revoked"}`}>
{t.iptvOk ? "Live TV" : "Fout"}
</span>
) : (
<span className="status offline">Geen</span>
)}
</td>
<td>{new Date(t.createdAt).toLocaleString()}</td>
<td>
<div className="action-row">
<button type="button" className="btn-cyan" onClick={() => openIptv(t.id)}>
IPTV
</button>
{t.revoked ? (
<button type="button" className="btn-ok" onClick={() => activateToken(t.id)}>
Activeren
</button>
) : (
<button type="button" className="btn-warn" onClick={() => revokeToken(t.id)}>
Deactiveren
</button>
)}
<button type="button" className="danger" onClick={() => deleteToken(t.id)}>
Verwijderen
</button>
</div>
</td>
</tr>
))}
{tokens.length === 0 && (
<tr>
<td colSpan={5} className="muted" style={{ textAlign: "center" }}>
Nog geen addons
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="list-cards">
{tokens.map((t) => (
<article key={t.id} className={`list-card${iptvTokenId === t.id ? " selected" : ""}`}>
<div className="list-card-top">
<strong>{t.label || "Zonder label"}</strong>
<span className={`status ${t.revoked ? "revoked" : "online"}`}>
{t.revoked ? "Inactief" : "Actief"}
</span>
</div>
<div className="list-card-meta">
<span>
IPTV:{" "}
{t.hasIptv ? (t.iptvOk ? "OK" : "Fout") : "geen"}
</span>
<span>{new Date(t.createdAt).toLocaleDateString()}</span>
</div>
<div className="action-row">
<button type="button" className="btn-cyan" onClick={() => openIptv(t.id)}>
IPTV
</button>
{t.revoked ? (
<button type="button" className="btn-ok" onClick={() => activateToken(t.id)}>
Activeren
</button>
) : (
<button type="button" className="btn-warn" onClick={() => revokeToken(t.id)}>
Deactiveren
</button>
)}
<button type="button" className="danger" onClick={() => deleteToken(t.id)}>
Verwijderen
</button>
</div>
</article>
))}
{tokens.length === 0 && <p className="muted">Nog geen addons</p>}
</div>
</div>
</section>
{iptvTokenId && (
<section className="stack-section">
<div className="card iptv-card">
<div className="section-head row">
<div>
<h2 className="section-title">IPTV · Xtream / XUI</h2>
<p className="muted">
Userline voor{" "}
<strong style={{ color: "var(--text-bright)" }}>
{activeToken?.label || iptvTokenId.slice(0, 8)}
</strong>
</p>
</div>
<button
type="button"
className="ghost"
onClick={() => {
setIptvTokenId(null);
setIptv(emptyIptv());
setIptvMsg(null);
setIptvErr(null);
}}
>
Sluiten
</button>
</div>
<div className="form-grid">
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Label</label>
<input
value={iptv.label}
onChange={(e) => setIptv({ ...iptv, label: e.target.value })}
placeholder="bijv. Thuis XUI"
/>
</div>
<div className="form-group form-span" style={{ marginBottom: 0 }}>
<label>Server-URL</label>
<input
value={iptv.baseUrl}
onChange={(e) => setIptv({ ...iptv, baseUrl: e.target.value })}
placeholder="http://host:port"
autoComplete="off"
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Gebruikersnaam</label>
<input
value={iptv.username}
onChange={(e) => setIptv({ ...iptv, username: e.target.value })}
autoComplete="off"
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Wachtwoord {iptv.hasPassword ? "(leeg = behouden)" : ""}</label>
<input
type="password"
value={iptv.password}
onChange={(e) => setIptv({ ...iptv, password: e.target.value })}
placeholder={iptv.hasPassword ? "••••••••" : ""}
autoComplete="new-password"
/>
</div>
</div>
<div className="check-row">
{(
[
["enableLive", "Live TV + EPG"],
["enableVod", "IPTV Films"],
["enableSeries", "IPTV Series"],
] as const
).map(([key, text]) => (
<label key={key} className="check-label">
<input
type="checkbox"
checked={iptv[key]}
onChange={(e) => setIptv({ ...iptv, [key]: e.target.checked })}
style={{ width: "auto", margin: 0 }}
/>
{text}
</label>
))}
</div>
{iptvMsg && <p style={{ color: "var(--ok)", fontSize: "0.875rem" }}>{iptvMsg}</p>}
{iptvErr && <div className="error">{iptvErr}</div>}
<div className="action-row">
<button type="button" onClick={saveIptv} disabled={iptvBusy}>
{iptvBusy ? "Bezig…" : "Opslaan & verbinden"}
</button>
<button
type="button"
className="btn-ok"
onClick={testIptv}
disabled={iptvBusy || !iptv.hasPassword}
>
Testen
</button>
{iptv.hasPassword && (
<button type="button" className="danger" onClick={deleteIptv} disabled={iptvBusy}>
Ontkoppelen
</button>
)}
</div>
</div>
</section>
)}
</div>
</>
); );
} }

View file

@ -4,10 +4,16 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Nav, useAuth } from "@/components/Nav"; import { Nav, useAuth } from "@/components/Nav";
type SettingsTab = "shelves" | "download-station" | "opensubtitles" | "google-play"; type SettingsTab = "admin" | "shelves" | "download-station" | "opensubtitles" | "google-play";
function parseTab(v: string | null): SettingsTab { function parseTab(v: string | null): SettingsTab {
if (v === "download-station" || v === "opensubtitles" || v === "shelves" || v === "google-play") { if (
v === "admin" ||
v === "download-station" ||
v === "opensubtitles" ||
v === "shelves" ||
v === "google-play"
) {
return v; return v;
} }
return "shelves"; return "shelves";
@ -47,6 +53,7 @@ interface DsConfig {
} }
const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [ const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [
{ id: "admin", label: "Admin", short: "Admin" },
{ id: "shelves", label: "Planken", short: "Planken" }, { id: "shelves", label: "Planken", short: "Planken" },
{ id: "download-station", label: "Download Station", short: "DS" }, { id: "download-station", label: "Download Station", short: "DS" },
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" }, { id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
@ -103,6 +110,14 @@ export default function SettingsPage() {
const [osErr, setOsErr] = useState<string | null>(null); const [osErr, setOsErr] = useState<string | null>(null);
const [osWarn, setOsWarn] = useState<string | null>(null); const [osWarn, setOsWarn] = useState<string | null>(null);
const [adminEmail, setAdminEmail] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [pwBusy, setPwBusy] = useState(false);
const [pwMsg, setPwMsg] = useState<string | null>(null);
const [pwErr, setPwErr] = useState<string | null>(null);
const [gpProjectId, setGpProjectId] = useState(""); const [gpProjectId, setGpProjectId] = useState("");
const [gpSaEmail, setGpSaEmail] = useState(""); const [gpSaEmail, setGpSaEmail] = useState("");
const [gpPrivateKey, setGpPrivateKey] = useState(""); const [gpPrivateKey, setGpPrivateKey] = useState("");
@ -187,6 +202,10 @@ export default function SettingsPage() {
loadDs(); loadDs();
loadOpenSubtitles(); loadOpenSubtitles();
loadGooglePlay(); loadGooglePlay();
fetch("/api/v1/auth/me", { credentials: "include" })
.then((r) => r.json())
.then((d) => setAdminEmail(d.user?.email ?? ""))
.catch(() => undefined);
}, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay]); }, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay]);
useEffect(() => { useEffect(() => {
@ -200,6 +219,43 @@ export default function SettingsPage() {
const hasConfig = useMemo(() => configs.some((c) => c.nodeId === nodeId), [configs, nodeId]); const hasConfig = useMemo(() => configs.some((c) => c.nodeId === nodeId), [configs, nodeId]);
async function changeAdminPassword() {
setPwMsg(null);
setPwErr(null);
if (!currentPassword || !newPassword) {
setPwErr("Vul huidig en nieuw wachtwoord in");
return;
}
if (newPassword.length < 8) {
setPwErr("Nieuw wachtwoord moet minstens 8 tekens zijn");
return;
}
if (newPassword !== confirmPassword) {
setPwErr("Nieuwe wachtwoorden komen niet overeen");
return;
}
setPwBusy(true);
try {
const res = await fetch("/api/v1/auth/change-password", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword, confirmPassword }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setPwErr(data.error?.message ?? "Wachtwoord wijzigen mislukt");
return;
}
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setPwMsg("Wachtwoord gewijzigd. Andere sessies zijn uitgelogd.");
} finally {
setPwBusy(false);
}
}
async function createShelf() { async function createShelf() {
if (!newShelfName.trim()) return; if (!newShelfName.trim()) return;
const res = await fetch("/api/v1/admin/library/shelves", { const res = await fetch("/api/v1/admin/library/shelves", {
@ -375,6 +431,53 @@ export default function SettingsPage() {
))} ))}
</nav> </nav>
{tab === "admin" && (
<section className="stack-section">
<div className="section-head">
<h2 className="section-title">Admin-login</h2>
<p className="muted">Ingelogd als {adminEmail || "…"}</p>
</div>
<div className="card">
{pwErr && <div className="error">{pwErr}</div>}
{pwMsg && (
<p className="muted" style={{ color: "var(--ok)", marginBottom: "0.75rem" }}>
{pwMsg}
</p>
)}
<div className="form-group">
<label>Huidig wachtwoord</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<div className="form-group">
<label>Nieuw wachtwoord</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="form-group">
<label>Bevestig nieuw wachtwoord</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<button type="button" onClick={() => void changeAdminPassword()} disabled={pwBusy}>
{pwBusy ? "Bezig…" : "Wachtwoord wijzigen"}
</button>
</div>
</section>
)}
{tab === "shelves" && ( {tab === "shelves" && (
<section className="stack-section"> <section className="stack-section">
<div className="section-head"> <div className="section-head">

View file

@ -20,6 +20,28 @@ type GooglePlaySyncStatus =
| "REMOVED" | "REMOVED"
| "ERROR"; | "ERROR";
type ViewerIptv = {
id: string;
label: string | null;
baseUrl: string;
username: string;
hasPassword: boolean;
enableLive: boolean;
enableVod: boolean;
enableSeries: boolean;
lastOkAt: string | null;
lastError: string | null;
ok: boolean;
};
type AddonTokenRow = {
id: string;
label: string | null;
revoked: boolean;
createdAt: string;
hasOwnIptv: boolean;
};
type Viewer = { type Viewer = {
id: string; id: string;
email: string; email: string;
@ -38,6 +60,18 @@ type Viewer = {
createdAt: string; createdAt: string;
deviceCount: number; deviceCount: number;
devices: Device[]; devices: Device[];
iptv?: ViewerIptv | null;
addonTokens?: AddonTokenRow[];
};
type IptvFormState = {
label: string;
baseUrl: string;
username: string;
password: string;
enableLive: boolean;
enableVod: boolean;
enableSeries: boolean;
}; };
type PlayConfig = { type PlayConfig = {
@ -47,6 +81,8 @@ type PlayConfig = {
optInUrl: string | null; optInUrl: string | null;
}; };
const MASTER_BASE = "https://master.vonas.nl";
const SYNC_LABELS: Record<GooglePlaySyncStatus, string> = { const SYNC_LABELS: Record<GooglePlaySyncStatus, string> = {
NOT_ENABLED: "Uit", NOT_ENABLED: "Uit",
PENDING_ADD: "Bezig…", PENDING_ADD: "Bezig…",
@ -67,6 +103,37 @@ function formatWhen(iso: string | null) {
}); });
} }
function emptyIptvForm(): IptvFormState {
return {
label: "",
baseUrl: "",
username: "",
password: "",
enableLive: true,
enableVod: true,
enableSeries: true,
};
}
function formFromIptv(iptv: ViewerIptv | null | undefined): IptvFormState {
if (!iptv) return emptyIptvForm();
return {
label: iptv.label ?? "",
baseUrl: iptv.baseUrl ?? "",
username: iptv.username ?? "",
password: "",
enableLive: iptv.enableLive ?? true,
enableVod: iptv.enableVod ?? true,
enableSeries: iptv.enableSeries ?? true,
};
}
function iptvBadge(iptv: ViewerIptv | null | undefined): { label: string; tone: "ok" | "bad" | "off" } {
if (!iptv) return { label: "IPTV Geen", tone: "off" };
if (iptv.ok) return { label: "IPTV OK", tone: "ok" };
return { label: "IPTV Fout", tone: "bad" };
}
export default function ViewersPage() { export default function ViewersPage() {
useAuth(); useAuth();
const [viewers, setViewers] = useState<Viewer[]>([]); const [viewers, setViewers] = useState<Viewer[]>([]);
@ -82,6 +149,10 @@ export default function ViewersPage() {
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [actionId, setActionId] = useState<string | null>(null); const [actionId, setActionId] = useState<string | null>(null);
const [iptvForms, setIptvForms] = useState<Record<string, IptvFormState>>({});
const [iptvLocalMsg, setIptvLocalMsg] = useState<Record<string, string | null>>({});
const [iptvLocalErr, setIptvLocalErr] = useState<Record<string, string | null>>({});
const [addonInstallUrls, setAddonInstallUrls] = useState<Record<string, string | null>>({});
const load = useCallback(() => { const load = useCallback(() => {
fetch("/api/v1/admin/viewers", { credentials: "include" }) fetch("/api/v1/admin/viewers", { credentials: "include" })
@ -97,7 +168,7 @@ export default function ViewersPage() {
return next; return next;
}); });
}) })
.catch(() => setErr("Kon kijkers niet laden")); .catch(() => setErr("Kon gebruikers niet laden"));
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -116,6 +187,25 @@ export default function ViewersPage() {
return { total: viewers.length, active, appOn, playOn, playErr }; return { total: viewers.length, active, appOn, playOn, playErr };
}, [viewers]); }, [viewers]);
function openViewer(v: Viewer) {
const open = expandedId === v.id;
if (open) {
setExpandedId(null);
return;
}
setExpandedId(v.id);
setIptvForms((prev) => ({ ...prev, [v.id]: formFromIptv(v.iptv) }));
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: null }));
setIptvLocalErr((prev) => ({ ...prev, [v.id]: null }));
}
function patchIptvForm(id: string, patch: Partial<IptvFormState>) {
setIptvForms((prev) => ({
...prev,
[id]: { ...(prev[id] ?? emptyIptvForm()), ...patch },
}));
}
async function createViewer(e: FormEvent) { async function createViewer(e: FormEvent) {
e.preventDefault(); e.preventDefault();
setBusy(true); setBusy(true);
@ -139,6 +229,7 @@ export default function ViewersPage() {
setName(""); setName("");
setShowCreate(false); setShowCreate(false);
setExpandedId(data.viewer.id); setExpandedId(data.viewer.id);
setIptvForms((prev) => ({ ...prev, [data.viewer.id]: emptyIptvForm() }));
load(); load();
} finally { } finally {
setBusy(false); setBusy(false);
@ -165,6 +256,137 @@ export default function ViewersPage() {
} }
} }
async function saveIptv(v: Viewer) {
const form = iptvForms[v.id] ?? emptyIptvForm();
setActionId(v.id);
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: null }));
setIptvLocalErr((prev) => ({ ...prev, [v.id]: null }));
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}/iptv`, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
label: form.label.trim() || undefined,
baseUrl: form.baseUrl.trim(),
username: form.username.trim(),
password: form.password.trim() || undefined,
enableLive: form.enableLive,
enableVod: form.enableVod,
enableSeries: form.enableSeries,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const message = data.error?.message ?? "IPTV opslaan mislukt";
setIptvLocalErr((prev) => ({ ...prev, [v.id]: message }));
setErr(message);
return;
}
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: "IPTV-lijn opgeslagen" }));
setMsg("IPTV-lijn opgeslagen");
patchIptvForm(v.id, { password: "" });
load();
} finally {
setActionId(null);
}
}
async function testIptv(v: Viewer) {
setActionId(v.id);
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: null }));
setIptvLocalErr((prev) => ({ ...prev, [v.id]: null }));
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}/iptv/test`, {
method: "POST",
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const message = data.error?.message ?? "IPTV-test mislukt";
setIptvLocalErr((prev) => ({ ...prev, [v.id]: message }));
setErr(message);
load();
return;
}
const summary = `OK · live ${data.liveCategories ?? 0} · vod ${data.vodCategories ?? 0} · series ${data.seriesCategories ?? 0}`;
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: summary }));
setMsg("IPTV-test gelukt");
load();
} finally {
setActionId(null);
}
}
async function deleteIptv(v: Viewer) {
if (!confirm("IPTV-lijn ontkoppelen voor deze gebruiker?")) return;
setActionId(v.id);
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: null }));
setIptvLocalErr((prev) => ({ ...prev, [v.id]: null }));
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}/iptv`, {
method: "DELETE",
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const message = data.error?.message ?? "Ontkoppelen mislukt";
setIptvLocalErr((prev) => ({ ...prev, [v.id]: message }));
setErr(message);
return;
}
setIptvForms((prev) => ({ ...prev, [v.id]: emptyIptvForm() }));
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: "IPTV-lijn ontkoppeld" }));
setMsg("IPTV-lijn ontkoppeld");
load();
} finally {
setActionId(null);
}
}
async function createAddonToken(v: Viewer) {
setActionId(v.id);
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}/addon-tokens`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label: v.name || v.email }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Addon aanmaken mislukt");
return;
}
const installPath = typeof data.installUrl === "string" ? data.installUrl : "";
const fullUrl = installPath.startsWith("http")
? installPath
: `${MASTER_BASE}${installPath.startsWith("/") ? "" : "/"}${installPath}`;
setAddonInstallUrls((prev) => ({ ...prev, [v.id]: fullUrl }));
setMsg("Stremio-addon aangemaakt — kopieer de installatie-URL (eenmalig)");
load();
} finally {
setActionId(null);
}
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text);
setMsg("Gekopieerd naar klembord");
} catch {
setErr("Kopiëren mislukt");
}
}
async function setPlayAccess(v: Viewer, enabled: boolean) { async function setPlayAccess(v: Viewer, enabled: boolean) {
setActionId(v.id); setActionId(v.id);
setMsg(null); setMsg(null);
@ -289,13 +511,14 @@ export default function ViewersPage() {
<header className="page-header"> <header className="page-header">
<div> <div>
<p className="page-kicker">Mensen</p> <p className="page-kicker">Mensen</p>
<h1>Kijkers</h1> <h1>Gebruikers</h1>
<p className="page-lead muted"> <p className="page-lead muted">
App-accounts voor Android TV. Koppelen via <Link href="/link">/link</Link> met een code. Unified accounts voor app, IPTV en Stremio-addons. Koppelen via{" "}
<Link href="/link">/link</Link> met een code.
</p> </p>
</div> </div>
<button type="button" className="btn-primary" onClick={() => setShowCreate((v) => !v)}> <button type="button" className="btn-primary" onClick={() => setShowCreate((v) => !v)}>
{showCreate ? "Sluiten" : "Nieuwe kijker"} {showCreate ? "Sluiten" : "Nieuwe gebruiker"}
</button> </button>
</header> </header>
@ -339,7 +562,7 @@ export default function ViewersPage() {
{showCreate && ( {showCreate && (
<section className="card create-card"> <section className="card create-card">
<h2 className="card-subtitle">Nieuwe kijker</h2> <h2 className="card-subtitle">Nieuwe gebruiker</h2>
<form className="form-grid" onSubmit={createViewer}> <form className="form-grid" onSubmit={createViewer}>
<label> <label>
Naam Naam
@ -380,7 +603,7 @@ export default function ViewersPage() {
<div> <div>
<h2 className="card-subtitle">Overzicht</h2> <h2 className="card-subtitle">Overzicht</h2>
<p className="muted list-hint"> <p className="muted list-hint">
Tik op een rij om account, Google Play en apparaten te beheren. Tik op een rij om account, IPTV, Stremio-addon, Google Play en apparaten te beheren.
</p> </p>
</div> </div>
{playConfig?.configured ? ( {playConfig?.configured ? (
@ -400,7 +623,7 @@ export default function ViewersPage() {
</div> </div>
{viewers.length === 0 ? ( {viewers.length === 0 ? (
<p className="muted empty">Nog geen kijkers. Maak er een aan om te beginnen.</p> <p className="muted empty">Nog geen gebruikers. Maak er een aan om te beginnen.</p>
) : ( ) : (
<ul className="viewer-table"> <ul className="viewer-table">
{viewers.map((v) => { {viewers.map((v) => {
@ -413,12 +636,17 @@ export default function ViewersPage() {
: v.googlePlayAccess : v.googlePlayAccess
? "warn" ? "warn"
: "off"; : "off";
const iptv = v.iptv ?? null;
const iptvForm = iptvForms[v.id] ?? formFromIptv(iptv);
const tokens = v.addonTokens ?? [];
const badge = iptvBadge(iptv);
const installUrl = addonInstallUrls[v.id] ?? null;
return ( return (
<li key={v.id} className={`viewer-item${open ? " open" : ""}`}> <li key={v.id} className={`viewer-item${open ? " open" : ""}`}>
<button <button
type="button" type="button"
className="viewer-summary" className="viewer-summary"
onClick={() => setExpandedId(open ? null : v.id)} onClick={() => openViewer(v)}
aria-expanded={open} aria-expanded={open}
> >
<div className="viewer-who"> <div className="viewer-who">
@ -432,6 +660,7 @@ export default function ViewersPage() {
<span className={`pill ${v.appAccess ? "ok" : "off"}`}> <span className={`pill ${v.appAccess ? "ok" : "off"}`}>
{v.appAccess ? "App aan" : "App uit"} {v.appAccess ? "App aan" : "App uit"}
</span> </span>
<span className={`pill ${badge.tone}`}>{badge.label}</span>
<span className={`pill ${playTone}`}> <span className={`pill ${playTone}`}>
Play {SYNC_LABELS[v.googlePlaySyncStatus]} Play {SYNC_LABELS[v.googlePlaySyncStatus]}
</span> </span>
@ -486,7 +715,9 @@ export default function ViewersPage() {
<div className="toggle-row"> <div className="toggle-row">
<div> <div>
<strong>Live TV (IPTV)</strong> <strong>Live TV (IPTV)</strong>
<span className="muted">TV-tab in de Android-app</span> <span className="muted">
Aan alleen als er een IPTV-lijn hieronder staat
</span>
</div> </div>
<button <button
type="button" type="button"
@ -501,6 +732,172 @@ export default function ViewersPage() {
</div> </div>
</section> </section>
<section className="detail-block">
<h3>IPTV · Xtream / XUI</h3>
<p className="muted detail-copy">
Eigen IPTV-lijn voor deze gebruiker (apps + gekoppelde Stremio-addons).
Geen gedeelde fallback meer.
</p>
<div className="form-group">
<label className="field">
Label
<input
value={iptvForm.label}
onChange={(e) => patchIptvForm(v.id, { label: e.target.value })}
placeholder="Thuis / XUI"
/>
</label>
<label className="field">
Base URL
<input
value={iptvForm.baseUrl}
onChange={(e) => patchIptvForm(v.id, { baseUrl: e.target.value })}
placeholder="http://host:port"
inputMode="url"
/>
</label>
<label className="field">
Gebruikersnaam
<input
value={iptvForm.username}
onChange={(e) => patchIptvForm(v.id, { username: e.target.value })}
autoComplete="off"
/>
</label>
<label className="field">
Wachtwoord
<input
type="password"
value={iptvForm.password}
onChange={(e) => patchIptvForm(v.id, { password: e.target.value })}
placeholder={
iptv?.hasPassword ? "•••••••• (leeg = behouden)" : "Wachtwoord"
}
autoComplete="new-password"
/>
</label>
<div className="check-rows">
<label className="check-row">
<input
type="checkbox"
checked={iptvForm.enableLive}
onChange={(e) =>
patchIptvForm(v.id, { enableLive: e.target.checked })
}
/>
Live
</label>
<label className="check-row">
<input
type="checkbox"
checked={iptvForm.enableVod}
onChange={(e) =>
patchIptvForm(v.id, { enableVod: e.target.checked })
}
/>
VOD
</label>
<label className="check-row">
<input
type="checkbox"
checked={iptvForm.enableSeries}
onChange={(e) =>
patchIptvForm(v.id, { enableSeries: e.target.checked })
}
/>
Series
</label>
</div>
</div>
{iptv?.lastError && (
<p className="text-bad sync-err">Laatste fout: {iptv.lastError}</p>
)}
{iptvLocalErr[v.id] && (
<p className="text-bad sync-err">{iptvLocalErr[v.id]}</p>
)}
{iptvLocalMsg[v.id] && (
<p className="muted detail-copy">{iptvLocalMsg[v.id]}</p>
)}
{iptv?.lastOkAt && !iptv.lastError && (
<p className="muted field-hint">
Laatst OK: {formatWhen(iptv.lastOkAt)}
</p>
)}
<div className="detail-actions">
<button
type="button"
className="btn-primary"
disabled={actionId === v.id}
onClick={() => void saveIptv(v)}
>
Opslaan
</button>
<button
type="button"
disabled={actionId === v.id || !iptv}
onClick={() => void testIptv(v)}
>
Testen
</button>
<button
type="button"
className="danger"
disabled={actionId === v.id || !iptv}
onClick={() => void deleteIptv(v)}
>
Ontkoppelen
</button>
</div>
</section>
<section className="detail-block">
<h3>Stremio-addon</h3>
<p className="muted detail-copy">
Stremio gebruikt de IPTV-lijn van deze gebruiker.
</p>
{tokens.length === 0 ? (
<p className="muted">Nog geen addon-tokens.</p>
) : (
<ul className="addon-rows">
{tokens.map((t) => (
<li key={t.id}>
<div>
<strong>{t.label || "Addon"}</strong>
<span className="muted">
{t.revoked ? "Ingetrokken" : "Actief"}
{t.hasOwnIptv ? " · eigen IPTV" : ""}
{t.createdAt
? ` · aangemaakt ${formatWhen(t.createdAt)}`
: ""}
</span>
</div>
</li>
))}
</ul>
)}
<div className="detail-actions">
<button
type="button"
className="btn-primary"
disabled={actionId === v.id}
onClick={() => void createAddonToken(v)}
>
Addon aanmaken
</button>
</div>
{installUrl && (
<div className="install-box">
<p className="muted field-hint">
Installatie-URL (eenmalig zichtbaar — kopieer nu):
</p>
<code className="install-url">{installUrl}</code>
<button type="button" onClick={() => void copyText(installUrl)}>
Kopiëren
</button>
</div>
)}
</section>
<section className="detail-block"> <section className="detail-block">
<h3>Google Play</h3> <h3>Google Play</h3>
{!playConfig?.configured ? ( {!playConfig?.configured ? (
@ -879,6 +1276,10 @@ export default function ViewersPage() {
transform: translateX(18px); transform: translateX(18px);
background: var(--ok); background: var(--ok);
} }
.form-group {
display: grid;
gap: 0.55rem;
}
.field { .field {
display: grid; display: grid;
gap: 0.3rem; gap: 0.3rem;
@ -892,6 +1293,19 @@ export default function ViewersPage() {
font-size: 0.75rem; font-size: 0.75rem;
margin-top: -0.2rem; margin-top: -0.2rem;
} }
.check-rows {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1rem;
}
.check-row {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--text-muted);
cursor: pointer;
}
.play-status-line { .play-status-line {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -914,6 +1328,43 @@ export default function ViewersPage() {
.optin-link { .optin-link {
font-size: 0.85rem; font-size: 0.85rem;
} }
.addon-rows {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
}
.addon-rows li {
padding: 0.65rem 0.75rem;
border-radius: 8px;
border: 1px solid var(--border-dim);
background: var(--bg-elevated);
}
.addon-rows strong {
display: block;
font-size: 0.9rem;
}
.addon-rows .muted {
display: block;
font-size: 0.75rem;
margin-top: 0.1rem;
}
.install-box {
display: grid;
gap: 0.4rem;
padding: 0.7rem 0.8rem;
border-radius: 8px;
border: 1px dashed var(--border);
background: var(--bg-elevated);
}
.install-url {
display: block;
font-family: var(--font-mono);
font-size: 0.75rem;
word-break: break-all;
color: var(--text-bright);
}
.devices-block { .devices-block {
border-top: 1px solid var(--border-dim); border-top: 1px solid var(--border-dim);
padding-top: 0.85rem; padding-top: 0.85rem;

View file

@ -12,8 +12,7 @@ const desktopLinks = [
{ href: "/series", label: "Series" }, { href: "/series", label: "Series" },
{ href: "/downloads", label: "Downloads" }, { href: "/downloads", label: "Downloads" },
{ href: "/streams", label: "Streams" }, { href: "/streams", label: "Streams" },
{ href: "/accounts", label: "Accounts" }, { href: "/viewers", label: "Gebruikers" },
{ href: "/viewers", label: "Kijkers" },
{ href: "/settings", label: "Instellingen" }, { href: "/settings", label: "Instellingen" },
]; ];
@ -24,7 +23,7 @@ const mobileLinks = [
{ href: "/movies", label: "Movies", short: "Films" }, { href: "/movies", label: "Movies", short: "Films" },
{ href: "/downloads", label: "Downloads", short: "DL" }, { href: "/downloads", label: "Downloads", short: "DL" },
{ href: "/streams", label: "Streams", short: "Live" }, { href: "/streams", label: "Streams", short: "Live" },
{ href: "/accounts", label: "Accounts", short: "Acc." }, { href: "/viewers", label: "Users", short: "Users" },
]; ];
export function Nav() { export function Nav() {

View file

@ -0,0 +1,41 @@
-- Unify IPTV ownership under ViewerUser (Option C); keep legacy addon-token lines.
ALTER TABLE "iptv_lines" ADD COLUMN IF NOT EXISTS "viewer_user_id" TEXT;
ALTER TABLE "iptv_lines" ALTER COLUMN "addon_token_id" DROP NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS "iptv_lines_viewer_user_id_key" ON "iptv_lines"("viewer_user_id");
ALTER TABLE "addon_tokens" ADD COLUMN IF NOT EXISTS "viewer_user_id" TEXT;
CREATE INDEX IF NOT EXISTS "addon_tokens_viewer_user_id_idx" ON "addon_tokens"("viewer_user_id");
-- Gebruikers die via legacy iptvAddonTokenId wezen → eigenaar van die lijn
UPDATE "iptv_lines" AS l
SET "viewer_user_id" = v."id"
FROM "viewer_users" AS v
WHERE v."iptv_addon_token_id" IS NOT NULL
AND v."iptv_addon_token_id" = l."addon_token_id"
AND l."viewer_user_id" IS NULL;
-- Stremio-addons die die gebruikers gebruikten → koppel aan gebruiker
UPDATE "addon_tokens" AS t
SET "viewer_user_id" = v."id"
FROM "viewer_users" AS v
WHERE v."iptv_addon_token_id" IS NOT NULL
AND v."iptv_addon_token_id" = t."id"
AND t."viewer_user_id" IS NULL;
DO $$ BEGIN
ALTER TABLE "iptv_lines"
ADD CONSTRAINT "iptv_lines_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 "addon_tokens"
ADD CONSTRAINT "addon_tokens_viewer_user_id_fkey"
FOREIGN KEY ("viewer_user_id") REFERENCES "viewer_users"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

View file

@ -66,7 +66,7 @@ model User {
@@map("users") @@map("users")
} }
/** App-kijker (Android TV / phone) — apart van admin User. */ /** App-gebruiker (Android/iOS/Tizen) — huishouden met optioneel IPTV + Stremio-addons. */
model ViewerUser { model ViewerUser {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
@ -74,8 +74,9 @@ model ViewerUser {
name String? name String?
enabled Boolean @default(true) enabled Boolean @default(true)
appAccess Boolean @default(true) @map("app_access") appAccess Boolean @default(true) @map("app_access")
/** Live TV in Android-app (Xtream via gekoppelde addon-token). */ /** Live TV in de apps (Xtream-lijn op deze gebruiker). */
iptvAccess Boolean @default(false) @map("iptv_access") iptvAccess Boolean @default(false) @map("iptv_access")
/** @deprecated Legacy: IPTV via addon-token; voorkeur = iptvLine op deze user. */
iptvAddonTokenId String? @map("iptv_addon_token_id") iptvAddonTokenId String? @map("iptv_addon_token_id")
/** Google-account voor Play Closed Testing; fallback = email */ /** Google-account voor Play Closed Testing; fallback = email */
googlePlayEmail String? @map("google_play_email") googlePlayEmail String? @map("google_play_email")
@ -92,7 +93,10 @@ model ViewerUser {
profiles ViewerProfile[] profiles ViewerProfile[]
linkCodes DeviceLinkCode[] linkCodes DeviceLinkCode[]
googlePlayAuditLogs GooglePlayAccessAuditLog[] googlePlayAuditLogs GooglePlayAccessAuditLog[]
iptvAddonToken AddonToken? @relation(fields: [iptvAddonTokenId], references: [id], onDelete: SetNull) /** Legacy koppeling — behouden voor migratie. */
iptvAddonToken AddonToken? @relation("ViewerLegacyIptvToken", fields: [iptvAddonTokenId], references: [id], onDelete: SetNull)
iptvLine IptvLine?
addonTokens AddonToken[] @relation("ViewerAddonTokens")
@@map("viewer_users") @@map("viewer_users")
} }
@ -238,23 +242,30 @@ model AdminSession {
model AddonToken { model AddonToken {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @map("user_id") userId String @map("user_id")
/** Gebruiker (huishouden) waaraan deze Stremio-addon hangt. */
viewerUserId String? @map("viewer_user_id")
tokenHash String @unique @map("token_hash") tokenHash String @unique @map("token_hash")
label String? label String?
revoked Boolean @default(false) revoked Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
viewerUser ViewerUser? @relation("ViewerAddonTokens", fields: [viewerUserId], references: [id], onDelete: SetNull)
iptvLine IptvLine? iptvLine IptvLine?
playbackSessions PlaybackSession[] playbackSessions PlaybackSession[]
iptvViewers ViewerUser[] legacyIptvViewers ViewerUser[] @relation("ViewerLegacyIptvToken")
@@index([viewerUserId])
@@map("addon_tokens") @@map("addon_tokens")
} }
/** Xtream/XUI credentials bound to one Stremio addon install (token). */ /** Xtream/XUI credentials — bij voorkeur per ViewerUser; legacy optioneel per AddonToken. */
model IptvLine { model IptvLine {
id String @id @default(uuid()) id String @id @default(uuid())
addonTokenId String @unique @map("addon_token_id") /** Primair: IPTV-lijn van één app-gebruiker. */
viewerUserId String? @unique @map("viewer_user_id")
/** Legacy: IPTV direct op Stremio-addon (pre-merge). */
addonTokenId String? @unique @map("addon_token_id")
label String? label String?
baseUrl String @map("base_url") baseUrl String @map("base_url")
username String username String
@ -268,7 +279,8 @@ model IptvLine {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
addonToken AddonToken @relation(fields: [addonTokenId], references: [id], onDelete: Cascade) viewerUser ViewerUser? @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
addonToken AddonToken? @relation(fields: [addonTokenId], references: [id], onDelete: Cascade)
@@map("iptv_lines") @@map("iptv_lines")
} }

View file

@ -369,12 +369,18 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
app.post("/api/v1/admin/addon-tokens", { preHandler: requireAdmin }, async (request) => { app.post("/api/v1/admin/addon-tokens", { preHandler: requireAdmin }, async (request) => {
const user = (request as import("../auth/routes").AuthenticatedRequest).user; const user = (request as import("../auth/routes").AuthenticatedRequest).user;
const body = request.body as { label?: string }; const body = request.body as { label?: string; viewerUserId?: string };
const token = generateSecureToken(24); const token = generateSecureToken(24);
if (body.viewerUserId) {
const viewer = await prisma.viewerUser.findUnique({ where: { id: body.viewerUserId } });
if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404);
}
const created = await prisma.addonToken.create({ const created = await prisma.addonToken.create({
data: { data: {
userId: user.id, userId: user.id,
viewerUserId: body.viewerUserId?.trim() || null,
tokenHash: hashToken(token), tokenHash: hashToken(token),
label: body.label, label: body.label,
}, },
@ -384,6 +390,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
token, token,
id: created.id, id: created.id,
label: created.label, label: created.label,
viewerUserId: created.viewerUserId,
installUrl: `/stremio/${token}/manifest.json`, installUrl: `/stremio/${token}/manifest.json`,
}; };
}); });
@ -393,7 +400,10 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
const tokens = await prisma.addonToken.findMany({ const tokens = await prisma.addonToken.findMany({
where: { userId: user.id }, where: { userId: user.id },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { iptvLine: { select: { id: true, enableLive: true, lastOkAt: true, lastError: true } } }, include: {
iptvLine: { select: { id: true, enableLive: true, lastOkAt: true, lastError: true } },
viewerUser: { select: { id: true, email: true, name: true } },
},
}); });
return { return {
tokens: tokens.map((t) => ({ tokens: tokens.map((t) => ({
@ -401,6 +411,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
label: t.label, label: t.label,
revoked: t.revoked, revoked: t.revoked,
createdAt: t.createdAt, createdAt: t.createdAt,
viewerUserId: t.viewerUserId,
viewerLabel: t.viewerUser?.name || t.viewerUser?.email || null,
hasIptv: !!t.iptvLine, hasIptv: !!t.iptvLine,
iptvOk: t.iptvLine ? !t.iptvLine.lastError && !!t.iptvLine.lastOkAt : false, iptvOk: t.iptvLine ? !t.iptvLine.lastError && !!t.iptvLine.lastOkAt : false,
})), })),

View file

@ -106,12 +106,13 @@ async function main() {
console.warn("Could not ensure still_url/logo_url columns:", err); console.warn("Could not ensure still_url/logo_url columns:", err);
} }
// IPTV userlines (Xtream) bound to addon tokens. // IPTV userlines (Xtream) — per gebruiker en/of legacy per addon-token.
try { try {
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "iptv_lines" ( CREATE TABLE IF NOT EXISTS "iptv_lines" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"addon_token_id" TEXT NOT NULL, "addon_token_id" TEXT,
"viewer_user_id" TEXT,
"label" TEXT, "label" TEXT,
"base_url" TEXT NOT NULL, "base_url" TEXT NOT NULL,
"username" TEXT NOT NULL, "username" TEXT NOT NULL,
@ -126,9 +127,41 @@ async function main() {
CONSTRAINT "iptv_lines_pkey" PRIMARY KEY ("id") CONSTRAINT "iptv_lines_pkey" PRIMARY KEY ("id")
) )
`); `);
await prisma.$executeRawUnsafe(
`ALTER TABLE "iptv_lines" ADD COLUMN IF NOT EXISTS "viewer_user_id" TEXT`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE "iptv_lines" ALTER COLUMN "addon_token_id" DROP NOT NULL`
);
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`CREATE UNIQUE INDEX IF NOT EXISTS "iptv_lines_addon_token_id_key" ON "iptv_lines"("addon_token_id")` `CREATE UNIQUE INDEX IF NOT EXISTS "iptv_lines_addon_token_id_key" ON "iptv_lines"("addon_token_id")`
); );
await prisma.$executeRawUnsafe(
`CREATE UNIQUE INDEX IF NOT EXISTS "iptv_lines_viewer_user_id_key" ON "iptv_lines"("viewer_user_id")`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE "addon_tokens" ADD COLUMN IF NOT EXISTS "viewer_user_id" TEXT`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS "addon_tokens_viewer_user_id_idx" ON "addon_tokens"("viewer_user_id")`
);
// Migreer legacy kijker→token koppeling naar viewer-owned IPTV
await prisma.$executeRawUnsafe(`
UPDATE "iptv_lines" AS l
SET "viewer_user_id" = v."id"
FROM "viewer_users" AS v
WHERE v."iptv_addon_token_id" IS NOT NULL
AND v."iptv_addon_token_id" = l."addon_token_id"
AND l."viewer_user_id" IS NULL
`);
await prisma.$executeRawUnsafe(`
UPDATE "addon_tokens" AS t
SET "viewer_user_id" = v."id"
FROM "viewer_users" AS v
WHERE v."iptv_addon_token_id" IS NOT NULL
AND v."iptv_addon_token_id" = t."id"
AND t."viewer_user_id" IS NULL
`);
} catch (err) { } catch (err) {
console.warn("Could not ensure iptv_lines table:", err); console.warn("Could not ensure iptv_lines table:", err);
} }

View file

@ -57,12 +57,25 @@ export function parseIptvId(id: string): {
return null; return null;
} }
export async function loadXtreamForToken( type LoadedXtream = {
tokenId: string, client: XtreamClient;
line: { id: string; enableLive: boolean; enableVod: boolean; enableSeries: boolean };
/** Stabiele cache-sleutel (lijn-id). */
cacheKey: string;
};
function xtreamFromRow(
line: {
id: string;
baseUrl: string;
username: string;
passwordEnc: string;
enableLive: boolean;
enableVod: boolean;
enableSeries: boolean;
},
config: Config config: Config
): Promise<{ client: XtreamClient; line: { enableLive: boolean; enableVod: boolean; enableSeries: boolean } } | null> { ): LoadedXtream | null {
const line = await prisma.iptvLine.findUnique({ where: { addonTokenId: tokenId } });
if (!line) return null;
try { try {
const client = XtreamClient.fromEncrypted({ const client = XtreamClient.fromEncrypted({
baseUrl: line.baseUrl, baseUrl: line.baseUrl,
@ -73,16 +86,80 @@ export async function loadXtreamForToken(
return { return {
client, client,
line: { line: {
id: line.id,
enableLive: line.enableLive, enableLive: line.enableLive,
enableVod: line.enableVod, enableVod: line.enableVod,
enableSeries: line.enableSeries, enableSeries: line.enableSeries,
}, },
cacheKey: line.id,
}; };
} catch { } catch {
return null; return null;
} }
} }
export async function loadXtreamByLineId(
lineId: string,
config: Config
): Promise<LoadedXtream | null> {
const line = await prisma.iptvLine.findUnique({ where: { id: lineId } });
if (!line) return null;
return xtreamFromRow(line, config);
}
/** Stremio-addon: eigen legacy-lijn óf IPTV van gekoppelde gebruiker. */
export async function loadXtreamForToken(
tokenId: string,
config: Config
): Promise<LoadedXtream | null> {
const token = await prisma.addonToken.findUnique({
where: { id: tokenId },
include: { iptvLine: true },
});
if (!token || token.revoked) return null;
if (token.iptvLine) {
return xtreamFromRow(token.iptvLine, config);
}
if (token.viewerUserId) {
const viewerLine = await prisma.iptvLine.findUnique({
where: { viewerUserId: token.viewerUserId },
});
if (viewerLine) return xtreamFromRow(viewerLine, config);
}
return null;
}
/** App-gebruiker: alleen diens eigen IPTV-lijn (of legacy token-lijn), nooit een globale fallback. */
export async function loadXtreamForViewer(
viewerId: string,
config: Config
): Promise<LoadedXtream | null> {
const viewer = await prisma.viewerUser.findUnique({
where: { id: viewerId },
select: {
enabled: true,
appAccess: true,
iptvAccess: true,
iptvAddonTokenId: true,
iptvLine: true,
},
});
if (!viewer?.enabled || !viewer.appAccess || !viewer.iptvAccess) return null;
if (viewer.iptvLine) {
return xtreamFromRow(viewer.iptvLine, config);
}
if (viewer.iptvAddonTokenId) {
return loadXtreamForToken(viewer.iptvAddonTokenId, config);
}
return null;
}
export async function buildIptvCatalogs(tokenId: string, config: Config) { export async function buildIptvCatalogs(tokenId: string, config: Config) {
const xt = await loadXtreamForToken(tokenId, config); const xt = await loadXtreamForToken(tokenId, config);
if (!xt) return { catalogs: [] as unknown[], types: [] as string[], genreLive: [] as string[] }; if (!xt) return { catalogs: [] as unknown[], types: [] as string[], genreLive: [] as string[] };

View file

@ -1,7 +1,7 @@
import { prisma } from "../database/client"; import { prisma } from "../database/client";
import type { Config } from "../config"; import type { Config } from "../config";
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
import { loadXtreamForToken } from "../iptv/service"; import { loadXtreamByLineId, loadXtreamForViewer } from "../iptv/service";
import { import {
decodeEpgText, decodeEpgText,
epgToIso, epgToIso,
@ -29,7 +29,7 @@ function normCatLabel(s: string): string {
/** Maak Xtream-categorienamen leesbaarder voor TV-gebruikers. */ /** Maak Xtream-categorienamen leesbaarder voor TV-gebruikers. */
export function friendlyCategoryName(raw: string): string { export function friendlyCategoryName(raw: string): string {
let s = raw.trim().replace(/^[\s★☆●▪•\-–—]+/, ""); let s = raw.trim().replace(/^[\s\u2605\u2606\u25CF\u25AA\u2022\-\u2013\u2014]+/u, "");
const m = s.match(/^([A-Z]{2,3})\s*[|·:]\s*(.+)$/i); const m = s.match(/^([A-Z]{2,3})\s*[|·:]\s*(.+)$/i);
if (m) { if (m) {
const rest = m[2].trim(); const rest = m[2].trim();
@ -42,53 +42,8 @@ export function friendlyCategoryName(raw: string): string {
return s.trim() || raw.trim() || "Overig"; return s.trim() || raw.trim() || "Overig";
} }
export async function resolveViewerIptvToken(viewerId: string): Promise<string | null> {
const viewer = await prisma.viewerUser.findUnique({
where: { id: viewerId },
select: { iptvAccess: true, iptvAddonTokenId: true, enabled: true, appAccess: true },
});
if (!viewer?.enabled || !viewer.appAccess || !viewer.iptvAccess) return null;
// Gekoppelde lijn altijd proberen — lastError mag TV niet stilleggen
if (viewer.iptvAddonTokenId) {
const linked = await prisma.addonToken.findFirst({
where: {
id: viewer.iptvAddonTokenId,
revoked: false,
iptvLine: { enableLive: true },
},
select: { id: true },
});
if (linked) return linked.id;
}
const fallbackOk = await prisma.iptvLine.findFirst({
where: {
enableLive: true,
lastError: null,
addonToken: { revoked: false },
},
select: { addonTokenId: true },
orderBy: { updatedAt: "desc" },
});
if (fallbackOk) return fallbackOk.addonTokenId;
const fallbackAny = await prisma.iptvLine.findFirst({
where: {
enableLive: true,
addonToken: { revoked: false },
},
select: { addonTokenId: true },
orderBy: { updatedAt: "desc" },
});
return fallbackAny?.addonTokenId ?? null;
}
export async function getViewerIptvStatus(viewerId: string, config: Config) { export async function getViewerIptvStatus(viewerId: string, config: Config) {
const tokenId = await resolveViewerIptvToken(viewerId); const xt = await loadXtreamForViewer(viewerId, config);
if (!tokenId) return { enabled: false as const };
const xt = await loadXtreamForToken(tokenId, config);
if (!xt?.line.enableLive) return { enabled: false as const }; if (!xt?.line.enableLive) return { enabled: false as const };
const cats = await xt.client.getLiveCategories().catch(() => []); const cats = await xt.client.getLiveCategories().catch(() => []);
@ -99,10 +54,8 @@ export async function getViewerIptvStatus(viewerId: string, config: Config) {
} }
export async function getViewerIptvCategories(viewerId: string, config: Config) { export async function getViewerIptvCategories(viewerId: string, config: Config) {
const tokenId = await resolveViewerIptvToken(viewerId); const xt = await loadXtreamForViewer(viewerId, config);
if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403); if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
const xt = await loadXtreamForToken(tokenId, config);
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);
// Categorieën + (korte) zenderlijst parallel — categorieën nooit laten falen op trage streams // Categorieën + (korte) zenderlijst parallel — categorieën nooit laten falen op trage streams
@ -166,7 +119,7 @@ export async function getViewerIptvCategories(viewerId: string, config: Config)
* Match op ruwe naam (zonder landcode te strippen). * Match op ruwe naam (zonder landcode te strippen).
*/ */
export function isAggregateCategoryName(raw: string): boolean { export function isAggregateCategoryName(raw: string): boolean {
const n = normCatLabel(String(raw ?? "").replace(/^[\s★☆●▪•\-–—]+/, "")); const n = normCatLabel(String(raw ?? "").replace(/^[\s\u2605\u2606\u25CF\u25AA\u2022\-\u2013\u2014]+/u, ""));
return AGGREGATE_LABELS.has(n); return AGGREGATE_LABELS.has(n);
} }
@ -191,10 +144,8 @@ export async function getViewerIptvChannels(
categoryId?: string, categoryId?: string,
includeEpg = false includeEpg = false
) { ) {
const tokenId = await resolveViewerIptvToken(viewerId); const xt = await loadXtreamForViewer(viewerId, config);
if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403); if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
const xt = await loadXtreamForToken(tokenId, config);
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);
let streams = let streams =
@ -205,10 +156,10 @@ export async function getViewerIptvChannels(
streams = sortChannels(streams); streams = sortChannels(streams);
const epgTitles = includeEpg const epgTitles = includeEpg
? await resolveCategoryEpgTitles(tokenId, categoryId, streams, xt.client, { ? await resolveCategoryEpgTitles(xt.cacheKey, categoryId, streams, xt.client, {
waitForXmltv: true, waitForXmltv: true,
}) })
: peekCategoryEpgTitles(tokenId, categoryId); : peekCategoryEpgTitles(xt.cacheKey, categoryId);
return { return {
channels: streams.map((s) => { channels: streams.map((s) => {
@ -231,10 +182,8 @@ export async function getViewerIptvPlayUrl(
config: Config, config: Config,
streamId: number streamId: number
) { ) {
const tokenId = await resolveViewerIptvToken(viewerId); const xt = await loadXtreamForViewer(viewerId, config);
if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403); if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
const xt = await loadXtreamForToken(tokenId, config);
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();
@ -271,17 +220,15 @@ export async function getViewerIptvPlayUrl(
} }
export async function getViewerIptvEpg(viewerId: string, config: Config, streamId: number) { export async function getViewerIptvEpg(viewerId: string, config: Config, streamId: number) {
const tokenId = await resolveViewerIptvToken(viewerId); const xt = await loadXtreamForViewer(viewerId, config);
if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403); if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
const xt = await loadXtreamForToken(tokenId, config);
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 === streamId);
if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404); if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
const cached = getCachedStreamEpg(tokenId, streamId); const cached = getCachedStreamEpg(xt.cacheKey, streamId);
if (cached && (cached.now || cached.next)) { if (cached && (cached.now || cached.next)) {
return { return {
streamId, streamId,
@ -299,7 +246,7 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
const xmlMap = await xt.client.getXmltvNowNextByChannelId(); const xmlMap = await xt.client.getXmltvNowNextByChannelId();
const hit = xmlMap.get(epgId) ?? xmlMap.get(epgId.toLowerCase()); const hit = xmlMap.get(epgId) ?? xmlMap.get(epgId.toLowerCase());
if (hit && (hit.now || hit.next)) { if (hit && (hit.now || hit.next)) {
setCachedStreamEpg(tokenId, streamId, hit.now, hit.next); setCachedStreamEpg(xt.cacheKey, streamId, hit.now, hit.next);
return { return {
streamId, streamId,
channelName: ch.name, channelName: ch.name,
@ -315,7 +262,7 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
const epg = await xt.client.getShortEpg(streamId, 12); const epg = await xt.client.getShortEpg(streamId, 12);
const { now: nowProgram, next: nextProgram } = parseNowNextPrograms(epg); const { now: nowProgram, next: nextProgram } = parseNowNextPrograms(epg);
setCachedStreamEpg(tokenId, streamId, nowProgram, nextProgram); setCachedStreamEpg(xt.cacheKey, streamId, nowProgram, nextProgram);
return { return {
streamId, streamId,
@ -667,17 +614,25 @@ export function startIptvEpgWarmup(config: Config) {
const tick = async () => { const tick = async () => {
try { try {
const lines = await prisma.iptvLine.findMany({ const lines = await prisma.iptvLine.findMany({
where: { enableLive: true, lastError: null, addonToken: { revoked: false } }, where: {
select: { addonTokenId: true }, enableLive: true,
take: 4, lastError: null,
OR: [
{ viewerUserId: { not: null } },
{ addonToken: { is: { revoked: false } } },
],
},
select: { id: true },
take: 8,
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
}); });
for (const line of lines) { for (const line of lines) {
if (xmltvLineRefreshing.has(line.addonTokenId)) continue; if (xmltvLineRefreshing.has(line.id)) continue;
xmltvLineRefreshing.add(line.addonTokenId); xmltvLineRefreshing.add(line.id);
try { try {
const xt = await loadXtreamForToken(line.addonTokenId, config); const xt = await loadXtreamByLineId(line.id, config);
const cacheKey = xt?.cacheKey ?? line.id;
if (!xt?.line.enableLive) continue; if (!xt?.line.enableLive) continue;
// Primair: hele XMLTV warmen (ttl=0 forceert refresh) // Primair: hele XMLTV warmen (ttl=0 forceert refresh)
@ -701,19 +656,19 @@ export function startIptvEpgWarmup(config: Config) {
let streams = await xt.client.getLiveStreams(catId).catch(() => []); let streams = await xt.client.getLiveStreams(catId).catch(() => []);
streams = sortChannels(streams); streams = sortChannels(streams);
if (streams.length === 0) continue; if (streams.length === 0) continue;
await resolveCategoryEpgTitles(line.addonTokenId, catId, streams, xt.client, { await resolveCategoryEpgTitles(cacheKey, catId, streams, xt.client, {
waitForXmltv: true, waitForXmltv: true,
}); });
} }
const all = sortChannels(await xt.client.getLiveStreams().catch(() => [])); const all = sortChannels(await xt.client.getLiveStreams().catch(() => []));
if (all.length > 0) { if (all.length > 0) {
await resolveCategoryEpgTitles(line.addonTokenId, "__all__", all, xt.client, { await resolveCategoryEpgTitles(cacheKey, "__all__", all, xt.client, {
waitForXmltv: true, waitForXmltv: true,
}); });
} }
} finally { } finally {
xmltvLineRefreshing.delete(line.addonTokenId); xmltvLineRefreshing.delete(line.id);
} }
} }
} catch (err) { } catch (err) {

View file

@ -61,6 +61,206 @@ export function registerViewerRoutes(
return { viewer }; return { viewer };
}); });
app.get("/api/v1/admin/viewers/:id/iptv", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const { prisma } = await import("../database/client");
const viewer = await prisma.viewerUser.findUnique({
where: { id },
include: { iptvLine: true },
});
if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404);
const line = viewer.iptvLine;
return {
iptv: line
? {
id: line.id,
label: line.label,
baseUrl: line.baseUrl,
username: line.username,
hasPassword: true,
enableLive: line.enableLive,
enableVod: line.enableVod,
enableSeries: line.enableSeries,
lastOkAt: line.lastOkAt,
lastError: line.lastError,
}
: null,
};
});
app.put("/api/v1/admin/viewers/:id/iptv", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const body = request.body as {
label?: string;
baseUrl?: string;
username?: string;
password?: string;
enableLive?: boolean;
enableVod?: boolean;
enableSeries?: boolean;
};
const { prisma } = await import("../database/client");
const viewer = await prisma.viewerUser.findUnique({
where: { id },
include: { iptvLine: true },
});
if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404);
if (!body.baseUrl?.trim() || !body.username?.trim()) {
throw new AppError("INVALID_REQUEST", "baseUrl and username required", 400);
}
if (!body.password?.trim() && !viewer.iptvLine) {
throw new AppError("INVALID_REQUEST", "password required for new IPTV line", 400);
}
const { encryptSecret } = await import("../security/crypto");
const { XtreamClient, parseXtreamBaseUrl } = await import("../iptv/xtream");
const baseUrl = parseXtreamBaseUrl(body.baseUrl);
const passwordEnc = body.password?.trim()
? encryptSecret(body.password.trim(), config.SESSION_SECRET)
: viewer.iptvLine!.passwordEnc;
try {
const client = XtreamClient.fromEncrypted({
baseUrl,
username: body.username.trim(),
passwordEnc,
sessionSecret: config.SESSION_SECRET,
});
await client.authenticate();
const liveCats = body.enableLive === false ? [] : await client.getLiveCategories().catch(() => []);
const line = await prisma.iptvLine.upsert({
where: { viewerUserId: id },
create: {
viewerUserId: id,
label: body.label?.trim() || null,
baseUrl,
username: body.username.trim(),
passwordEnc,
enableLive: body.enableLive ?? true,
enableVod: body.enableVod ?? true,
enableSeries: body.enableSeries ?? true,
lastOkAt: new Date(),
lastError: null,
},
update: {
label: body.label?.trim() || null,
baseUrl,
username: body.username.trim(),
passwordEnc,
enableLive: body.enableLive ?? true,
enableVod: body.enableVod ?? true,
enableSeries: body.enableSeries ?? true,
lastOkAt: new Date(),
lastError: null,
},
});
await prisma.viewerUser.update({
where: { id },
data: { iptvAccess: true },
});
return {
ok: true,
iptv: {
id: line.id,
liveCategories: liveCats.length,
lastOkAt: line.lastOkAt,
},
};
} catch (err) {
const message = err instanceof Error ? err.message : "Xtream connect failed";
if (viewer.iptvLine) {
await prisma.iptvLine.update({
where: { id: viewer.iptvLine.id },
data: { lastError: message },
});
}
throw new AppError("IPTV_ERROR", message, 400);
}
});
app.post("/api/v1/admin/viewers/:id/iptv/test", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const { prisma } = await import("../database/client");
const viewer = await prisma.viewerUser.findUnique({
where: { id },
include: { iptvLine: true },
});
if (!viewer?.iptvLine) throw new AppError("NOT_FOUND", "Geen IPTV-lijn op deze gebruiker", 404);
const { XtreamClient } = await import("../iptv/xtream");
try {
const client = XtreamClient.fromEncrypted({
baseUrl: viewer.iptvLine.baseUrl,
username: viewer.iptvLine.username,
passwordEnc: viewer.iptvLine.passwordEnc,
sessionSecret: config.SESSION_SECRET,
});
const auth = await client.authenticate();
const [live, vod, series] = await Promise.all([
client.getLiveCategories().catch(() => []),
client.getVodCategories().catch(() => []),
client.getSeriesCategories().catch(() => []),
]);
await prisma.iptvLine.update({
where: { id: viewer.iptvLine.id },
data: { lastOkAt: new Date(), lastError: null },
});
return {
ok: true,
status: auth.status,
expDate: auth.expDate,
liveCategories: live.length,
vodCategories: vod.length,
seriesCategories: series.length,
liveCategoryNames: live.slice(0, 40).map((c) => String(c.category_name ?? "")),
};
} catch (err) {
const message = err instanceof Error ? err.message : "Test mislukt";
await prisma.iptvLine.update({
where: { id: viewer.iptvLine.id },
data: { lastError: message },
});
throw new AppError("IPTV_ERROR", message, 400);
}
});
app.delete("/api/v1/admin/viewers/:id/iptv", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const { prisma } = await import("../database/client");
await prisma.iptvLine.deleteMany({ where: { viewerUserId: id } });
await prisma.viewerUser.update({
where: { id },
data: { iptvAccess: false },
});
return { ok: true };
});
app.post("/api/v1/admin/viewers/:id/addon-tokens", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const admin = (request as AuthenticatedRequest).user;
const body = request.body as { label?: string };
const { prisma } = await import("../database/client");
const { generateSecureToken, hashToken } = await import("../security/crypto");
const viewer = await prisma.viewerUser.findUnique({ where: { id } });
if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404);
const token = generateSecureToken(24);
const created = await prisma.addonToken.create({
data: {
userId: admin.id,
viewerUserId: id,
tokenHash: hashToken(token),
label: body.label?.trim() || viewer.name || viewer.email,
},
});
return {
token,
id: created.id,
label: created.label,
installUrl: `/stremio/${token}/manifest.json`,
};
});
app.post( app.post(
"/api/v1/admin/viewers/devices/:deviceId/revoke", "/api/v1/admin/viewers/devices/:deviceId/revoke",
{ preHandler: requireAdmin }, { preHandler: requireAdmin },

View file

@ -117,6 +117,29 @@ export class ViewerService {
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { include: {
_count: { select: { devices: true } }, _count: { select: { devices: true } },
iptvLine: {
select: {
id: true,
label: true,
baseUrl: true,
username: true,
enableLive: true,
enableVod: true,
enableSeries: true,
lastOkAt: true,
lastError: true,
},
},
addonTokens: {
orderBy: { createdAt: "desc" },
select: {
id: true,
label: true,
revoked: true,
createdAt: true,
iptvLine: { select: { id: true } },
},
},
devices: { devices: {
where: { revoked: false }, where: { revoked: false },
select: { id: true, name: true, platform: true, lastSeenAt: true, createdAt: true }, select: { id: true, name: true, platform: true, lastSeenAt: true, createdAt: true },
@ -132,6 +155,28 @@ export class ViewerService {
appAccess: v.appAccess, appAccess: v.appAccess,
iptvAccess: v.iptvAccess, iptvAccess: v.iptvAccess,
iptvAddonTokenId: v.iptvAddonTokenId, iptvAddonTokenId: v.iptvAddonTokenId,
iptv: v.iptvLine
? {
id: v.iptvLine.id,
label: v.iptvLine.label,
baseUrl: v.iptvLine.baseUrl,
username: v.iptvLine.username,
hasPassword: true,
enableLive: v.iptvLine.enableLive,
enableVod: v.iptvLine.enableVod,
enableSeries: v.iptvLine.enableSeries,
lastOkAt: v.iptvLine.lastOkAt,
lastError: v.iptvLine.lastError,
ok: !v.iptvLine.lastError && !!v.iptvLine.lastOkAt,
}
: null,
addonTokens: v.addonTokens.map((t) => ({
id: t.id,
label: t.label,
revoked: t.revoked,
createdAt: t.createdAt,
hasOwnIptv: !!t.iptvLine,
})),
googlePlayEmail: v.googlePlayEmail, googlePlayEmail: v.googlePlayEmail,
googlePlayAccess: v.googlePlayAccess, googlePlayAccess: v.googlePlayAccess,
googlePlayMembershipId: v.googlePlayMembershipId, googlePlayMembershipId: v.googlePlayMembershipId,

View file

@ -20,6 +20,8 @@ ADMIN_PASSWORD=CHANGE_ME_STRONG_ADMIN_PASSWORD
# Publieke Master URL (Dokploy domain of NPM) # Publieke Master URL (Dokploy domain of NPM)
PUBLIC_URL=https://master.jouwdomein.nl PUBLIC_URL=https://master.jouwdomein.nl
# Koppelpagina voor apps (admin-UI host met /link)
CLIENT_LINK_BASE_URL=https://admin.jouwdomein.nl
# Optioneel – betere metadata matching # Optioneel – betere metadata matching
TMDB_API_KEY= TMDB_API_KEY=