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:
parent
bc63ec11bb
commit
a431bebf3b
13 changed files with 1055 additions and 683 deletions
|
|
@ -1,575 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Nav, useAuth } from "@/components/Nav";
|
||||
|
||||
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 ?? []));
|
||||
}, []);
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/** Accounts is samengevoegd met Gebruikers (/viewers). */
|
||||
export default function AccountsRedirectPage() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
loadTokens();
|
||||
fetch("/api/v1/auth/me", { credentials: "include" })
|
||||
.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);
|
||||
|
||||
router.replace("/viewers");
|
||||
}, [router]);
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-kicker">Access</div>
|
||||
<h1>Accounts</h1>
|
||||
<div className="container" style={{ paddingTop: "3rem" }}>
|
||||
<p className="muted">Doorsturen naar Gebruikers…</p>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,16 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||
import Link from "next/link";
|
||||
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 {
|
||||
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 "shelves";
|
||||
|
|
@ -47,6 +53,7 @@ interface DsConfig {
|
|||
}
|
||||
|
||||
const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [
|
||||
{ id: "admin", label: "Admin", short: "Admin" },
|
||||
{ id: "shelves", label: "Planken", short: "Planken" },
|
||||
{ id: "download-station", label: "Download Station", short: "DS" },
|
||||
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
|
||||
|
|
@ -103,6 +110,14 @@ export default function SettingsPage() {
|
|||
const [osErr, setOsErr] = 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 [gpSaEmail, setGpSaEmail] = useState("");
|
||||
const [gpPrivateKey, setGpPrivateKey] = useState("");
|
||||
|
|
@ -187,6 +202,10 @@ export default function SettingsPage() {
|
|||
loadDs();
|
||||
loadOpenSubtitles();
|
||||
loadGooglePlay();
|
||||
fetch("/api/v1/auth/me", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setAdminEmail(d.user?.email ?? ""))
|
||||
.catch(() => undefined);
|
||||
}, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -200,6 +219,43 @@ export default function SettingsPage() {
|
|||
|
||||
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() {
|
||||
if (!newShelfName.trim()) return;
|
||||
const res = await fetch("/api/v1/admin/library/shelves", {
|
||||
|
|
@ -375,6 +431,53 @@ export default function SettingsPage() {
|
|||
))}
|
||||
</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" && (
|
||||
<section className="stack-section">
|
||||
<div className="section-head">
|
||||
|
|
|
|||
|
|
@ -20,6 +20,28 @@ type GooglePlaySyncStatus =
|
|||
| "REMOVED"
|
||||
| "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 = {
|
||||
id: string;
|
||||
email: string;
|
||||
|
|
@ -38,6 +60,18 @@ type Viewer = {
|
|||
createdAt: string;
|
||||
deviceCount: number;
|
||||
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 = {
|
||||
|
|
@ -47,6 +81,8 @@ type PlayConfig = {
|
|||
optInUrl: string | null;
|
||||
};
|
||||
|
||||
const MASTER_BASE = "https://master.vonas.nl";
|
||||
|
||||
const SYNC_LABELS: Record<GooglePlaySyncStatus, string> = {
|
||||
NOT_ENABLED: "Uit",
|
||||
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() {
|
||||
useAuth();
|
||||
const [viewers, setViewers] = useState<Viewer[]>([]);
|
||||
|
|
@ -82,6 +149,10 @@ export default function ViewersPage() {
|
|||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
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(() => {
|
||||
fetch("/api/v1/admin/viewers", { credentials: "include" })
|
||||
|
|
@ -97,7 +168,7 @@ export default function ViewersPage() {
|
|||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => setErr("Kon kijkers niet laden"));
|
||||
.catch(() => setErr("Kon gebruikers niet laden"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -116,6 +187,25 @@ export default function ViewersPage() {
|
|||
return { total: viewers.length, active, appOn, playOn, playErr };
|
||||
}, [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) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
|
|
@ -139,6 +229,7 @@ export default function ViewersPage() {
|
|||
setName("");
|
||||
setShowCreate(false);
|
||||
setExpandedId(data.viewer.id);
|
||||
setIptvForms((prev) => ({ ...prev, [data.viewer.id]: emptyIptvForm() }));
|
||||
load();
|
||||
} finally {
|
||||
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) {
|
||||
setActionId(v.id);
|
||||
setMsg(null);
|
||||
|
|
@ -289,13 +511,14 @@ export default function ViewersPage() {
|
|||
<header className="page-header">
|
||||
<div>
|
||||
<p className="page-kicker">Mensen</p>
|
||||
<h1>Kijkers</h1>
|
||||
<h1>Gebruikers</h1>
|
||||
<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>
|
||||
</div>
|
||||
<button type="button" className="btn-primary" onClick={() => setShowCreate((v) => !v)}>
|
||||
{showCreate ? "Sluiten" : "Nieuwe kijker"}
|
||||
{showCreate ? "Sluiten" : "Nieuwe gebruiker"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
|
|
@ -339,7 +562,7 @@ export default function ViewersPage() {
|
|||
|
||||
{showCreate && (
|
||||
<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}>
|
||||
<label>
|
||||
Naam
|
||||
|
|
@ -380,7 +603,7 @@ export default function ViewersPage() {
|
|||
<div>
|
||||
<h2 className="card-subtitle">Overzicht</h2>
|
||||
<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>
|
||||
</div>
|
||||
{playConfig?.configured ? (
|
||||
|
|
@ -400,7 +623,7 @@ export default function ViewersPage() {
|
|||
</div>
|
||||
|
||||
{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">
|
||||
{viewers.map((v) => {
|
||||
|
|
@ -413,12 +636,17 @@ export default function ViewersPage() {
|
|||
: v.googlePlayAccess
|
||||
? "warn"
|
||||
: "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 (
|
||||
<li key={v.id} className={`viewer-item${open ? " open" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="viewer-summary"
|
||||
onClick={() => setExpandedId(open ? null : v.id)}
|
||||
onClick={() => openViewer(v)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="viewer-who">
|
||||
|
|
@ -432,6 +660,7 @@ export default function ViewersPage() {
|
|||
<span className={`pill ${v.appAccess ? "ok" : "off"}`}>
|
||||
{v.appAccess ? "App aan" : "App uit"}
|
||||
</span>
|
||||
<span className={`pill ${badge.tone}`}>{badge.label}</span>
|
||||
<span className={`pill ${playTone}`}>
|
||||
Play {SYNC_LABELS[v.googlePlaySyncStatus]}
|
||||
</span>
|
||||
|
|
@ -486,7 +715,9 @@ export default function ViewersPage() {
|
|||
<div className="toggle-row">
|
||||
<div>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -501,6 +732,172 @@ export default function ViewersPage() {
|
|||
</div>
|
||||
</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">
|
||||
<h3>Google Play</h3>
|
||||
{!playConfig?.configured ? (
|
||||
|
|
@ -879,6 +1276,10 @@ export default function ViewersPage() {
|
|||
transform: translateX(18px);
|
||||
background: var(--ok);
|
||||
}
|
||||
.form-group {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
|
|
@ -892,6 +1293,19 @@ export default function ViewersPage() {
|
|||
font-size: 0.75rem;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -914,6 +1328,43 @@ export default function ViewersPage() {
|
|||
.optin-link {
|
||||
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 {
|
||||
border-top: 1px solid var(--border-dim);
|
||||
padding-top: 0.85rem;
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ const desktopLinks = [
|
|||
{ href: "/series", label: "Series" },
|
||||
{ href: "/downloads", label: "Downloads" },
|
||||
{ href: "/streams", label: "Streams" },
|
||||
{ href: "/accounts", label: "Accounts" },
|
||||
{ href: "/viewers", label: "Kijkers" },
|
||||
{ href: "/viewers", label: "Gebruikers" },
|
||||
{ href: "/settings", label: "Instellingen" },
|
||||
];
|
||||
|
||||
|
|
@ -24,7 +23,7 @@ const mobileLinks = [
|
|||
{ href: "/movies", label: "Movies", short: "Films" },
|
||||
{ href: "/downloads", label: "Downloads", short: "DL" },
|
||||
{ href: "/streams", label: "Streams", short: "Live" },
|
||||
{ href: "/accounts", label: "Accounts", short: "Acc." },
|
||||
{ href: "/viewers", label: "Users", short: "Users" },
|
||||
];
|
||||
|
||||
export function Nav() {
|
||||
|
|
|
|||
|
|
@ -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 $$;
|
||||
|
|
@ -66,7 +66,7 @@ model User {
|
|||
@@map("users")
|
||||
}
|
||||
|
||||
/** App-kijker (Android TV / phone) — apart van admin User. */
|
||||
/** App-gebruiker (Android/iOS/Tizen) — huishouden met optioneel IPTV + Stremio-addons. */
|
||||
model ViewerUser {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
|
|
@ -74,8 +74,9 @@ model ViewerUser {
|
|||
name String?
|
||||
enabled Boolean @default(true)
|
||||
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")
|
||||
/** @deprecated Legacy: IPTV via addon-token; voorkeur = iptvLine op deze user. */
|
||||
iptvAddonTokenId String? @map("iptv_addon_token_id")
|
||||
/** Google-account voor Play Closed Testing; fallback = email */
|
||||
googlePlayEmail String? @map("google_play_email")
|
||||
|
|
@ -92,7 +93,10 @@ model ViewerUser {
|
|||
profiles ViewerProfile[]
|
||||
linkCodes DeviceLinkCode[]
|
||||
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")
|
||||
}
|
||||
|
|
@ -238,23 +242,30 @@ model AdminSession {
|
|||
model AddonToken {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
/** Gebruiker (huishouden) waaraan deze Stremio-addon hangt. */
|
||||
viewerUserId String? @map("viewer_user_id")
|
||||
tokenHash String @unique @map("token_hash")
|
||||
label String?
|
||||
revoked Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
viewerUser ViewerUser? @relation("ViewerAddonTokens", fields: [viewerUserId], references: [id], onDelete: SetNull)
|
||||
iptvLine IptvLine?
|
||||
playbackSessions PlaybackSession[]
|
||||
iptvViewers ViewerUser[]
|
||||
legacyIptvViewers ViewerUser[] @relation("ViewerLegacyIptvToken")
|
||||
|
||||
@@index([viewerUserId])
|
||||
@@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 {
|
||||
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?
|
||||
baseUrl String @map("base_url")
|
||||
username String
|
||||
|
|
@ -268,7 +279,8 @@ model IptvLine {
|
|||
createdAt DateTime @default(now()) @map("created_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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -369,12 +369,18 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
|
||||
app.post("/api/v1/admin/addon-tokens", { preHandler: requireAdmin }, async (request) => {
|
||||
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);
|
||||
|
||||
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({
|
||||
data: {
|
||||
userId: user.id,
|
||||
viewerUserId: body.viewerUserId?.trim() || null,
|
||||
tokenHash: hashToken(token),
|
||||
label: body.label,
|
||||
},
|
||||
|
|
@ -384,6 +390,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
token,
|
||||
id: created.id,
|
||||
label: created.label,
|
||||
viewerUserId: created.viewerUserId,
|
||||
installUrl: `/stremio/${token}/manifest.json`,
|
||||
};
|
||||
});
|
||||
|
|
@ -393,7 +400,10 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
const tokens = await prisma.addonToken.findMany({
|
||||
where: { userId: user.id },
|
||||
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 {
|
||||
tokens: tokens.map((t) => ({
|
||||
|
|
@ -401,6 +411,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
label: t.label,
|
||||
revoked: t.revoked,
|
||||
createdAt: t.createdAt,
|
||||
viewerUserId: t.viewerUserId,
|
||||
viewerLabel: t.viewerUser?.name || t.viewerUser?.email || null,
|
||||
hasIptv: !!t.iptvLine,
|
||||
iptvOk: t.iptvLine ? !t.iptvLine.lastError && !!t.iptvLine.lastOkAt : false,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -106,12 +106,13 @@ async function main() {
|
|||
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 {
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "iptv_lines" (
|
||||
"id" TEXT NOT NULL,
|
||||
"addon_token_id" TEXT NOT NULL,
|
||||
"addon_token_id" TEXT,
|
||||
"viewer_user_id" TEXT,
|
||||
"label" TEXT,
|
||||
"base_url" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
|
|
@ -126,9 +127,41 @@ async function main() {
|
|||
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(
|
||||
`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) {
|
||||
console.warn("Could not ensure iptv_lines table:", err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,12 +57,25 @@ export function parseIptvId(id: string): {
|
|||
return null;
|
||||
}
|
||||
|
||||
export async function loadXtreamForToken(
|
||||
tokenId: string,
|
||||
type LoadedXtream = {
|
||||
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
|
||||
): Promise<{ client: XtreamClient; line: { enableLive: boolean; enableVod: boolean; enableSeries: boolean } } | null> {
|
||||
const line = await prisma.iptvLine.findUnique({ where: { addonTokenId: tokenId } });
|
||||
if (!line) return null;
|
||||
): LoadedXtream | null {
|
||||
try {
|
||||
const client = XtreamClient.fromEncrypted({
|
||||
baseUrl: line.baseUrl,
|
||||
|
|
@ -73,16 +86,80 @@ export async function loadXtreamForToken(
|
|||
return {
|
||||
client,
|
||||
line: {
|
||||
id: line.id,
|
||||
enableLive: line.enableLive,
|
||||
enableVod: line.enableVod,
|
||||
enableSeries: line.enableSeries,
|
||||
},
|
||||
cacheKey: line.id,
|
||||
};
|
||||
} catch {
|
||||
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) {
|
||||
const xt = await loadXtreamForToken(tokenId, config);
|
||||
if (!xt) return { catalogs: [] as unknown[], types: [] as string[], genreLive: [] as string[] };
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { prisma } from "../database/client";
|
||||
import type { Config } from "../config";
|
||||
import { AppError } from "../security/errors";
|
||||
import { loadXtreamForToken } from "../iptv/service";
|
||||
import { loadXtreamByLineId, loadXtreamForViewer } from "../iptv/service";
|
||||
import {
|
||||
decodeEpgText,
|
||||
epgToIso,
|
||||
|
|
@ -29,7 +29,7 @@ function normCatLabel(s: string): string {
|
|||
|
||||
/** Maak Xtream-categorienamen leesbaarder voor TV-gebruikers. */
|
||||
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);
|
||||
if (m) {
|
||||
const rest = m[2].trim();
|
||||
|
|
@ -42,53 +42,8 @@ export function friendlyCategoryName(raw: string): string {
|
|||
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) {
|
||||
const tokenId = await resolveViewerIptvToken(viewerId);
|
||||
if (!tokenId) return { enabled: false as const };
|
||||
|
||||
const xt = await loadXtreamForToken(tokenId, config);
|
||||
const xt = await loadXtreamForViewer(viewerId, config);
|
||||
if (!xt?.line.enableLive) return { enabled: false as const };
|
||||
|
||||
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) {
|
||||
const tokenId = await resolveViewerIptvToken(viewerId);
|
||||
if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
||||
|
||||
const xt = await loadXtreamForToken(tokenId, config);
|
||||
const xt = await loadXtreamForViewer(viewerId, config);
|
||||
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
||||
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
|
||||
|
||||
// Categorieën + (korte) zenderlijst parallel — categorieën nooit laten falen op trage streams
|
||||
|
|
@ -166,7 +119,7 @@ export async function getViewerIptvCategories(viewerId: string, config: Config)
|
|||
* Match op ruwe naam (zonder landcode te strippen).
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -191,10 +144,8 @@ export async function getViewerIptvChannels(
|
|||
categoryId?: string,
|
||||
includeEpg = false
|
||||
) {
|
||||
const tokenId = await resolveViewerIptvToken(viewerId);
|
||||
if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
||||
|
||||
const xt = await loadXtreamForToken(tokenId, config);
|
||||
const xt = await loadXtreamForViewer(viewerId, config);
|
||||
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
||||
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
|
||||
|
||||
let streams =
|
||||
|
|
@ -205,10 +156,10 @@ export async function getViewerIptvChannels(
|
|||
streams = sortChannels(streams);
|
||||
|
||||
const epgTitles = includeEpg
|
||||
? await resolveCategoryEpgTitles(tokenId, categoryId, streams, xt.client, {
|
||||
? await resolveCategoryEpgTitles(xt.cacheKey, categoryId, streams, xt.client, {
|
||||
waitForXmltv: true,
|
||||
})
|
||||
: peekCategoryEpgTitles(tokenId, categoryId);
|
||||
: peekCategoryEpgTitles(xt.cacheKey, categoryId);
|
||||
|
||||
return {
|
||||
channels: streams.map((s) => {
|
||||
|
|
@ -231,10 +182,8 @@ export async function getViewerIptvPlayUrl(
|
|||
config: Config,
|
||||
streamId: number
|
||||
) {
|
||||
const tokenId = await resolveViewerIptvToken(viewerId);
|
||||
if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
||||
|
||||
const xt = await loadXtreamForToken(tokenId, config);
|
||||
const xt = await loadXtreamForViewer(viewerId, config);
|
||||
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
||||
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
|
||||
|
||||
const streams = await xt.client.getLiveStreams();
|
||||
|
|
@ -271,17 +220,15 @@ export async function getViewerIptvPlayUrl(
|
|||
}
|
||||
|
||||
export async function getViewerIptvEpg(viewerId: string, config: Config, streamId: number) {
|
||||
const tokenId = await resolveViewerIptvToken(viewerId);
|
||||
if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
||||
|
||||
const xt = await loadXtreamForToken(tokenId, config);
|
||||
const xt = await loadXtreamForViewer(viewerId, config);
|
||||
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
||||
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
|
||||
|
||||
const streams = await xt.client.getLiveStreams();
|
||||
const ch = streams.find((s) => s.stream_id === streamId);
|
||||
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)) {
|
||||
return {
|
||||
streamId,
|
||||
|
|
@ -299,7 +246,7 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
|
|||
const xmlMap = await xt.client.getXmltvNowNextByChannelId();
|
||||
const hit = xmlMap.get(epgId) ?? xmlMap.get(epgId.toLowerCase());
|
||||
if (hit && (hit.now || hit.next)) {
|
||||
setCachedStreamEpg(tokenId, streamId, hit.now, hit.next);
|
||||
setCachedStreamEpg(xt.cacheKey, streamId, hit.now, hit.next);
|
||||
return {
|
||||
streamId,
|
||||
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 { now: nowProgram, next: nextProgram } = parseNowNextPrograms(epg);
|
||||
setCachedStreamEpg(tokenId, streamId, nowProgram, nextProgram);
|
||||
setCachedStreamEpg(xt.cacheKey, streamId, nowProgram, nextProgram);
|
||||
|
||||
return {
|
||||
streamId,
|
||||
|
|
@ -667,17 +614,25 @@ export function startIptvEpgWarmup(config: Config) {
|
|||
const tick = async () => {
|
||||
try {
|
||||
const lines = await prisma.iptvLine.findMany({
|
||||
where: { enableLive: true, lastError: null, addonToken: { revoked: false } },
|
||||
select: { addonTokenId: true },
|
||||
take: 4,
|
||||
where: {
|
||||
enableLive: true,
|
||||
lastError: null,
|
||||
OR: [
|
||||
{ viewerUserId: { not: null } },
|
||||
{ addonToken: { is: { revoked: false } } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
take: 8,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
|
||||
for (const line of lines) {
|
||||
if (xmltvLineRefreshing.has(line.addonTokenId)) continue;
|
||||
xmltvLineRefreshing.add(line.addonTokenId);
|
||||
if (xmltvLineRefreshing.has(line.id)) continue;
|
||||
xmltvLineRefreshing.add(line.id);
|
||||
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;
|
||||
|
||||
// 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(() => []);
|
||||
streams = sortChannels(streams);
|
||||
if (streams.length === 0) continue;
|
||||
await resolveCategoryEpgTitles(line.addonTokenId, catId, streams, xt.client, {
|
||||
await resolveCategoryEpgTitles(cacheKey, catId, streams, xt.client, {
|
||||
waitForXmltv: true,
|
||||
});
|
||||
}
|
||||
|
||||
const all = sortChannels(await xt.client.getLiveStreams().catch(() => []));
|
||||
if (all.length > 0) {
|
||||
await resolveCategoryEpgTitles(line.addonTokenId, "__all__", all, xt.client, {
|
||||
await resolveCategoryEpgTitles(cacheKey, "__all__", all, xt.client, {
|
||||
waitForXmltv: true,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
xmltvLineRefreshing.delete(line.addonTokenId);
|
||||
xmltvLineRefreshing.delete(line.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,206 @@ export function registerViewerRoutes(
|
|||
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(
|
||||
"/api/v1/admin/viewers/devices/:deviceId/revoke",
|
||||
{ preHandler: requireAdmin },
|
||||
|
|
|
|||
|
|
@ -117,6 +117,29 @@ export class ViewerService {
|
|||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
_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: {
|
||||
where: { revoked: false },
|
||||
select: { id: true, name: true, platform: true, lastSeenAt: true, createdAt: true },
|
||||
|
|
@ -132,6 +155,28 @@ export class ViewerService {
|
|||
appAccess: v.appAccess,
|
||||
iptvAccess: v.iptvAccess,
|
||||
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,
|
||||
googlePlayAccess: v.googlePlayAccess,
|
||||
googlePlayMembershipId: v.googlePlayMembershipId,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ ADMIN_PASSWORD=CHANGE_ME_STRONG_ADMIN_PASSWORD
|
|||
|
||||
# Publieke Master URL (Dokploy domain of NPM)
|
||||
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
|
||||
TMDB_API_KEY=
|
||||
|
|
|
|||
Loading…
Reference in a new issue