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 decryptViaplay } from "../viaplay/accounts";
|
||||||
import { decryptAccount as decryptF1tv } from "../f1tv/accounts";
|
import { decryptAccount as decryptF1tv } from "../f1tv/accounts";
|
||||||
import { prisma } from "../database/client";
|
import { prisma } from "../database/client";
|
||||||
|
import { jwtExpiryIso, tokenNeedsRefresh } from "./token-expiry";
|
||||||
|
|
||||||
async function requireTrayKey(request: FastifyRequest, config: Config) {
|
async function requireTrayKey(request: FastifyRequest, config: Config) {
|
||||||
const secret = await resolveTokenTraySecret(
|
const secret = await resolveTokenTraySecret(
|
||||||
|
|
@ -32,21 +33,15 @@ export type AgentAccountRow = {
|
||||||
password: string | null;
|
password: string | null;
|
||||||
hasTokens: boolean;
|
hasTokens: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
tokenExpiresAt: string | null;
|
||||||
|
refreshDue: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
async function listAgentAccounts(
|
||||||
app.get("/api/v1/agent/status", async (request) => {
|
config: Config,
|
||||||
await requireTrayKey(request, config);
|
opts?: { onlyDue?: boolean; withinMinutes?: number }
|
||||||
return {
|
): Promise<AgentAccountRow[]> {
|
||||||
ok: true,
|
const withinMs = Math.max(5, opts?.withinMinutes ?? 90) * 60_000;
|
||||||
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);
|
|
||||||
const accounts: AgentAccountRow[] = [];
|
const accounts: AgentAccountRow[] = [];
|
||||||
|
|
||||||
const vpRow = await prisma.integrationSetting.findUnique({ where: { id: "viaplay" } });
|
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)) {
|
for (const a of parseAccountsJson(parsed.accounts)) {
|
||||||
if (a.enabled === false) continue;
|
if (a.enabled === false) continue;
|
||||||
const creds = decryptViaplay(a, config.SESSION_SECRET);
|
const creds = decryptViaplay(a, config.SESSION_SECRET);
|
||||||
|
const exp =
|
||||||
|
a.tokenExpiresAt ||
|
||||||
|
jwtExpiryIso(creds.accessToken) ||
|
||||||
|
null;
|
||||||
|
const refreshDue =
|
||||||
|
!!creds.password &&
|
||||||
|
tokenNeedsRefresh(exp, withinMs);
|
||||||
accounts.push({
|
accounts.push({
|
||||||
provider: "viaplay",
|
provider: "viaplay",
|
||||||
id: a.id,
|
id: a.id,
|
||||||
|
|
@ -65,10 +67,12 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
||||||
password: creds.password,
|
password: creds.password,
|
||||||
hasTokens: !!(creds.session && creds.accessToken),
|
hasTokens: !!(creds.session && creds.accessToken),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
tokenExpiresAt: exp,
|
||||||
|
refreshDue,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore corrupt */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +84,15 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
||||||
for (const a of parseAccountsJson(parsed.accounts)) {
|
for (const a of parseAccountsJson(parsed.accounts)) {
|
||||||
if (a.enabled === false) continue;
|
if (a.enabled === false) continue;
|
||||||
const creds = decryptF1tv(a, config.SESSION_SECRET);
|
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({
|
accounts.push({
|
||||||
provider: "f1tv",
|
provider: "f1tv",
|
||||||
id: a.id,
|
id: a.id,
|
||||||
|
|
@ -88,6 +101,8 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
||||||
password: creds.password,
|
password: creds.password,
|
||||||
hasTokens: !!(creds.ascendontoken && creds.entitlementtoken),
|
hasTokens: !!(creds.ascendontoken && creds.entitlementtoken),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
tokenExpiresAt: exp,
|
||||||
|
refreshDue,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} 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) => {
|
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 { randomUUID } from "node:crypto";
|
||||||
import { decryptSecret, encryptSecret } from "../security/crypto";
|
import { decryptSecret, encryptSecret } from "../security/crypto";
|
||||||
|
import { jwtExpiryIso } from "../agent/token-expiry";
|
||||||
|
|
||||||
export type F1tvAccountHealth = "ok" | "dead" | "unknown";
|
export type F1tvAccountHealth = "ok" | "dead" | "unknown";
|
||||||
|
|
||||||
|
|
@ -16,6 +17,8 @@ export type F1tvAccountStored = {
|
||||||
healthStatus?: F1tvAccountHealth;
|
healthStatus?: F1tvAccountHealth;
|
||||||
healthCheckedAt?: string;
|
healthCheckedAt?: string;
|
||||||
healthError?: string;
|
healthError?: string;
|
||||||
|
/** ISO — earliest JWT exp van play-tokens. */
|
||||||
|
tokenExpiresAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type F1tvAccountPublic = {
|
export type F1tvAccountPublic = {
|
||||||
|
|
@ -31,6 +34,7 @@ export type F1tvAccountPublic = {
|
||||||
healthStatus: F1tvAccountHealth;
|
healthStatus: F1tvAccountHealth;
|
||||||
healthCheckedAt: string | null;
|
healthCheckedAt: string | null;
|
||||||
healthError: string | null;
|
healthError: string | null;
|
||||||
|
tokenExpiresAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type F1tvAccountCreds = {
|
export type F1tvAccountCreds = {
|
||||||
|
|
@ -70,6 +74,7 @@ export function accountToPublic(
|
||||||
healthStatus: account.healthStatus ?? "unknown",
|
healthStatus: account.healthStatus ?? "unknown",
|
||||||
healthCheckedAt: account.healthCheckedAt ?? null,
|
healthCheckedAt: account.healthCheckedAt ?? null,
|
||||||
healthError: account.healthError ?? null,
|
healthError: account.healthError ?? null,
|
||||||
|
tokenExpiresAt: account.tokenExpiresAt ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,6 +121,12 @@ export function createAccountStored(
|
||||||
if (!password && !ascendontoken && !entitlementtoken) {
|
if (!password && !ascendontoken && !entitlementtoken) {
|
||||||
throw new Error("Wachtwoord of tokens verplicht");
|
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 {
|
return {
|
||||||
id: input.id?.trim() || randomUUID(),
|
id: input.id?.trim() || randomUUID(),
|
||||||
label: (input.label ?? "").trim() || email,
|
label: (input.label ?? "").trim() || email,
|
||||||
|
|
@ -129,6 +140,7 @@ export function createAccountStored(
|
||||||
: undefined,
|
: undefined,
|
||||||
sessionId: input.sessionId?.trim() || undefined,
|
sessionId: input.sessionId?.trim() || undefined,
|
||||||
enabled: input.enabled !== false,
|
enabled: input.enabled !== false,
|
||||||
|
tokenExpiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,6 +171,8 @@ export function parseAccountsJson(raw: unknown): F1tvAccountStored[] {
|
||||||
healthCheckedAt:
|
healthCheckedAt:
|
||||||
typeof row.healthCheckedAt === "string" ? row.healthCheckedAt : undefined,
|
typeof row.healthCheckedAt === "string" ? row.healthCheckedAt : undefined,
|
||||||
healthError: typeof row.healthError === "string" ? row.healthError : undefined,
|
healthError: typeof row.healthError === "string" ? row.healthError : undefined,
|
||||||
|
tokenExpiresAt:
|
||||||
|
typeof row.tokenExpiresAt === "string" ? row.tokenExpiresAt : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { prisma } from "../database/client";
|
import { prisma } from "../database/client";
|
||||||
import { encryptSecret } from "../security/crypto";
|
import { decryptSecret, encryptSecret } from "../security/crypto";
|
||||||
import { AppError } from "../security/errors";
|
import { AppError } from "../security/errors";
|
||||||
import {
|
import {
|
||||||
accountHasPlayTokens,
|
accountHasPlayTokens,
|
||||||
|
|
@ -231,6 +231,26 @@ export async function updateF1tvAccount(
|
||||||
sessionId = input.sessionId.trim() || undefined;
|
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] = {
|
config.accounts[idx] = {
|
||||||
...current,
|
...current,
|
||||||
email,
|
email,
|
||||||
|
|
@ -245,6 +265,7 @@ export async function updateF1tvAccount(
|
||||||
ascendontokenEnc,
|
ascendontokenEnc,
|
||||||
entitlementtokenEnc,
|
entitlementtokenEnc,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
tokenExpiresAt,
|
||||||
};
|
};
|
||||||
await persist(config, existing?.enabled ?? true);
|
await persist(config, existing?.enabled ?? true);
|
||||||
return getF1tvSettingsPublic();
|
return getF1tvSettingsPublic();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { decryptSecret, encryptSecret } from "../security/crypto";
|
import { decryptSecret, encryptSecret } from "../security/crypto";
|
||||||
|
import { jwtExpiryIso } from "../agent/token-expiry";
|
||||||
|
|
||||||
export type ViaplayAccountHealth = "ok" | "dead" | "unknown";
|
export type ViaplayAccountHealth = "ok" | "dead" | "unknown";
|
||||||
|
|
||||||
|
|
@ -21,6 +22,8 @@ export type ViaplayAccountStored = {
|
||||||
subscriptionSummary?: string;
|
subscriptionSummary?: string;
|
||||||
partnerName?: string;
|
partnerName?: string;
|
||||||
overviewCheckedAt?: string;
|
overviewCheckedAt?: string;
|
||||||
|
/** ISO — accessToken JWT exp (voor tray auto-refresh). */
|
||||||
|
tokenExpiresAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ViaplayAccountPublic = {
|
export type ViaplayAccountPublic = {
|
||||||
|
|
@ -39,6 +42,7 @@ export type ViaplayAccountPublic = {
|
||||||
subscriptionSummary: string | null;
|
subscriptionSummary: string | null;
|
||||||
partnerName: string | null;
|
partnerName: string | null;
|
||||||
overviewCheckedAt: string | null;
|
overviewCheckedAt: string | null;
|
||||||
|
tokenExpiresAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ViaplayAccountCreds = {
|
export type ViaplayAccountCreds = {
|
||||||
|
|
@ -83,6 +87,7 @@ export function accountToPublic(
|
||||||
subscriptionSummary: account.subscriptionSummary ?? null,
|
subscriptionSummary: account.subscriptionSummary ?? null,
|
||||||
partnerName: account.partnerName ?? null,
|
partnerName: account.partnerName ?? null,
|
||||||
overviewCheckedAt: account.overviewCheckedAt ?? null,
|
overviewCheckedAt: account.overviewCheckedAt ?? null,
|
||||||
|
tokenExpiresAt: account.tokenExpiresAt ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,6 +158,7 @@ export function createAccountStored(
|
||||||
? encryptSecret(input.persistentLogin.trim(), sessionSecret)
|
? encryptSecret(input.persistentLogin.trim(), sessionSecret)
|
||||||
: undefined,
|
: undefined,
|
||||||
enabled: input.enabled !== false,
|
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,
|
partnerName: typeof row.partnerName === "string" ? row.partnerName : undefined,
|
||||||
overviewCheckedAt:
|
overviewCheckedAt:
|
||||||
typeof row.overviewCheckedAt === "string" ? row.overviewCheckedAt : undefined,
|
typeof row.overviewCheckedAt === "string" ? row.overviewCheckedAt : undefined,
|
||||||
|
tokenExpiresAt:
|
||||||
|
typeof row.tokenExpiresAt === "string" ? row.tokenExpiresAt : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,14 @@ export async function updateViaplayAccount(
|
||||||
persistentLoginEnc = encryptSecret(input.persistentLogin.trim(), sessionSecret);
|
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] = {
|
config.accounts[idx] = {
|
||||||
...current,
|
...current,
|
||||||
email,
|
email,
|
||||||
|
|
@ -335,6 +343,7 @@ export async function updateViaplayAccount(
|
||||||
passwordEnc: input.password?.trim()
|
passwordEnc: input.password?.trim()
|
||||||
? encryptSecret(input.password.trim(), sessionSecret)
|
? encryptSecret(input.password.trim(), sessionSecret)
|
||||||
: current.passwordEnc,
|
: current.passwordEnc,
|
||||||
|
tokenExpiresAt,
|
||||||
};
|
};
|
||||||
await persist(config, existing?.enabled ?? true);
|
await persist(config, existing?.enabled ?? true);
|
||||||
const tokensChanged = !!(
|
const tokensChanged = !!(
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,16 @@ pnpm.cmd run build
|
||||||
pnpm.cmd run start
|
pnpm.cmd run start
|
||||||
```
|
```
|
||||||
|
|
||||||
Menu:
|
## Auto-modus (hands-off)
|
||||||
|
|
||||||
- **Stil vernieuwen** — headless Chromium klikt inloggen, pusht tokens
|
Zolang de tray op de Windows-PC draait:
|
||||||
- **Vernieuwen (zichtbaar)** — zelfde flow met venster (debug)
|
|
||||||
- Automatisch elke **3 uur**
|
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)
|
## API (tray-key)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,26 @@ export type AgentAccount = {
|
||||||
password: string | null;
|
password: string | null;
|
||||||
hasTokens: boolean;
|
hasTokens: boolean;
|
||||||
enabled: 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[]> {
|
export async function fetchAgentAccounts(cfg: TrayConfig): Promise<AgentAccount[]> {
|
||||||
const res = await fetch(`${cfg.apiUrl}/api/v1/agent/accounts`, {
|
const res = await fetch(`${cfg.apiUrl}/api/v1/agent/accounts`, {
|
||||||
headers: { "X-Tray-Key": cfg.trayKey },
|
headers: { "X-Tray-Key": cfg.trayKey },
|
||||||
|
|
@ -91,23 +109,12 @@ export type RefreshSummary = {
|
||||||
errors: string[];
|
errors: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Headless UI-login voor alle accounts met wachtwoord. */
|
async function refreshOne(
|
||||||
export async function refreshAllSilent(
|
|
||||||
cfg: TrayConfig,
|
cfg: TrayConfig,
|
||||||
opts?: { headless?: boolean; providers?: Array<"viaplay" | "f1tv"> }
|
acc: AgentAccount,
|
||||||
): Promise<RefreshSummary> {
|
headless: boolean
|
||||||
const headless = opts?.headless !== false;
|
): Promise<void> {
|
||||||
const providers = new Set(opts?.providers ?? ["viaplay", "f1tv"]);
|
if (!acc.password?.trim()) throw new Error("Geen wachtwoord");
|
||||||
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 {
|
|
||||||
if (acc.provider === "viaplay") {
|
if (acc.provider === "viaplay") {
|
||||||
const tokens = await loginViaplayUi({
|
const tokens = await loginViaplayUi({
|
||||||
accountId: acc.id,
|
accountId: acc.id,
|
||||||
|
|
@ -116,7 +123,8 @@ export async function refreshAllSilent(
|
||||||
headless,
|
headless,
|
||||||
});
|
});
|
||||||
await pushViaplay(cfg, acc.email, acc.label, tokens);
|
await pushViaplay(cfg, acc.email, acc.label, tokens);
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
const tokens = await loginF1tvUi({
|
const tokens = await loginF1tvUi({
|
||||||
accountId: acc.id,
|
accountId: acc.id,
|
||||||
email: acc.email,
|
email: acc.email,
|
||||||
|
|
@ -124,7 +132,25 @@ export async function refreshAllSilent(
|
||||||
headless,
|
headless,
|
||||||
});
|
});
|
||||||
await pushF1tv(cfg, acc.email, acc.label, tokens);
|
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;
|
summary.ok += 1;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
summary.failed += 1;
|
summary.failed += 1;
|
||||||
|
|
@ -135,16 +161,54 @@ export async function refreshAllSilent(
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.lastPushAt = new Date().toISOString();
|
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];
|
cfg.lastError = summary.errors[0];
|
||||||
saveConfig(cfg);
|
saveConfig(cfg);
|
||||||
|
|
||||||
if (Notification.isSupported()) {
|
if (notify && Notification.isSupported() && (summary.ok > 0 || summary.failed > 0)) {
|
||||||
new Notification({
|
new Notification({
|
||||||
title: "Token Tray",
|
title: summary.failed ? "Token Tray — fouten" : "Token Tray",
|
||||||
body: cfg.lastPushSummary,
|
body: cfg.lastPushSummary,
|
||||||
}).show();
|
}).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
return summary;
|
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";
|
} from "electron";
|
||||||
import { configPath, loadConfig, saveConfig, type TrayConfig } from "./config";
|
import { configPath, loadConfig, saveConfig, type TrayConfig } from "./config";
|
||||||
import { pingAgent } from "./viaplay";
|
import { pingAgent } from "./viaplay";
|
||||||
import { refreshAllSilent } from "./jobs";
|
import { refreshAllSilent, refreshDueSilent } from "./jobs";
|
||||||
|
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
let settingsWin: BrowserWindow | null = null;
|
let settingsWin: BrowserWindow | null = null;
|
||||||
let cfg: TrayConfig = loadConfig();
|
let cfg: TrayConfig = loadConfig();
|
||||||
let watchTimer: ReturnType<typeof setInterval> | null = null;
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let busy = false;
|
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 {
|
function statusTooltip(): string {
|
||||||
const parts = ["Media Cluster Token Tray"];
|
const parts = ["Media Cluster Token Tray (auto)"];
|
||||||
if (busy) parts.push("Bezig met stille refresh…");
|
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.lastPushSummary) parts.push(cfg.lastPushSummary);
|
||||||
if (cfg.lastError) parts.push(`Fout: ${cfg.lastError}`);
|
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");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,14 +39,19 @@ function rebuildMenu() {
|
||||||
if (!tray) return;
|
if (!tray) return;
|
||||||
const menu = Menu.buildFromTemplate([
|
const menu = Menu.buildFromTemplate([
|
||||||
{
|
{
|
||||||
label: busy ? "Bezig…" : "Stil vernieuwen (alle accounts)",
|
label: busy ? "Bezig…" : "Nu checken (server → due)",
|
||||||
enabled: !busy,
|
enabled: !busy,
|
||||||
click: () => void runSilent(true),
|
click: () => void runAuto(true),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Vernieuwen (zichtbaar venster)",
|
label: "Alles vernieuwen (stil)",
|
||||||
enabled: !busy,
|
enabled: !busy,
|
||||||
click: () => void runSilent(false),
|
click: () => void runManualAll(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Alles vernieuwen (zichtbaar)",
|
||||||
|
enabled: !busy,
|
||||||
|
click: () => void runManualAll(false),
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ label: "Verbinding testen", click: () => void testConnection() },
|
{ 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}
|
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}
|
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}
|
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>
|
</style></head><body>
|
||||||
<h2 style="margin-top:0">Token Tray</h2>
|
<h2 style="margin-top:0">Token Tray (auto)</h2>
|
||||||
<p>Accounts + wachtwoorden komen uit Admin (Scripts). Hier alleen API-URL + tray-key.</p>
|
<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>
|
<label>API URL</label>
|
||||||
<input id="apiUrl" value="${escapeHtml(cfg.apiUrl)}" />
|
<input id="apiUrl" value="${escapeHtml(cfg.apiUrl)}" />
|
||||||
<label>Tray key (X-Tray-Key)</label>
|
<label>Tray key (X-Tray-Key)</label>
|
||||||
|
|
@ -107,10 +118,11 @@ function openSettings() {
|
||||||
cfg.trayKey = payload.trayKey.trim();
|
cfg.trayKey = payload.trayKey.trim();
|
||||||
saveConfig(cfg);
|
saveConfig(cfg);
|
||||||
rebuildMenu();
|
rebuildMenu();
|
||||||
|
startPolling();
|
||||||
void dialog.showMessageBox({
|
void dialog.showMessageBox({
|
||||||
type: "info",
|
type: "info",
|
||||||
message: "Instellingen opgeslagen",
|
message: "Opgeslagen",
|
||||||
detail: `Accounts met wachtwoord staan in Admin → Scripts (Viaplay / F1TV).`,
|
detail: "Auto-poll draait (elke 10 min, alleen bijna-verlopen tokens).",
|
||||||
});
|
});
|
||||||
settingsWin?.close();
|
settingsWin?.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -130,7 +142,57 @@ function escapeHtml(s: string): string {
|
||||||
.replace(/'/g, "'");
|
.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 (busy) return;
|
||||||
if (!cfg.trayKey.trim()) {
|
if (!cfg.trayKey.trim()) {
|
||||||
openSettings();
|
openSettings();
|
||||||
|
|
@ -139,7 +201,7 @@ async function runSilent(headless: boolean) {
|
||||||
busy = true;
|
busy = true;
|
||||||
rebuildMenu();
|
rebuildMenu();
|
||||||
try {
|
try {
|
||||||
const summary = await refreshAllSilent(cfg, { headless });
|
const summary = await refreshAllSilent(cfg, { headless, notify: true });
|
||||||
cfg = loadConfig();
|
cfg = loadConfig();
|
||||||
void dialog.showMessageBox({
|
void dialog.showMessageBox({
|
||||||
type: summary.failed ? "warning" : "info",
|
type: summary.failed ? "warning" : "info",
|
||||||
|
|
@ -181,21 +243,27 @@ function createTray() {
|
||||||
tray = new Tray(png.isEmpty() ? nativeImage.createEmpty() : png);
|
tray = new Tray(png.isEmpty() ? nativeImage.createEmpty() : png);
|
||||||
tray.setToolTip("Media Cluster Token Tray");
|
tray.setToolTip("Media Cluster Token Tray");
|
||||||
rebuildMenu();
|
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(() => {
|
app.whenReady().then(() => {
|
||||||
cfg = loadConfig();
|
cfg = loadConfig();
|
||||||
createTray();
|
createTray();
|
||||||
if (!cfg.trayKey) openSettings();
|
if (!cfg.trayKey) openSettings();
|
||||||
// Elke 3 uur stil proberen (alleen accounts mét wachtwoord)
|
else startPolling();
|
||||||
watchTimer = setInterval(() => void runSilent(true), 3 * 60 * 60 * 1000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
// Tray blijft actief
|
/* tray blijft */
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("before-quit", () => {
|
app.on("before-quit", () => {
|
||||||
if (watchTimer) clearInterval(watchTimer);
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue