diff --git a/apps/master-api/src/agent/routes.ts b/apps/master-api/src/agent/routes.ts index 7dba1bb..93a2760 100644 --- a/apps/master-api/src/agent/routes.ts +++ b/apps/master-api/src/agent/routes.ts @@ -5,6 +5,7 @@ import { resolveTokenTraySecret } from "../settings/token-tray"; import { decryptAccount as decryptViaplay } from "../viaplay/accounts"; import { decryptAccount as decryptF1tv } from "../f1tv/accounts"; import { prisma } from "../database/client"; +import { jwtExpiryIso, tokenNeedsRefresh } from "./token-expiry"; async function requireTrayKey(request: FastifyRequest, config: Config) { const secret = await resolveTokenTraySecret( @@ -32,70 +33,125 @@ export type AgentAccountRow = { password: string | null; hasTokens: boolean; enabled: boolean; + tokenExpiresAt: string | null; + refreshDue: boolean; }; +async function listAgentAccounts( + config: Config, + opts?: { onlyDue?: boolean; withinMinutes?: number } +): Promise { + const withinMs = Math.max(5, opts?.withinMinutes ?? 90) * 60_000; + const accounts: AgentAccountRow[] = []; + + const vpRow = await prisma.integrationSetting.findUnique({ where: { id: "viaplay" } }); + if (vpRow?.configJson) { + const { parseAccountsJson } = await import("../viaplay/accounts"); + try { + const parsed = JSON.parse(vpRow.configJson) as { accounts?: unknown }; + for (const a of parseAccountsJson(parsed.accounts)) { + if (a.enabled === false) continue; + const creds = decryptViaplay(a, config.SESSION_SECRET); + const exp = + a.tokenExpiresAt || + jwtExpiryIso(creds.accessToken) || + null; + const refreshDue = + !!creds.password && + tokenNeedsRefresh(exp, withinMs); + accounts.push({ + provider: "viaplay", + id: a.id, + email: a.email, + label: a.label || a.email, + password: creds.password, + hasTokens: !!(creds.session && creds.accessToken), + enabled: true, + tokenExpiresAt: exp, + refreshDue, + }); + } + } catch { + /* ignore */ + } + } + + const f1Row = await prisma.integrationSetting.findUnique({ where: { id: "f1tv" } }); + if (f1Row?.configJson) { + const { parseAccountsJson } = await import("../f1tv/accounts"); + try { + const parsed = JSON.parse(f1Row.configJson) as { accounts?: unknown }; + for (const a of parseAccountsJson(parsed.accounts)) { + if (a.enabled === false) continue; + const creds = decryptF1tv(a, config.SESSION_SECRET); + const exp = + a.tokenExpiresAt || + [jwtExpiryIso(creds.ascendontoken), jwtExpiryIso(creds.entitlementtoken)] + .filter(Boolean) + .sort()[0] || + null; + const refreshDue = + !!creds.password && + tokenNeedsRefresh(exp, withinMs); + accounts.push({ + provider: "f1tv", + id: a.id, + email: a.email, + label: a.label || a.email, + password: creds.password, + hasTokens: !!(creds.ascendontoken && creds.entitlementtoken), + enabled: true, + tokenExpiresAt: exp, + refreshDue, + }); + } + } catch { + /* ignore */ + } + } + + if (opts?.onlyDue) return accounts.filter((a) => a.refreshDue && a.password); + return accounts; +} + export function registerAgentRoutes(app: FastifyInstance, config: Config) { app.get("/api/v1/agent/status", async (request) => { await requireTrayKey(request, config); + const due = await listAgentAccounts(config, { onlyDue: true, withinMinutes: 90 }); return { ok: true, trayEnabled: true, providers: ["viaplay", "f1tv"], + refreshDueCount: due.length, }; }); - /** Accounts + wachtwoorden voor stille browser-login (alleen met tray-key). */ + /** Alle accounts (+ wachtwoorden) voor de tray. */ app.get("/api/v1/agent/accounts", async (request) => { await requireTrayKey(request, config); - const accounts: AgentAccountRow[] = []; + const q = request.query as { withinMinutes?: string }; + const withinMinutes = Number(q.withinMinutes) || 90; + const accounts = await listAgentAccounts(config, { withinMinutes }); + return { accounts, withinMinutes }; + }); - const vpRow = await prisma.integrationSetting.findUnique({ where: { id: "viaplay" } }); - if (vpRow?.configJson) { - const { parseAccountsJson } = await import("../viaplay/accounts"); - try { - const parsed = JSON.parse(vpRow.configJson) as { accounts?: unknown }; - for (const a of parseAccountsJson(parsed.accounts)) { - if (a.enabled === false) continue; - const creds = decryptViaplay(a, config.SESSION_SECRET); - accounts.push({ - provider: "viaplay", - id: a.id, - email: a.email, - label: a.label || a.email, - password: creds.password, - hasTokens: !!(creds.session && creds.accessToken), - enabled: true, - }); - } - } catch { - /* ignore corrupt */ - } - } - - const f1Row = await prisma.integrationSetting.findUnique({ where: { id: "f1tv" } }); - if (f1Row?.configJson) { - const { parseAccountsJson } = await import("../f1tv/accounts"); - try { - const parsed = JSON.parse(f1Row.configJson) as { accounts?: unknown }; - for (const a of parseAccountsJson(parsed.accounts)) { - if (a.enabled === false) continue; - const creds = decryptF1tv(a, config.SESSION_SECRET); - accounts.push({ - provider: "f1tv", - id: a.id, - email: a.email, - label: a.label || a.email, - password: creds.password, - hasTokens: !!(creds.ascendontoken && creds.entitlementtoken), - enabled: true, - }); - } - } catch { - /* ignore */ - } - } - - return { accounts }; + /** + * Alleen accounts die binnenkort verlopen / geen token hebben. + * Tray pollt dit en vernieuwt stil — zonder handmatige actie. + */ + app.get("/api/v1/agent/refresh-due", async (request) => { + await requireTrayKey(request, config); + const q = request.query as { withinMinutes?: string }; + const withinMinutes = Number(q.withinMinutes) || 90; + const accounts = await listAgentAccounts(config, { + onlyDue: true, + withinMinutes, + }); + return { + withinMinutes, + count: accounts.length, + accounts, + }; }); app.post("/api/v1/agent/viaplay/tokens", async (request) => { diff --git a/apps/master-api/src/agent/token-expiry.ts b/apps/master-api/src/agent/token-expiry.ts new file mode 100644 index 0000000..1ada6bc --- /dev/null +++ b/apps/master-api/src/agent/token-expiry.ts @@ -0,0 +1,26 @@ +/** Decode JWT exp → ISO string (geen signature-check; alleen voor expiry-planning). */ +export function jwtExpiryIso(token: string | null | undefined): string | null { + if (!token?.trim()) return null; + const raw = token.trim().replace(/^(VIAPLAY-AT|MTG-AT|Bearer)\s+/i, ""); + try { + const payload = JSON.parse( + Buffer.from(raw.split(".")[1]!.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString() + ) as { exp?: number }; + if (!payload.exp || !Number.isFinite(payload.exp)) return null; + return new Date(payload.exp * 1000).toISOString(); + } catch { + return null; + } +} + +/** True als token ontbreekt, onleesbaar is, of binnen `withinMs` verloopt. */ +export function tokenNeedsRefresh( + expiresAtIso: string | null | undefined, + withinMs: number, + nowMs = Date.now() +): boolean { + if (!expiresAtIso) return true; + const t = Date.parse(expiresAtIso); + if (!Number.isFinite(t)) return true; + return t - nowMs <= withinMs; +} diff --git a/apps/master-api/src/f1tv/accounts.ts b/apps/master-api/src/f1tv/accounts.ts index a59ed02..91cfd2d 100644 --- a/apps/master-api/src/f1tv/accounts.ts +++ b/apps/master-api/src/f1tv/accounts.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { decryptSecret, encryptSecret } from "../security/crypto"; +import { jwtExpiryIso } from "../agent/token-expiry"; export type F1tvAccountHealth = "ok" | "dead" | "unknown"; @@ -16,6 +17,8 @@ export type F1tvAccountStored = { healthStatus?: F1tvAccountHealth; healthCheckedAt?: string; healthError?: string; + /** ISO — earliest JWT exp van play-tokens. */ + tokenExpiresAt?: string; }; export type F1tvAccountPublic = { @@ -31,6 +34,7 @@ export type F1tvAccountPublic = { healthStatus: F1tvAccountHealth; healthCheckedAt: string | null; healthError: string | null; + tokenExpiresAt: string | null; }; export type F1tvAccountCreds = { @@ -70,6 +74,7 @@ export function accountToPublic( healthStatus: account.healthStatus ?? "unknown", healthCheckedAt: account.healthCheckedAt ?? null, healthError: account.healthError ?? null, + tokenExpiresAt: account.tokenExpiresAt ?? null, }; } @@ -116,6 +121,12 @@ export function createAccountStored( if (!password && !ascendontoken && !entitlementtoken) { throw new Error("Wachtwoord of tokens verplicht"); } + const expA = jwtExpiryIso(ascendontoken); + const expB = jwtExpiryIso(entitlementtoken); + const tokenExpiresAt = + [expA, expB] + .filter(Boolean) + .sort()[0] ?? undefined; return { id: input.id?.trim() || randomUUID(), label: (input.label ?? "").trim() || email, @@ -129,6 +140,7 @@ export function createAccountStored( : undefined, sessionId: input.sessionId?.trim() || undefined, enabled: input.enabled !== false, + tokenExpiresAt, }; } @@ -159,6 +171,8 @@ export function parseAccountsJson(raw: unknown): F1tvAccountStored[] { healthCheckedAt: typeof row.healthCheckedAt === "string" ? row.healthCheckedAt : undefined, healthError: typeof row.healthError === "string" ? row.healthError : undefined, + tokenExpiresAt: + typeof row.tokenExpiresAt === "string" ? row.tokenExpiresAt : undefined, }); } return out; diff --git a/apps/master-api/src/f1tv/settings.ts b/apps/master-api/src/f1tv/settings.ts index a359a6c..bb381a2 100644 --- a/apps/master-api/src/f1tv/settings.ts +++ b/apps/master-api/src/f1tv/settings.ts @@ -1,5 +1,5 @@ import { prisma } from "../database/client"; -import { encryptSecret } from "../security/crypto"; +import { decryptSecret, encryptSecret } from "../security/crypto"; import { AppError } from "../security/errors"; import { accountHasPlayTokens, @@ -231,6 +231,26 @@ export async function updateF1tvAccount( sessionId = input.sessionId.trim() || undefined; } + let tokenExpiresAt = current.tokenExpiresAt; + if (input.clearTokens) { + tokenExpiresAt = undefined; + } else if (input.ascendontoken?.trim() || input.entitlementtoken?.trim()) { + const { jwtExpiryIso } = await import("../agent/token-expiry"); + const a = jwtExpiryIso( + input.ascendontoken?.trim() || + (current.ascendontokenEnc + ? decryptSecret(current.ascendontokenEnc, sessionSecret) + : null) + ); + const b = jwtExpiryIso( + input.entitlementtoken?.trim() || + (current.entitlementtokenEnc + ? decryptSecret(current.entitlementtokenEnc, sessionSecret) + : null) + ); + tokenExpiresAt = [a, b].filter(Boolean).sort()[0] ?? undefined; + } + config.accounts[idx] = { ...current, email, @@ -245,6 +265,7 @@ export async function updateF1tvAccount( ascendontokenEnc, entitlementtokenEnc, sessionId, + tokenExpiresAt, }; await persist(config, existing?.enabled ?? true); return getF1tvSettingsPublic(); diff --git a/apps/master-api/src/viaplay/accounts.ts b/apps/master-api/src/viaplay/accounts.ts index 20b3abf..58e845a 100644 --- a/apps/master-api/src/viaplay/accounts.ts +++ b/apps/master-api/src/viaplay/accounts.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { decryptSecret, encryptSecret } from "../security/crypto"; +import { jwtExpiryIso } from "../agent/token-expiry"; export type ViaplayAccountHealth = "ok" | "dead" | "unknown"; @@ -21,6 +22,8 @@ export type ViaplayAccountStored = { subscriptionSummary?: string; partnerName?: string; overviewCheckedAt?: string; + /** ISO — accessToken JWT exp (voor tray auto-refresh). */ + tokenExpiresAt?: string; }; export type ViaplayAccountPublic = { @@ -39,6 +42,7 @@ export type ViaplayAccountPublic = { subscriptionSummary: string | null; partnerName: string | null; overviewCheckedAt: string | null; + tokenExpiresAt: string | null; }; export type ViaplayAccountCreds = { @@ -83,6 +87,7 @@ export function accountToPublic( subscriptionSummary: account.subscriptionSummary ?? null, partnerName: account.partnerName ?? null, overviewCheckedAt: account.overviewCheckedAt ?? null, + tokenExpiresAt: account.tokenExpiresAt ?? null, }; } @@ -153,6 +158,7 @@ export function createAccountStored( ? encryptSecret(input.persistentLogin.trim(), sessionSecret) : undefined, enabled: input.enabled !== false, + tokenExpiresAt: accessToken ? jwtExpiryIso(accessToken) ?? undefined : undefined, }; } @@ -192,6 +198,8 @@ export function parseAccountsJson(raw: unknown): ViaplayAccountStored[] { partnerName: typeof row.partnerName === "string" ? row.partnerName : undefined, overviewCheckedAt: typeof row.overviewCheckedAt === "string" ? row.overviewCheckedAt : undefined, + tokenExpiresAt: + typeof row.tokenExpiresAt === "string" ? row.tokenExpiresAt : undefined, }); } return out; diff --git a/apps/master-api/src/viaplay/settings.ts b/apps/master-api/src/viaplay/settings.ts index e4b511a..3fc4fc2 100644 --- a/apps/master-api/src/viaplay/settings.ts +++ b/apps/master-api/src/viaplay/settings.ts @@ -320,6 +320,14 @@ export async function updateViaplayAccount( persistentLoginEnc = encryptSecret(input.persistentLogin.trim(), sessionSecret); } + let tokenExpiresAt = current.tokenExpiresAt; + if (input.clearCookies) { + tokenExpiresAt = undefined; + } else if (input.accessToken?.trim()) { + const { jwtExpiryIso } = await import("../agent/token-expiry"); + tokenExpiresAt = jwtExpiryIso(input.accessToken) ?? undefined; + } + config.accounts[idx] = { ...current, email, @@ -335,6 +343,7 @@ export async function updateViaplayAccount( passwordEnc: input.password?.trim() ? encryptSecret(input.password.trim(), sessionSecret) : current.passwordEnc, + tokenExpiresAt, }; await persist(config, existing?.enabled ?? true); const tokensChanged = !!( diff --git a/apps/token-tray/README.md b/apps/token-tray/README.md index 6d62dfc..5d496a4 100644 --- a/apps/token-tray/README.md +++ b/apps/token-tray/README.md @@ -18,11 +18,16 @@ pnpm.cmd run build pnpm.cmd run start ``` -Menu: +## Auto-modus (hands-off) -- **Stil vernieuwen** — headless Chromium klikt inloggen, pusht tokens -- **Vernieuwen (zichtbaar)** — zelfde flow met venster (debug) -- Automatisch elke **3 uur** +Zolang de tray op de Windows-PC draait: + +1. Server onthoudt JWT-expiry per account +2. Tray pollt elke **10 min** `GET /api/v1/agent/refresh-due` +3. Alleen als een token **< 90 min** geldig is (of ontbreekt): stille Chrome-login + push +4. Jij hoeft niets te klikken — alleen PC/tray aan laten + +Handmatig blijft beschikbaar: *Nu checken* / *Alles vernieuwen*. ## API (tray-key) diff --git a/apps/token-tray/src/jobs.ts b/apps/token-tray/src/jobs.ts index a48bcb4..b728930 100644 --- a/apps/token-tray/src/jobs.ts +++ b/apps/token-tray/src/jobs.ts @@ -12,8 +12,26 @@ export type AgentAccount = { password: string | null; hasTokens: boolean; enabled: boolean; + tokenExpiresAt?: string | null; + refreshDue?: boolean; }; +export async function fetchRefreshDue( + cfg: TrayConfig, + withinMinutes = 90 +): Promise { + const res = await fetch( + `${cfg.apiUrl}/api/v1/agent/refresh-due?withinMinutes=${withinMinutes}`, + { headers: { "X-Tray-Key": cfg.trayKey } } + ); + const data = (await res.json().catch(() => ({}))) as { + error?: { message?: string }; + accounts?: AgentAccount[]; + }; + if (!res.ok) throw new Error(data.error?.message || `HTTP ${res.status}`); + return data.accounts ?? []; +} + export async function fetchAgentAccounts(cfg: TrayConfig): Promise { const res = await fetch(`${cfg.apiUrl}/api/v1/agent/accounts`, { headers: { "X-Tray-Key": cfg.trayKey }, @@ -91,40 +109,48 @@ export type RefreshSummary = { errors: string[]; }; -/** Headless UI-login voor alle accounts met wachtwoord. */ -export async function refreshAllSilent( +async function refreshOne( cfg: TrayConfig, - opts?: { headless?: boolean; providers?: Array<"viaplay" | "f1tv"> } + acc: AgentAccount, + headless: boolean +): Promise { + if (!acc.password?.trim()) throw new Error("Geen wachtwoord"); + if (acc.provider === "viaplay") { + const tokens = await loginViaplayUi({ + accountId: acc.id, + email: acc.email, + password: acc.password, + headless, + }); + await pushViaplay(cfg, acc.email, acc.label, tokens); + return; + } + const tokens = await loginF1tvUi({ + accountId: acc.id, + email: acc.email, + password: acc.password, + headless, + }); + await pushF1tv(cfg, acc.email, acc.label, tokens); +} + +/** Headless UI-login voor gegeven accounts (of alle met wachtwoord). */ +export async function refreshAccountsSilent( + cfg: TrayConfig, + accounts: AgentAccount[], + opts?: { headless?: boolean; notify?: boolean } ): Promise { const headless = opts?.headless !== false; - const providers = new Set(opts?.providers ?? ["viaplay", "f1tv"]); - const accounts = await fetchAgentAccounts(cfg); + const notify = opts?.notify !== false; const summary: RefreshSummary = { ok: 0, failed: 0, skipped: 0, errors: [] }; for (const acc of accounts) { - if (!providers.has(acc.provider)) continue; if (!acc.password?.trim()) { summary.skipped += 1; continue; } try { - if (acc.provider === "viaplay") { - const tokens = await loginViaplayUi({ - accountId: acc.id, - email: acc.email, - password: acc.password, - headless, - }); - await pushViaplay(cfg, acc.email, acc.label, tokens); - } else { - const tokens = await loginF1tvUi({ - accountId: acc.id, - email: acc.email, - password: acc.password, - headless, - }); - await pushF1tv(cfg, acc.email, acc.label, tokens); - } + await refreshOne(cfg, acc, headless); summary.ok += 1; } catch (err) { summary.failed += 1; @@ -135,16 +161,54 @@ export async function refreshAllSilent( } cfg.lastPushAt = new Date().toISOString(); - cfg.lastPushSummary = `Stil: ${summary.ok} ok · ${summary.failed} fout · ${summary.skipped} overgeslagen`; + cfg.lastPushSummary = `Auto: ${summary.ok} ok · ${summary.failed} fout · ${summary.skipped} overgeslagen`; cfg.lastError = summary.errors[0]; saveConfig(cfg); - if (Notification.isSupported()) { + if (notify && Notification.isSupported() && (summary.ok > 0 || summary.failed > 0)) { new Notification({ - title: "Token Tray", + title: summary.failed ? "Token Tray — fouten" : "Token Tray", body: cfg.lastPushSummary, }).show(); } return summary; } + +export async function refreshAllSilent( + cfg: TrayConfig, + opts?: { headless?: boolean; providers?: Array<"viaplay" | "f1tv">; notify?: boolean } +): Promise { + const providers = new Set(opts?.providers ?? ["viaplay", "f1tv"]); + const all = await fetchAgentAccounts(cfg); + return refreshAccountsSilent( + cfg, + all.filter((a) => providers.has(a.provider)), + opts + ); +} + +/** Alleen accounts die de server als bijna-verlopen markeert. */ +export async function refreshDueSilent( + cfg: TrayConfig, + opts?: { headless?: boolean; withinMinutes?: number; notifyOnFailOnly?: boolean } +): Promise { + const due = await fetchRefreshDue(cfg, opts?.withinMinutes ?? 90); + if (!due.length) { + cfg.lastPushSummary = "Auto: niets te vernieuwen"; + saveConfig(cfg); + return { ok: 0, failed: 0, skipped: 0, errors: [] }; + } + return refreshAccountsSilent(cfg, due, { + headless: opts?.headless !== false, + notify: opts?.notifyOnFailOnly ? false : true, + }).then((summary) => { + if (opts?.notifyOnFailOnly && summary.failed > 0 && Notification.isSupported()) { + new Notification({ + title: "Token Tray — refresh mislukt", + body: summary.errors.slice(0, 2).join(" · "), + }).show(); + } + return summary; + }); +} diff --git a/apps/token-tray/src/main.ts b/apps/token-tray/src/main.ts index 2f55443..47b9076 100644 --- a/apps/token-tray/src/main.ts +++ b/apps/token-tray/src/main.ts @@ -9,22 +9,29 @@ import { } from "electron"; import { configPath, loadConfig, saveConfig, type TrayConfig } from "./config"; import { pingAgent } from "./viaplay"; -import { refreshAllSilent } from "./jobs"; +import { refreshAllSilent, refreshDueSilent } from "./jobs"; let tray: Tray | null = null; let settingsWin: BrowserWindow | null = null; let cfg: TrayConfig = loadConfig(); -let watchTimer: ReturnType | null = null; +let pollTimer: ReturnType | null = null; let busy = false; +let lastPollAt: string | null = null; +let lastDueCount = 0; + +const POLL_MS = 10 * 60 * 1000; // elke 10 min: vraag server wie bijna verloopt +const WITHIN_MINUTES = 90; // vernieuw als token < 90 min geldig is function statusTooltip(): string { - const parts = ["Media Cluster Token Tray"]; + const parts = ["Media Cluster Token Tray (auto)"]; if (busy) parts.push("Bezig met stille refresh…"); + parts.push(`Poll: elke 10 min · drempel ${WITHIN_MINUTES} min`); + if (lastPollAt) { + parts.push(`Laatste check: ${new Date(lastPollAt).toLocaleString("nl-NL")}`); + } + if (lastDueCount > 0) parts.push(`Laatst due: ${lastDueCount} account(s)`); if (cfg.lastPushSummary) parts.push(cfg.lastPushSummary); if (cfg.lastError) parts.push(`Fout: ${cfg.lastError}`); - if (cfg.lastPushAt) { - parts.push(`Laatste push: ${new Date(cfg.lastPushAt).toLocaleString("nl-NL")}`); - } return parts.join("\n"); } @@ -32,14 +39,19 @@ function rebuildMenu() { if (!tray) return; const menu = Menu.buildFromTemplate([ { - label: busy ? "Bezig…" : "Stil vernieuwen (alle accounts)", + label: busy ? "Bezig…" : "Nu checken (server → due)", enabled: !busy, - click: () => void runSilent(true), + click: () => void runAuto(true), }, { - label: "Vernieuwen (zichtbaar venster)", + label: "Alles vernieuwen (stil)", enabled: !busy, - click: () => void runSilent(false), + click: () => void runManualAll(true), + }, + { + label: "Alles vernieuwen (zichtbaar)", + enabled: !busy, + click: () => void runManualAll(false), }, { type: "separator" }, { label: "Verbinding testen", click: () => void testConnection() }, @@ -75,10 +87,9 @@ function openSettings() { input{width:100%;box-sizing:border-box;padding:0.5rem 0.6rem;border-radius:6px;border:1px solid #333;background:#1c1f28;color:#fff} button{margin-top:1.25rem;padding:0.55rem 1rem;border:0;border-radius:6px;background:#3b82f6;color:#fff;font-weight:600;cursor:pointer} p{font-size:0.85rem;opacity:.75;line-height:1.4} - code{font-size:0.8rem;background:#1c1f28;padding:2px 5px;border-radius:4px} -

Token Tray

-

Accounts + wachtwoorden komen uit Admin (Scripts). Hier alleen API-URL + tray-key.

+

Token Tray (auto)

+

Server houdt JWT-expiry bij. Deze PC pollt elke 10 min en logt stil in via Chrome als tokens bijna verlopen. Accounts + wachtwoorden staan in Admin → Scripts.

@@ -107,10 +118,11 @@ function openSettings() { cfg.trayKey = payload.trayKey.trim(); saveConfig(cfg); rebuildMenu(); + startPolling(); void dialog.showMessageBox({ type: "info", - message: "Instellingen opgeslagen", - detail: `Accounts met wachtwoord staan in Admin → Scripts (Viaplay / F1TV).`, + message: "Opgeslagen", + detail: "Auto-poll draait (elke 10 min, alleen bijna-verlopen tokens).", }); settingsWin?.close(); } catch (err) { @@ -130,7 +142,57 @@ function escapeHtml(s: string): string { .replace(/'/g, "'"); } -async function runSilent(headless: boolean) { +/** Automatische poll: alleen due accounts, geen dialoog bij succes. */ +async function runAuto(showDialogIfManual: boolean) { + if (busy) return; + if (!cfg.trayKey.trim()) { + if (showDialogIfManual) openSettings(); + return; + } + busy = true; + rebuildMenu(); + try { + const { fetchRefreshDue } = await import("./jobs"); + const due = await fetchRefreshDue(cfg, WITHIN_MINUTES); + lastPollAt = new Date().toISOString(); + lastDueCount = due.length; + if (!due.length) { + cfg = loadConfig(); + cfg.lastPushSummary = "Auto: niets te vernieuwen"; + saveConfig(cfg); + if (showDialogIfManual) { + void dialog.showMessageBox({ + type: "info", + message: "Niets te doen", + detail: `Geen tokens die binnen ${WITHIN_MINUTES} min verlopen.`, + }); + } + return; + } + const summary = await refreshDueSilent(cfg, { + headless: true, + withinMinutes: WITHIN_MINUTES, + notifyOnFailOnly: !showDialogIfManual, + }); + cfg = loadConfig(); + if (showDialogIfManual) { + void dialog.showMessageBox({ + type: summary.failed ? "warning" : "info", + message: "Auto-refresh klaar", + detail: `${summary.ok} ok · ${summary.failed} fout\n${summary.errors.slice(0, 3).join("\n")}`, + }); + } + } catch (err) { + if (showDialogIfManual) { + void dialog.showErrorBox("Auto-check mislukt", String(err)); + } + } finally { + busy = false; + rebuildMenu(); + } +} + +async function runManualAll(headless: boolean) { if (busy) return; if (!cfg.trayKey.trim()) { openSettings(); @@ -139,7 +201,7 @@ async function runSilent(headless: boolean) { busy = true; rebuildMenu(); try { - const summary = await refreshAllSilent(cfg, { headless }); + const summary = await refreshAllSilent(cfg, { headless, notify: true }); cfg = loadConfig(); void dialog.showMessageBox({ type: summary.failed ? "warning" : "info", @@ -181,21 +243,27 @@ function createTray() { tray = new Tray(png.isEmpty() ? nativeImage.createEmpty() : png); tray.setToolTip("Media Cluster Token Tray"); rebuildMenu(); - tray.on("double-click", () => void runSilent(true)); + tray.on("double-click", () => void runAuto(true)); +} + +function startPolling() { + if (pollTimer) clearInterval(pollTimer); + // Direct één check, daarna elke 10 min + void runAuto(false); + pollTimer = setInterval(() => void runAuto(false), POLL_MS); } app.whenReady().then(() => { cfg = loadConfig(); createTray(); if (!cfg.trayKey) openSettings(); - // Elke 3 uur stil proberen (alleen accounts mét wachtwoord) - watchTimer = setInterval(() => void runSilent(true), 3 * 60 * 60 * 1000); + else startPolling(); }); app.on("window-all-closed", () => { - // Tray blijft actief + /* tray blijft */ }); app.on("before-quit", () => { - if (watchTimer) clearInterval(watchTimer); + if (pollTimer) clearInterval(pollTimer); });