From a431bebf3b6b36b0db4da6557d4a76206290fe86 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 8 Sep 2026 00:43:55 +0200 Subject: [PATCH] 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 --- apps/admin-ui/src/app/accounts/page.tsx | 578 +----------------- apps/admin-ui/src/app/settings/page.tsx | 107 +++- apps/admin-ui/src/app/viewers/page.tsx | 469 +++++++++++++- apps/admin-ui/src/components/Nav.tsx | 5 +- .../migration.sql | 41 ++ apps/master-api/prisma/schema.prisma | 38 +- apps/master-api/src/admin/routes.ts | 16 +- apps/master-api/src/app.ts | 37 +- apps/master-api/src/iptv/service.ts | 87 ++- apps/master-api/src/viewer/iptv-client.ts | 113 ++-- apps/master-api/src/viewer/routes.ts | 200 ++++++ apps/master-api/src/viewer/service.ts | 45 ++ deploy/dokploy/.env.example | 2 + 13 files changed, 1055 insertions(+), 683 deletions(-) create mode 100644 apps/master-api/prisma/migrations/20260908010000_viewer_owned_iptv/migration.sql diff --git a/apps/admin-ui/src/app/accounts/page.tsx b/apps/admin-ui/src/app/accounts/page.tsx index ce3a866..34eeeb3 100644 --- a/apps/admin-ui/src/app/accounts/page.tsx +++ b/apps/admin-ui/src/app/accounts/page.tsx @@ -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([]); - const [addonToken, setAddonToken] = useState(""); - const [installUrl, setInstallUrl] = useState(""); - const [label, setLabel] = useState("Stremio"); - const [iptvTokenId, setIptvTokenId] = useState(null); - const [iptv, setIptv] = useState(emptyIptv()); - const [iptvBusy, setIptvBusy] = useState(false); - const [iptvMsg, setIptvMsg] = useState(null); - const [iptvErr, setIptvErr] = useState(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(null); - const [pwErr, setPwErr] = useState(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 ( - <> - + {tab === "admin" && ( +
+
+

Admin-login

+

Ingelogd als {adminEmail || "…"}

+
+
+ {pwErr &&
{pwErr}
} + {pwMsg && ( +

+ {pwMsg} +

+ )} +
+ + setCurrentPassword(e.target.value)} + autoComplete="current-password" + /> +
+
+ + setNewPassword(e.target.value)} + autoComplete="new-password" + /> +
+
+ + setConfirmPassword(e.target.value)} + autoComplete="new-password" + /> +
+ +
+
+ )} + {tab === "shelves" && (
diff --git a/apps/admin-ui/src/app/viewers/page.tsx b/apps/admin-ui/src/app/viewers/page.tsx index e7b184f..4ce7eb2 100644 --- a/apps/admin-ui/src/app/viewers/page.tsx +++ b/apps/admin-ui/src/app/viewers/page.tsx @@ -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 = { 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([]); @@ -82,6 +149,10 @@ export default function ViewersPage() { const [expandedId, setExpandedId] = useState(null); const [showCreate, setShowCreate] = useState(false); const [actionId, setActionId] = useState(null); + const [iptvForms, setIptvForms] = useState>({}); + const [iptvLocalMsg, setIptvLocalMsg] = useState>({}); + const [iptvLocalErr, setIptvLocalErr] = useState>({}); + const [addonInstallUrls, setAddonInstallUrls] = useState>({}); 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) { + 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() {

Mensen

-

Kijkers

+

Gebruikers

- App-accounts voor Android TV. Koppelen via /link met een code. + Unified accounts voor app, IPTV en Stremio-addons. Koppelen via{" "} + /link met een code.

@@ -339,7 +562,7 @@ export default function ViewersPage() { {showCreate && (
-

Nieuwe kijker

+

Nieuwe gebruiker

{viewers.length === 0 ? ( -

Nog geen kijkers. Maak er een aan om te beginnen.

+

Nog geen gebruikers. Maak er een aan om te beginnen.

) : (
    {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 (
+
+

IPTV · Xtream / XUI

+

+ Eigen IPTV-lijn voor deze gebruiker (apps + gekoppelde Stremio-addons). + Geen gedeelde fallback meer. +

+
+ + + + +
+ + + +
+
+ {iptv?.lastError && ( +

Laatste fout: {iptv.lastError}

+ )} + {iptvLocalErr[v.id] && ( +

{iptvLocalErr[v.id]}

+ )} + {iptvLocalMsg[v.id] && ( +

{iptvLocalMsg[v.id]}

+ )} + {iptv?.lastOkAt && !iptv.lastError && ( +

+ Laatst OK: {formatWhen(iptv.lastOkAt)} +

+ )} +
+ + + +
+
+ +
+

Stremio-addon

+

+ Stremio gebruikt de IPTV-lijn van deze gebruiker. +

+ {tokens.length === 0 ? ( +

Nog geen addon-tokens.

+ ) : ( +
    + {tokens.map((t) => ( +
  • +
    + {t.label || "Addon"} + + {t.revoked ? "Ingetrokken" : "Actief"} + {t.hasOwnIptv ? " · eigen IPTV" : ""} + {t.createdAt + ? ` · aangemaakt ${formatWhen(t.createdAt)}` + : ""} + +
    +
  • + ))} +
+ )} +
+ +
+ {installUrl && ( +
+

+ Installatie-URL (eenmalig zichtbaar — kopieer nu): +

+ {installUrl} + +
+ )} +
+

Google Play

{!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; diff --git a/apps/admin-ui/src/components/Nav.tsx b/apps/admin-ui/src/components/Nav.tsx index e416a2a..964be6c 100644 --- a/apps/admin-ui/src/components/Nav.tsx +++ b/apps/admin-ui/src/components/Nav.tsx @@ -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() { diff --git a/apps/master-api/prisma/migrations/20260908010000_viewer_owned_iptv/migration.sql b/apps/master-api/prisma/migrations/20260908010000_viewer_owned_iptv/migration.sql new file mode 100644 index 0000000..9551ead --- /dev/null +++ b/apps/master-api/prisma/migrations/20260908010000_viewer_owned_iptv/migration.sql @@ -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 $$; diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index ddb8a2c..9f04ba5 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -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") } @@ -236,25 +240,32 @@ model AdminSession { } model AddonToken { - id String @id @default(uuid()) - userId String @map("user_id") - tokenHash String @unique @map("token_hash") - label String? - revoked Boolean @default(false) - createdAt DateTime @default(now()) @map("created_at") + 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") } diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index 4b0d026..e09847b 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -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, })), diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index d5806f1..cffce09 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -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); } diff --git a/apps/master-api/src/iptv/service.ts b/apps/master-api/src/iptv/service.ts index 885c15a..077bf7d 100644 --- a/apps/master-api/src/iptv/service.ts +++ b/apps/master-api/src/iptv/service.ts @@ -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 { + 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 { + 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 { + 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[] }; diff --git a/apps/master-api/src/viewer/iptv-client.ts b/apps/master-api/src/viewer/iptv-client.ts index eca6f2c..c5e51a0 100644 --- a/apps/master-api/src/viewer/iptv-client.ts +++ b/apps/master-api/src/viewer/iptv-client.ts @@ -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 { - 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) { diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index b317557..29ba8db 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -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 }, diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 707d5ae..3c917af 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -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, diff --git a/deploy/dokploy/.env.example b/deploy/dokploy/.env.example index c6a0833..9ce00fc 100644 --- a/deploy/dokploy/.env.example +++ b/deploy/dokploy/.env.example @@ -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=