Auto-refresh tokens based on server-tracked JWT expiry.
Tray polls refresh-due every 10 minutes and silently renews accounts within 90 minutes of expiry.
This commit is contained in:
parent
4b4f0abcff
commit
c6eec07de5
9 changed files with 373 additions and 102 deletions
|
|
@ -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,21 +33,15 @@ export type AgentAccountRow = {
|
|||
password: string | null;
|
||||
hasTokens: boolean;
|
||||
enabled: boolean;
|
||||
tokenExpiresAt: string | null;
|
||||
refreshDue: boolean;
|
||||
};
|
||||
|
||||
export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
||||
app.get("/api/v1/agent/status", async (request) => {
|
||||
await requireTrayKey(request, config);
|
||||
return {
|
||||
ok: true,
|
||||
trayEnabled: true,
|
||||
providers: ["viaplay", "f1tv"],
|
||||
};
|
||||
});
|
||||
|
||||
/** Accounts + wachtwoorden voor stille browser-login (alleen met tray-key). */
|
||||
app.get("/api/v1/agent/accounts", async (request) => {
|
||||
await requireTrayKey(request, config);
|
||||
async function listAgentAccounts(
|
||||
config: Config,
|
||||
opts?: { onlyDue?: boolean; withinMinutes?: number }
|
||||
): Promise<AgentAccountRow[]> {
|
||||
const withinMs = Math.max(5, opts?.withinMinutes ?? 90) * 60_000;
|
||||
const accounts: AgentAccountRow[] = [];
|
||||
|
||||
const vpRow = await prisma.integrationSetting.findUnique({ where: { id: "viaplay" } });
|
||||
|
|
@ -57,6 +52,13 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
|||
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,
|
||||
|
|
@ -65,10 +67,12 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
|||
password: creds.password,
|
||||
hasTokens: !!(creds.session && creds.accessToken),
|
||||
enabled: true,
|
||||
tokenExpiresAt: exp,
|
||||
refreshDue,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore corrupt */
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +84,15 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
|||
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,
|
||||
|
|
@ -88,6 +101,8 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
|||
password: creds.password,
|
||||
hasTokens: !!(creds.ascendontoken && creds.entitlementtoken),
|
||||
enabled: true,
|
||||
tokenExpiresAt: exp,
|
||||
refreshDue,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -95,7 +110,48 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
|||
}
|
||||
}
|
||||
|
||||
return { accounts };
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
/** Alle accounts (+ wachtwoorden) voor de tray. */
|
||||
app.get("/api/v1/agent/accounts", async (request) => {
|
||||
await requireTrayKey(request, config);
|
||||
const q = request.query as { withinMinutes?: string };
|
||||
const withinMinutes = Number(q.withinMinutes) || 90;
|
||||
const accounts = await listAgentAccounts(config, { withinMinutes });
|
||||
return { accounts, withinMinutes };
|
||||
});
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
|
|
|
|||
26
apps/master-api/src/agent/token-expiry.ts
Normal file
26
apps/master-api/src/agent/token-expiry.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = !!(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AgentAccount[]> {
|
||||
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<AgentAccount[]> {
|
||||
const res = await fetch(`${cfg.apiUrl}/api/v1/agent/accounts`, {
|
||||
headers: { "X-Tray-Key": cfg.trayKey },
|
||||
|
|
@ -91,23 +109,12 @@ 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"> }
|
||||
): Promise<RefreshSummary> {
|
||||
const headless = opts?.headless !== false;
|
||||
const providers = new Set(opts?.providers ?? ["viaplay", "f1tv"]);
|
||||
const accounts = await fetchAgentAccounts(cfg);
|
||||
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 {
|
||||
acc: AgentAccount,
|
||||
headless: boolean
|
||||
): Promise<void> {
|
||||
if (!acc.password?.trim()) throw new Error("Geen wachtwoord");
|
||||
if (acc.provider === "viaplay") {
|
||||
const tokens = await loginViaplayUi({
|
||||
accountId: acc.id,
|
||||
|
|
@ -116,7 +123,8 @@ export async function refreshAllSilent(
|
|||
headless,
|
||||
});
|
||||
await pushViaplay(cfg, acc.email, acc.label, tokens);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const tokens = await loginF1tvUi({
|
||||
accountId: acc.id,
|
||||
email: acc.email,
|
||||
|
|
@ -125,6 +133,24 @@ export async function refreshAllSilent(
|
|||
});
|
||||
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<RefreshSummary> {
|
||||
const headless = opts?.headless !== false;
|
||||
const notify = opts?.notify !== false;
|
||||
const summary: RefreshSummary = { ok: 0, failed: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (const acc of accounts) {
|
||||
if (!acc.password?.trim()) {
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
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<RefreshSummary> {
|
||||
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<RefreshSummary> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof setInterval> | null = null;
|
||||
let pollTimer: ReturnType<typeof setInterval> | 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}
|
||||
</style></head><body>
|
||||
<h2 style="margin-top:0">Token Tray</h2>
|
||||
<p>Accounts + wachtwoorden komen uit Admin (Scripts). Hier alleen API-URL + tray-key.</p>
|
||||
<h2 style="margin-top:0">Token Tray (auto)</h2>
|
||||
<p>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.</p>
|
||||
<label>API URL</label>
|
||||
<input id="apiUrl" value="${escapeHtml(cfg.apiUrl)}" />
|
||||
<label>Tray key (X-Tray-Key)</label>
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue