diff --git a/apps/admin-ui/src/app/scripts/page.tsx b/apps/admin-ui/src/app/scripts/page.tsx index c13e955..fcb1d66 100644 --- a/apps/admin-ui/src/app/scripts/page.tsx +++ b/apps/admin-ui/src/app/scripts/page.tsx @@ -21,6 +21,9 @@ type OdidoAccount = { hasPin: boolean; enabled: boolean; channelCount: number; + healthStatus: "ok" | "dead" | "unknown"; + healthCheckedAt: string | null; + healthError: string | null; }; type OdidoSettings = { @@ -183,6 +186,71 @@ export default function ScriptsPage() { } } + async function probeAccounts(accountId?: string) { + setBusy(true); + setErr(null); + setMsg(null); + try { + const url = accountId + ? `/api/v1/admin/scripts/odido/accounts/${accountId}/probe` + : "/api/v1/admin/scripts/odido/accounts/probe"; + const r = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: accountId ? undefined : JSON.stringify({}), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error?.message ?? "Statuscheck mislukt"); + applySettings(d); + const list: OdidoAccount[] = d.accounts ?? []; + const ok = list.filter((a) => a.healthStatus === "ok").length; + const dead = list.filter((a) => a.healthStatus === "dead").length; + setMsg(`Accountstatus: ${ok} actief · ${dead} dood`); + } catch (e) { + setErr(String((e as Error).message ?? e)); + } finally { + setBusy(false); + } + } + + function healthBadge(a: OdidoAccount) { + if (!a.enabled) { + return ( + + uit + + ); + } + if (a.healthStatus === "ok") { + return ( + + actief + + ); + } + if (a.healthStatus === "dead") { + return ( + + dood + + ); + } + return ( + + onbekend + + ); + } + async function uploadWvd(file: File | null) { if (!file) return; setBusy(true); @@ -298,7 +366,12 @@ export default function ScriptsPage() { load-balanced; unieke zenders komen van het account dat ze wél heeft.

-

Accounts

+

+ Accounts + +

{accounts.length === 0 ? (

Nog geen accounts — voeg hieronder je eerste klantnummer toe.

) : ( @@ -316,17 +389,20 @@ export default function ScriptsPage() { borderBottom: "1px solid var(--border, #333)", }} > -
+
{a.label || a.username} - + {healthBadge(a)} + {a.username} {a.id !== "legacy" ? ` · ${a.channelCount} zenders` : ""} - {!a.enabled ? " · uit" : ""}
-
+
{a.id !== "legacy" && ( <> + diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index 94f0038..992703a 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -1692,6 +1692,26 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) } ); + app.post( + "/api/v1/admin/scripts/odido/accounts/probe", + { preHandler: requireAdmin }, + async (request) => { + const body = (request.body ?? {}) as { accountId?: string }; + const { probeOdidoAccounts } = await import("../odido/settings"); + return probeOdidoAccounts(config.SESSION_SECRET, body.accountId?.trim() || undefined); + } + ); + + app.post( + "/api/v1/admin/scripts/odido/accounts/:accountId/probe", + { preHandler: requireAdmin }, + async (request) => { + const { accountId } = request.params as { accountId: string }; + const { probeOdidoAccounts } = await import("../odido/settings"); + return probeOdidoAccounts(config.SESSION_SECRET, accountId); + } + ); + // --- Custom ClearKey/DASH live channel lists --- app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => { const { listLiveChannelListsAdmin } = await import("../viewer/live-channels"); diff --git a/apps/master-api/src/odido/accounts.ts b/apps/master-api/src/odido/accounts.ts index 30b9de5..f76ca6a 100644 --- a/apps/master-api/src/odido/accounts.ts +++ b/apps/master-api/src/odido/accounts.ts @@ -1,12 +1,17 @@ import { randomUUID } from "node:crypto"; import { decryptSecret, encryptSecret } from "../security/crypto"; +export type OdidoAccountHealth = "ok" | "dead" | "unknown"; + export type OdidoAccountStored = { id: string; label: string; username: string; pinEnc: string; enabled: boolean; + healthStatus?: OdidoAccountHealth; + healthCheckedAt?: string; + healthError?: string; }; export type OdidoAccountPublic = { @@ -17,6 +22,9 @@ export type OdidoAccountPublic = { enabled: boolean; /** Aantal zenders waar dit account toegang toe heeft (na laatste sync). */ channelCount: number; + healthStatus: OdidoAccountHealth; + healthCheckedAt: string | null; + healthError: string | null; }; export type OdidoAccountCreds = { @@ -47,6 +55,9 @@ export function accountToPublic( hasPin: !!account.pinEnc, enabled: account.enabled !== false, channelCount, + healthStatus: account.healthStatus ?? "unknown", + healthCheckedAt: account.healthCheckedAt ?? null, + healthError: account.healthError ?? null, }; } @@ -96,6 +107,13 @@ export function parseAccountsJson(raw: unknown): OdidoAccountStored[] { username, pinEnc, enabled: row.enabled !== false, + healthStatus: + row.healthStatus === "ok" || row.healthStatus === "dead" || row.healthStatus === "unknown" + ? row.healthStatus + : undefined, + healthCheckedAt: + typeof row.healthCheckedAt === "string" ? row.healthCheckedAt : undefined, + healthError: typeof row.healthError === "string" ? row.healthError : undefined, }); } return out; diff --git a/apps/master-api/src/odido/client.ts b/apps/master-api/src/odido/client.ts index 62388ed..4db84a0 100644 --- a/apps/master-api/src/odido/client.ts +++ b/apps/master-api/src/odido/client.ts @@ -146,10 +146,13 @@ async function loginWithDevice( export async function login( username: string, password: string, - sessionCacheMs = DEFAULT_SESSION_CACHE_MS + sessionCacheMs = DEFAULT_SESSION_CACHE_MS, + opts?: { force?: boolean } ): Promise { - const cached = sessions.get(username); - if (cached && cached.expiresAt > Date.now()) return cached.cookies; + if (!opts?.force) { + const cached = sessions.get(username); + if (cached && cached.expiresAt > Date.now()) return cached.cookies; + } const cookies = await loginWithDevice(username, password, DEFAULT_DEVICE_ID); const ttl = Number.isFinite(sessionCacheMs) && sessionCacheMs > 0 ? sessionCacheMs @@ -158,6 +161,11 @@ export async function login( return cookies; } +export function clearOdidoLoginCache(username?: string) { + if (username) sessions.delete(username); + else sessions.clear(); +} + export async function heartbeat(cookies: OdidoCookies): Promise { const { body } = await post("/VSP/V3/OnLineHeartbeat", "inMSAAccess", {}, cookies); return isSuccess(body) && body.userValid !== false && body.userValid !== "0"; diff --git a/apps/master-api/src/odido/settings.ts b/apps/master-api/src/odido/settings.ts index b506fb3..7194bb6 100644 --- a/apps/master-api/src/odido/settings.ts +++ b/apps/master-api/src/odido/settings.ts @@ -132,6 +132,9 @@ export async function getOdidoSettingsPublic() { hasPin: !!row.passwordEnc, enabled: true, channelCount: 0, + healthStatus: "unknown" as const, + healthCheckedAt: null, + healthError: null, }, ] : []; @@ -290,6 +293,53 @@ export async function removeOdidoAccount(sessionSecret: string, accountId: strin return getOdidoSettingsPublic(); } +/** Login + heartbeat per account → healthStatus ok/dead opslaan. */ +export async function probeOdidoAccounts( + sessionSecret: string, + accountId?: string +) { + const { login, heartbeat } = await import("./client"); + const existing = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); + const config = await ensureAccountsMigrated(sessionSecret, existing); + const targets = accountId + ? config.accounts.filter((a) => a.id === accountId) + : config.accounts; + if (accountId && !targets.length) { + throw new AppError("NOT_FOUND", "Account niet gevonden", 404); + } + + const now = new Date().toISOString(); + for (const account of targets) { + if (account.enabled === false) { + account.healthStatus = "unknown"; + account.healthCheckedAt = now; + account.healthError = "Uitgeschakeld"; + continue; + } + try { + const creds = decryptAccount(account, sessionSecret); + const cookies = await login(creds.username, creds.pin, 60_000, { force: true }); + const alive = await heartbeat(cookies); + if (!alive) { + account.healthStatus = "dead"; + account.healthCheckedAt = now; + account.healthError = "Sessie ongeldig (heartbeat)"; + } else { + account.healthStatus = "ok"; + account.healthCheckedAt = now; + account.healthError = undefined; + } + } catch (err) { + account.healthStatus = "dead"; + account.healthCheckedAt = now; + account.healthError = err instanceof Error ? err.message : String(err); + } + } + + await persistAccounts(config, existing?.enabled ?? true); + return getOdidoSettingsPublic(); +} + export async function saveOdidoWvdUpload(contentBase64: string) { const cleaned = contentBase64.replace(/^data:.*?;base64,/, "").trim(); if (!cleaned) throw new AppError("INVALID_REQUEST", "Lege WVD-upload", 400);