+
{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);