Silent browser UI login for Viaplay and F1TV via Token Tray.

Admin stores email/password; tray uses Playwright against real Chrome to click login and push tokens.
This commit is contained in:
Jos Vooges | STH 2026-09-22 23:54:10 +02:00
parent c425b2b839
commit 2c14e2ce64
12 changed files with 736 additions and 208 deletions

View file

@ -305,10 +305,8 @@ export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void
<div className="card" style={{ maxWidth: 760 }}>
<h2 style={{ marginTop: 0 }}>F1TV</h2>
<p className="muted" style={{ marginTop: 0 }}>
Events + F1-hub. Imperva blokkeert server-login: plak{" "}
<code>ascendontoken</code> + <code>entitlementtoken</code> uit een ingelogde
browsersessie. Meerdere accounts: sessies worden als unie samengevoegd; play
round-robint over owners.
Events + F1-hub. Zet e-mail + wachtwoord voor de Token Tray (stille browser-login).
Tokens plakken blijft optioneel. Meerdere accounts: sessies als unie; play round-robint.
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: "1rem" }}>
@ -322,20 +320,24 @@ export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void
<h3 style={{ marginBottom: "0.5rem" }}>Accounts</h3>
{accounts.length === 0 ? (
<p className="muted">Nog geen accounts — voeg e-mail + tokens toe.</p>
<p className="muted">Nog geen accounts — voeg e-mail + wachtwoord toe.</p>
) : (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 1rem" }}>
{accounts.map((a) => (
<li
key={a.id}
style={{
padding: "0.6rem 0",
borderBottom: "1px solid var(--border, #333)",
}}
>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
alignItems: "center",
justifyContent: "space-between",
padding: "0.6rem 0",
borderBottom: "1px solid var(--border, #333)",
}}
>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
@ -344,6 +346,8 @@ export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void
<span className="muted" style={{ fontSize: "0.85rem" }}>
{a.email}
{" · "}
{a.hasPassword ? "wachtwoord ok" : "geen wachtwoord"}
{" · "}
{a.hasAscendonToken && a.hasEntitlementToken ? "tokens ok" : "tokens missen"}
{a.sessionCount ? ` · ${a.sessionCount} sessies` : ""}
</span>
@ -352,6 +356,43 @@ export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void
<button type="button" disabled={busy} onClick={() => void probeAccounts(a.id)}>
Check
</button>
<button
type="button"
disabled={busy}
onClick={() => {
const pw = prompt(
a.hasPassword
? `Nieuw wachtwoord voor ${a.email} (leeg = annuleren)`
: `Wachtwoord voor ${a.email}`
);
if (pw == null || !pw.trim()) return;
void (async () => {
setBusy(true);
setErr(null);
try {
const r = await fetch(
`/api/v1/admin/scripts/f1tv/accounts/${a.id}`,
{
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pw.trim() }),
}
);
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Opslaan mislukt");
applySettings(d);
setMsg("Wachtwoord opgeslagen");
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
})();
}}
>
Wachtwoord
</button>
<button type="button" disabled={busy} onClick={() => void toggleAccount(a)}>
{a.enabled ? "Uitschakelen" : "Inschakelen"}
</button>
@ -359,6 +400,7 @@ export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void
Verwijderen
</button>
</div>
</div>
</li>
))}
</ul>
@ -393,7 +435,7 @@ export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void
/>
</label>
<label className="field">
Wachtwoord (optioneel)
Wachtwoord (voor Token Tray)
<input
type="password"
value={newPassword}
@ -410,7 +452,7 @@ export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void
style={{ marginTop: 10 }}
onClick={() => setShowTokens((v) => !v)}
>
{showTokens ? "Tokens verbergen" : "Tokens plakken (vereist)"}
{showTokens ? "Tokens verbergen" : "Tokens plakken (optioneel)"}
</button>
{showTokens && (

View file

@ -68,6 +68,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
const [newLabel, setNewLabel] = useState("");
const [newEmail, setNewEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newSession, setNewSession] = useState("");
const [newAccessToken, setNewAccessToken] = useState("");
const [newProfileId, setNewProfileId] = useState("");
@ -76,6 +77,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
const [editSession, setEditSession] = useState("");
const [editAccessToken, setEditAccessToken] = useState("");
const [editProfileId, setEditProfileId] = useState("");
const [editPassword, setEditPassword] = useState("");
const applySettings = useCallback(
(d: ViaplaySettings) => {
@ -140,6 +142,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
body: JSON.stringify({
label: newLabel.trim() || undefined,
email: newEmail.trim(),
password: newPassword.trim() || undefined,
session: newSession.trim() || undefined,
accessToken: newAccessToken.trim() || undefined,
profileId: newProfileId.trim() || undefined,
@ -152,6 +155,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
applySettings(d);
setNewLabel("");
setNewEmail("");
setNewPassword("");
setNewSession("");
setNewAccessToken("");
setNewProfileId("");
@ -162,6 +166,8 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
setMsg(
sub
? `Account toegevoegd · abonnement: ${sub}`
: newPassword.trim()
? "Account toegevoegd — Token Tray kan stil inloggen met wachtwoord"
: "Account toegevoegd — abonnement nog niet zichtbaar, druk Check"
);
} catch (e) {
@ -196,13 +202,19 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
setEditSession("");
setEditAccessToken("");
setEditProfileId("");
setEditPassword("");
setErr(null);
setMsg(null);
}
async function saveAccountTokens(account: ViaplayAccount) {
if (!editSession.trim() && !editAccessToken.trim() && !editProfileId.trim()) {
setErr("Plak minstens session, accessToken of profileId");
if (
!editSession.trim() &&
!editAccessToken.trim() &&
!editProfileId.trim() &&
!editPassword.trim()
) {
setErr("Plak minstens wachtwoord, session, accessToken of profileId");
return;
}
setBusy(true);
@ -213,6 +225,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
if (editSession.trim()) body.session = editSession.trim();
if (editAccessToken.trim()) body.accessToken = editAccessToken.trim();
if (editProfileId.trim()) body.profileId = editProfileId.trim();
if (editPassword.trim()) body.password = editPassword.trim();
const r = await fetch(`/api/v1/admin/scripts/viaplay/accounts/${account.id}`, {
method: "PATCH",
credentials: "include",
@ -226,12 +239,13 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
setEditSession("");
setEditAccessToken("");
setEditProfileId("");
setEditPassword("");
const updated = (d.accounts as ViaplayAccount[] | undefined)?.find((a) => a.id === account.id);
const sub = updated?.subscriptionSummary;
setMsg(
sub
? `Tokens bijgewerkt · abonnement: ${sub}`
: "Tokens bijgewerkt — druk Check als abonnement nog ontbreekt"
? `Bijgewerkt · abonnement: ${sub}`
: "Opgeslagen — Token Tray gebruikt wachtwoord voor stille login"
);
} catch (e) {
setErr(String((e as Error).message ?? e));
@ -397,9 +411,8 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
<div className="card" style={{ maxWidth: 760 }}>
<h2 style={{ marginTop: 0 }}>Viaplay</h2>
<p className="muted" style={{ marginTop: 0 }}>
Sportagenda en live streams voor gekoppelde Viaplay-accounts. Voeg een account toe met
sessie-token, access token en optioneel profile-id (uit een ingelogde browsersessie),
of gebruik de Windows Token Tray (<code>apps/token-tray</code>) om tokens te pushen.
Sportagenda en live streams voor gekoppelde Viaplay-accounts. Zet e-mail + wachtwoord
(Token Tray logt stil in via de browser-UI). Tokens plakken blijft optioneel.
Agenda: max 3 dagen vooruit (vervangt Viaplay uit de externe events-feed). Manifests en
licenties cache-first (±5 min vóór start).
</p>
@ -452,6 +465,8 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
{healthBadge(a)}
<span className="muted" style={{ fontSize: "0.85rem" }}>
{a.email}
{a.hasPassword ? " · wachtwoord" : ""}
{a.hasSession && a.hasAccessToken ? " · tokens" : ""}
{a.eventCount ? ` · ${a.eventCount} events` : ""}
</span>
</div>
@ -495,7 +510,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
Check
</button>
<button type="button" disabled={busy} onClick={() => openTokenEdit(a)}>
{tokenEditId === a.id ? "Annuleren" : "Tokens vernieuwen"}
{tokenEditId === a.id ? "Annuleren" : "Tokens / wachtwoord"}
</button>
<button type="button" disabled={busy} onClick={() => void toggleAccount(a)}>
{a.enabled ? "Uitschakelen" : "Inschakelen"}
@ -517,8 +532,21 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
}}
>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
Plak verse waarden uit de browser. Lege velden blijven ongewijzigd.
Wachtwoord voor Token Tray (stille login), of verse tokens. Lege velden
blijven ongewijzigd.
{a.hasPassword ? " · wachtwoord staat al opgeslagen" : ""}
</p>
<label className="field">
Wachtwoord
<input
type="password"
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
disabled={busy}
placeholder={a.hasPassword ? "nieuw wachtwoord (optioneel)" : "wachtwoord"}
autoComplete="new-password"
/>
</label>
<label className="field">
session
<textarea
@ -593,6 +621,17 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
autoComplete="username"
/>
</label>
<label className="field">
Wachtwoord (voor Token Tray)
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={busy}
autoComplete="new-password"
placeholder="aanbevolen"
/>
</label>
</div>
<button
@ -601,7 +640,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
style={{ marginTop: 10 }}
onClick={() => setShowCookies((v) => !v)}
>
{showCookies ? "Tokens verbergen" : "Sessie-tokens plakken (vereist)"}
{showCookies ? "Tokens verbergen" : "Sessie-tokens plakken (optioneel)"}
</button>
{showCookies && (

View file

@ -2,6 +2,9 @@ import type { FastifyInstance, FastifyRequest } from "fastify";
import type { Config } from "../config";
import { AppError } from "../security/errors";
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";
async function requireTrayKey(request: FastifyRequest, config: Config) {
const secret = await resolveTokenTraySecret(
@ -21,16 +24,80 @@ async function requireTrayKey(request: FastifyRequest, config: Config) {
}
}
export type AgentAccountRow = {
provider: "viaplay" | "f1tv";
id: string;
email: string;
label: string;
password: string | null;
hasTokens: boolean;
enabled: 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"],
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 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 };
});
app.post("/api/v1/agent/viaplay/tokens", async (request) => {
await requireTrayKey(request, config);
const body = request.body as {
@ -51,4 +118,23 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
persistentLogin: body.persistentLogin,
});
});
app.post("/api/v1/agent/f1tv/tokens", async (request) => {
await requireTrayKey(request, config);
const body = request.body as {
email?: string;
label?: string;
ascendontoken?: string;
entitlementtoken?: string;
sessionId?: string;
};
const { upsertF1tvAccountByEmail } = await import("../f1tv/settings");
return upsertF1tvAccountByEmail(config.SESSION_SECRET, {
email: String(body.email ?? ""),
label: body.label,
ascendontoken: String(body.ascendontoken ?? ""),
entitlementtoken: String(body.entitlementtoken ?? ""),
sessionId: body.sessionId,
});
});
}

View file

@ -150,6 +150,43 @@ export async function addF1tvAccount(
return getF1tvSettingsPublic();
}
/** Tray/agent: tokens bijwerken of account aanmaken op e-mail. */
export async function upsertF1tvAccountByEmail(
sessionSecret: string,
input: {
email: string;
label?: string;
ascendontoken: string;
entitlementtoken: string;
sessionId?: string;
}
) {
const existing = await prisma.integrationSetting.findUnique({ where: { id: F1TV_ID } });
const config = parseConfig(existing?.configJson);
const email = input.email.trim().toLowerCase();
if (!email) throw new AppError("INVALID_REQUEST", "E-mail verplicht", 400);
if (!input.ascendontoken?.trim() || !input.entitlementtoken?.trim()) {
throw new AppError("INVALID_REQUEST", "ascendontoken + entitlementtoken verplicht", 400);
}
const idx = config.accounts.findIndex((a) => a.email === email);
if (idx < 0) {
return addF1tvAccount(sessionSecret, {
email,
label: input.label,
ascendontoken: input.ascendontoken,
entitlementtoken: input.entitlementtoken,
sessionId: input.sessionId,
enabled: true,
});
}
return updateF1tvAccount(sessionSecret, config.accounts[idx]!.id, {
label: input.label,
ascendontoken: input.ascendontoken,
entitlementtoken: input.entitlementtoken,
sessionId: input.sessionId,
});
}
export async function updateF1tvAccount(
sessionSecret: string,
accountId: string,

View file

@ -134,20 +134,21 @@ export function createAccountStored(
?.trim()
.replace(/^(VIAPLAY-AT|MTG-AT|Bearer)\s+/i, "")
.trim();
if (!session || !accessToken) {
throw new Error("session + accessToken verplicht");
const password = input.password?.trim();
if ((!session || !accessToken) && !password) {
throw new Error("Wachtwoord of session+accessToken verplicht");
}
return {
id: input.id?.trim() || randomUUID(),
label: (input.label ?? "").trim() || email,
email,
sessionEnc: encryptSecret(session, sessionSecret),
accessTokenEnc: encryptSecret(accessToken, sessionSecret),
sessionEnc: session ? encryptSecret(session, sessionSecret) : undefined,
accessTokenEnc: accessToken
? encryptSecret(accessToken, sessionSecret)
: undefined,
profileId: input.profileId?.trim() || undefined,
deviceId: input.deviceId?.trim() || randomUUID(),
passwordEnc: input.password?.trim()
? encryptSecret(input.password.trim(), sessionSecret)
: undefined,
passwordEnc: password ? encryptSecret(password, sessionSecret) : undefined,
persistentLoginEnc: input.persistentLogin?.trim()
? encryptSecret(input.persistentLogin.trim(), sessionSecret)
: undefined,

View file

@ -107,7 +107,11 @@ function parseConfig(raw: string | null | undefined): ViaplayConfig {
}
function hasUsableAccount(accounts: ViaplayAccountStored[]): boolean {
return accounts.some((a) => a.enabled !== false && accountHasPlayCookies(a));
return accounts.some(
(a) =>
a.enabled !== false &&
(accountHasPlayCookies(a) || !!a.passwordEnc)
);
}
async function persist(config: ViaplayConfig, enabled: boolean) {

View file

@ -1,45 +1,31 @@
# Media Cluster Token Tray
Windows system-tray helper die Viaplay-sessie-tokens vernieuwt en naar de master-api pusht.
Windows system-tray helper: **stille browser-UI login** voor Viaplay + F1TV (geen HTTP-login → geen cmd-CAPTCHA).
1. Open **Admin → Instellingen → Admin** → sectie **Token Tray**
2. Klik **Genereer nieuwe key** en kopieer de key (+ API URL) naar de tray-app
3. Of zet optioneel `TOKEN_TRAY_SECRET` in `.env` (fallback als er geen DB-key is)
## Admin
Endpoint:
1. **Instellingen → Admin → Token Tray** → genereer key + kopieer API URL
2. **Scripts → Viaplay / F1TV** → account met **e-mail + wachtwoord**
3. Tokens plakken is optioneel (tray vult die)
- `GET /api/v1/agent/status` — header `X-Tray-Key`
- `POST /api/v1/agent/viaplay/tokens` — body: `email`, `session`, `accessToken`, optioneel `profileId` / `label` / `persistentLogin`
## Lokaal starten
PowerShell blokkeert soms `pnpm.ps1`. Gebruik dan **`pnpm.cmd`** (of CMD):
## Tray
```powershell
cd apps\token-tray
pnpm.cmd install
pnpm.cmd exec playwright install chromium
pnpm.cmd run build
pnpm.cmd run start
```
Of eenmalig voor je user-account:
Menu:
```powershell
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
```
- **Stil vernieuwen** — headless Chromium klikt inloggen, pusht tokens
- **Vernieuwen (zichtbaar)** — zelfde flow met venster (debug)
- Automatisch elke **3 uur**
Daarna werkt gewoon `pnpm install` / `pnpm dev` weer.
## API (tray-key)
1. Vul **API URL** + **Tray key** (= `TOKEN_TRAY_SECRET`)
2. Vul het **zelfde e-mailadres** als het Scripts → Viaplay account
3. Tray → **Viaplay inloggen / vernieuwen** (CAPTCHA in het venster)
4. Tray → **Tokens naar server sturen**
Configbestand: `%APPDATA%/token-tray/tray-config.json` (Electron `userData`).
## MVP-scope
- Alleen Viaplay
- Browserprofiel per account (cookies blijven lokaal)
- Handmatige push na login + JWT-expiry waarschuwing in tooltip/notificatie
- Nog geen Odido/F1, nog geen stille background-refresh zonder browser
- `GET /api/v1/agent/accounts` — accounts + wachtwoorden
- `POST /api/v1/agent/viaplay/tokens`
- `POST /api/v1/agent/f1tv/tokens`

View file

@ -2,15 +2,18 @@
"name": "@media-cluster/token-tray",
"version": "0.1.0",
"private": true,
"description": "Windows system tray helper to refresh Viaplay session tokens",
"description": "Windows system tray helper to refresh Viaplay/F1TV session tokens",
"main": "dist/main.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json && electron .",
"start": "electron .",
"postinstall": "node -e \"try{require('child_process').execSync('playwright install chromium',{stdio:'inherit'})}catch(e){console.warn('playwright chromium download skipped — tray gebruikt system Chrome/Edge')}\"",
"pack:win": "npm run build && electron-builder --win dir"
},
"dependencies": {},
"dependencies": {
"playwright": "^1.49.1"
},
"devDependencies": {
"@types/node": "^22.10.7",
"electron": "^33.2.1",

150
apps/token-tray/src/jobs.ts Normal file
View file

@ -0,0 +1,150 @@
import { Notification } from "electron";
import type { TrayConfig } from "./config";
import { saveConfig } from "./config";
import { loginViaplayUi } from "./login/viaplay";
import { loginF1tvUi } from "./login/f1tv";
export type AgentAccount = {
provider: "viaplay" | "f1tv";
id: string;
email: string;
label: string;
password: string | null;
hasTokens: boolean;
enabled: boolean;
};
export async function fetchAgentAccounts(cfg: TrayConfig): Promise<AgentAccount[]> {
const res = await fetch(`${cfg.apiUrl}/api/v1/agent/accounts`, {
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 ?? [];
}
async function pushViaplay(
cfg: TrayConfig,
email: string,
label: string,
tokens: {
session: string;
accessToken: string;
profileId: string | null;
persistentLogin: string | null;
}
) {
const res = await fetch(`${cfg.apiUrl}/api/v1/agent/viaplay/tokens`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Tray-Key": cfg.trayKey,
},
body: JSON.stringify({
email,
label,
session: tokens.session,
accessToken: tokens.accessToken,
profileId: tokens.profileId || undefined,
persistentLogin: tokens.persistentLogin || undefined,
}),
});
const data = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
if (!res.ok) throw new Error(data.error?.message || `HTTP ${res.status}`);
}
async function pushF1tv(
cfg: TrayConfig,
email: string,
label: string,
tokens: {
ascendontoken: string;
entitlementtoken: string;
sessionId: string | null;
}
) {
const res = await fetch(`${cfg.apiUrl}/api/v1/agent/f1tv/tokens`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Tray-Key": cfg.trayKey,
},
body: JSON.stringify({
email,
label,
ascendontoken: tokens.ascendontoken,
entitlementtoken: tokens.entitlementtoken,
sessionId: tokens.sessionId || undefined,
}),
});
const data = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
if (!res.ok) throw new Error(data.error?.message || `HTTP ${res.status}`);
}
export type RefreshSummary = {
ok: number;
failed: number;
skipped: number;
errors: string[];
};
/** Headless UI-login voor alle accounts met wachtwoord. */
export async function refreshAllSilent(
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 {
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);
}
summary.ok += 1;
} catch (err) {
summary.failed += 1;
summary.errors.push(
`${acc.provider}:${acc.email}: ${err instanceof Error ? err.message : String(err)}`
);
}
}
cfg.lastPushAt = new Date().toISOString();
cfg.lastPushSummary = `Stil: ${summary.ok} ok · ${summary.failed} fout · ${summary.skipped} overgeslagen`;
cfg.lastError = summary.errors[0];
saveConfig(cfg);
if (Notification.isSupported()) {
new Notification({
title: "Token Tray",
body: cfg.lastPushSummary,
}).show();
}
return summary;
}

View file

@ -0,0 +1,137 @@
import { chromium, type BrowserContext } from "playwright";
import path from "node:path";
import { app } from "electron";
export type F1tvLoginResult = {
ascendontoken: string;
entitlementtoken: string;
sessionId: string | null;
};
function profileDir(accountId: string): string {
return path.join(app.getPath("userData"), "browser-profiles", "f1tv", accountId);
}
async function readTokens(context: BrowserContext): Promise<F1tvLoginResult | null> {
const cookies = await context.cookies();
const storage = await context.storageState();
const byName = new Map(cookies.map((c) => [c.name.toLowerCase(), c.value]));
let ascendontoken =
byName.get("ascendontoken") ||
byName.get("ascendon_token") ||
byName.get("login-session") ||
"";
let entitlementtoken =
byName.get("entitlementtoken") || byName.get("entitlement_token") || "";
let sessionId = byName.get("sessionid") || byName.get("session-id") || "";
// localStorage / sessionStorage vaak JWT's
for (const origin of storage.origins || []) {
for (const item of origin.localStorage || []) {
const k = item.name.toLowerCase();
const v = item.value?.trim() || "";
if (!v) continue;
if (!ascendontoken && /ascendon|login.?session|auth/i.test(k) && v.startsWith("eyJ")) {
ascendontoken = v;
}
if (!entitlementtoken && /entitlement/i.test(k) && v.startsWith("eyJ")) {
entitlementtoken = v;
}
if (!sessionId && /sessionid|session.?id/i.test(k)) {
sessionId = v;
}
}
}
ascendontoken = ascendontoken.replace(/^Bearer\s+/i, "").trim();
entitlementtoken = entitlementtoken.replace(/^Bearer\s+/i, "").trim();
if (!ascendontoken || !entitlementtoken) return null;
return {
ascendontoken,
entitlementtoken,
sessionId: sessionId.trim() || null,
};
}
async function launchProfile(userDataDir: string, headless: boolean) {
const common = {
headless,
viewport: { width: 1280, height: 800 } as const,
args: ["--disable-blink-features=AutomationControlled"],
};
for (const channel of ["chrome", "msedge"] as const) {
try {
return await chromium.launchPersistentContext(userDataDir, {
...common,
channel,
locale: "en-GB",
});
} catch {
/* try next */
}
}
return chromium.launchPersistentContext(userDataDir, {
...common,
locale: "en-GB",
});
}
export async function loginF1tvUi(opts: {
accountId: string;
email: string;
password: string;
headless?: boolean;
}): Promise<F1tvLoginResult> {
const userDataDir = profileDir(opts.accountId);
const context = await launchProfile(userDataDir, opts.headless !== false);
try {
const page = context.pages()[0] || (await context.newPage());
await page.goto("https://account.formula1.com/#/en/login", {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
await new Promise((r) => setTimeout(r, 2000));
let tokens = await readTokens(context);
if (tokens) return tokens;
const emailBox = page
.locator('input[type="email"]')
.or(page.getByLabel(/e-?mail/i))
.or(page.locator("#LoginEmailAddress, #Email, input[name*=email i]"))
.first();
await emailBox.waitFor({ state: "visible", timeout: 45_000 });
await emailBox.fill(opts.email);
const passBox = page.locator('input[type="password"]').first();
await passBox.fill(opts.password);
const submit = page
.getByRole("button", { name: /log\s*in|sign\s*in|continue/i })
.or(page.locator('button[type="submit"]'))
.first();
await submit.click();
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000));
if (!page.url().includes("f1tv") && Date.now() > deadline - 60_000) {
await page
.goto("https://f1tv.formula1.com/", {
waitUntil: "domcontentloaded",
timeout: 45_000,
})
.catch(() => undefined);
}
tokens = await readTokens(context);
if (tokens) return tokens;
}
throw new Error(
"Geen F1TV-tokens na UI-login — check wachtwoord of pas selectors aan"
);
} finally {
await context.close();
}
}

View file

@ -0,0 +1,128 @@
import { chromium, type BrowserContext, type Page } from "playwright";
import path from "node:path";
import { app } from "electron";
export type ViaplayLoginResult = {
session: string;
accessToken: string;
profileId: string | null;
persistentLogin: string | null;
};
function profileDir(accountId: string): string {
return path.join(app.getPath("userData"), "browser-profiles", "viaplay", accountId);
}
async function fillLogin(page: Page, email: string, password: string) {
// Semantic selectors — werkt op account.viaplay.com / viaplay.com login
const emailBox = page
.locator('input[type="email"]')
.or(page.getByLabel(/e-?mail/i))
.or(page.getByPlaceholder(/e-?mail/i))
.first();
await emailBox.waitFor({ state: "visible", timeout: 30_000 });
await emailBox.fill(email);
const passBox = page.locator('input[type="password"]').first();
await passBox.waitFor({ state: "visible", timeout: 15_000 });
await passBox.fill(password);
const submit = page
.getByRole("button", { name: /aanmelden|log\s*in|sign\s*in|inloggen/i })
.or(page.locator('button[type="submit"]'))
.first();
await submit.click();
}
async function readCookies(context: BrowserContext): Promise<ViaplayLoginResult | null> {
const cookies = await context.cookies();
const byName = new Map(cookies.map((c) => [c.name, c.value]));
const session = (byName.get("session") || "").trim();
const accessToken = (byName.get("accessToken") || "")
.trim()
.replace(/^(VIAPLAY-AT|MTG-AT|Bearer)\s+/i, "");
const profileId = (byName.get("viaplay_profileId") || "").trim() || null;
const persistentLogin = (byName.get("persistentLogin") || "").trim() || null;
if (!session || !accessToken) return null;
return { session, accessToken, profileId, persistentLogin };
}
async function launchProfile(userDataDir: string, headless: boolean) {
const common = {
headless,
viewport: { width: 1280, height: 800 } as const,
args: ["--disable-blink-features=AutomationControlled"],
};
// Voorkeur: geïnstalleerde Chrome/Edge (geen Playwright-download nodig)
for (const channel of ["chrome", "msedge"] as const) {
try {
return await chromium.launchPersistentContext(userDataDir, {
...common,
channel,
locale: "nl-NL",
});
} catch {
/* try next */
}
}
return chromium.launchPersistentContext(userDataDir, {
...common,
locale: "nl-NL",
});
}
/**
* Stille UI-login in echte Chromium (geen HTTP login-API → geen cmd-CAPTCHA).
* Hergebruikt persistent profiel per account.
*/
export async function loginViaplayUi(opts: {
accountId: string;
email: string;
password: string;
headless?: boolean;
}): Promise<ViaplayLoginResult> {
const userDataDir = profileDir(opts.accountId);
const context = await launchProfile(userDataDir, opts.headless !== false);
try {
const page = context.pages()[0] || (await context.newPage());
// Snel pad: al cookies?
await page.goto("https://viaplay.com/nl-nl", {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
let tokens = await readCookies(context);
if (tokens) return tokens;
await page.goto("https://viaplay.com/nl-nl/login", {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
// Soms redirect naar account.viaplay.com
await new Promise((r) => setTimeout(r, 1500));
await fillLogin(page, opts.email, opts.password);
// Wacht tot session cookie verschijnt
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 1500));
tokens = await readCookies(context);
if (tokens) return tokens;
const profileBtn = page
.locator('[data-testid*="profile"], button')
.filter({ hasText: /./ })
.first();
try {
if (await profileBtn.isVisible({ timeout: 500 })) {
await profileBtn.click().catch(() => undefined);
}
} catch {
/* ignore */
}
}
throw new Error("Geen session/accessToken na UI-login — check wachtwoord of pagina-wijziging");
} finally {
await context.close();
}
}

View file

@ -2,37 +2,24 @@ import {
app,
BrowserWindow,
Menu,
Notification,
Tray,
dialog,
nativeImage,
shell,
} from "electron";
import path from "node:path";
import { configPath, loadConfig, saveConfig, type TrayAccount, type TrayConfig } from "./config";
import { captureViaplayTokens, pingAgent, pushViaplayTokens } from "./viaplay";
import { configPath, loadConfig, saveConfig, type TrayConfig } from "./config";
import { pingAgent } from "./viaplay";
import { refreshAllSilent } from "./jobs";
let tray: Tray | null = null;
let settingsWin: BrowserWindow | null = null;
let loginWin: BrowserWindow | null = null;
let cfg: TrayConfig = loadConfig();
let watchTimer: ReturnType<typeof setInterval> | null = null;
function ensureAccount(): TrayAccount {
if (cfg.accounts[0]) return cfg.accounts[0]!;
const email = "viaplay@local";
const account: TrayAccount = {
email,
label: "Viaplay",
partition: `vp-${Date.now().toString(36)}`,
};
cfg.accounts = [account];
saveConfig(cfg);
return account;
}
let busy = false;
function statusTooltip(): string {
const parts = ["Media Cluster Token Tray"];
if (busy) parts.push("Bezig met stille refresh…");
if (cfg.lastPushSummary) parts.push(cfg.lastPushSummary);
if (cfg.lastError) parts.push(`Fout: ${cfg.lastError}`);
if (cfg.lastPushAt) {
@ -43,10 +30,17 @@ function statusTooltip(): string {
function rebuildMenu() {
if (!tray) return;
const account = ensureAccount();
const menu = Menu.buildFromTemplate([
{ label: "Viaplay inloggen / vernieuwen", click: () => void openLoginWindow(account) },
{ label: "Tokens naar server sturen", click: () => void pushNow(account) },
{
label: busy ? "Bezig…" : "Stil vernieuwen (alle accounts)",
enabled: !busy,
click: () => void runSilent(true),
},
{
label: "Vernieuwen (zichtbaar venster)",
enabled: !busy,
click: () => void runSilent(false),
},
{ type: "separator" },
{ label: "Verbinding testen", click: () => void testConnection() },
{ label: "Instellingen…", click: () => openSettings() },
@ -68,12 +62,11 @@ function openSettings() {
}
settingsWin = new BrowserWindow({
width: 520,
height: 420,
height: 360,
title: "Token Tray — instellingen",
autoHideMenuBar: true,
webPreferences: { contextIsolation: true, nodeIntegration: false },
});
const account = ensureAccount();
const html = `<!doctype html>
<html lang="nl"><head><meta charset="utf-8"/><title>Instellingen</title>
<style>
@ -85,23 +78,17 @@ function openSettings() {
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>API-URL van je master-api + <code>TOKEN_TRAY_SECRET</code> (zelfde waarde als op de server).</p>
<p>Accounts + wachtwoorden komen uit Admin (Scripts). Hier alleen API-URL + tray-key.</p>
<label>API URL</label>
<input id="apiUrl" value="${escapeHtml(cfg.apiUrl)}" />
<label>Tray key (X-Tray-Key)</label>
<input id="trayKey" value="${escapeHtml(cfg.trayKey)}" />
<label>Viaplay e-mail (account-id op de server)</label>
<input id="email" value="${escapeHtml(account.email)}" />
<label>Label</label>
<input id="label" value="${escapeHtml(account.label)}" />
<button id="save">Opslaan</button>
<script>
document.getElementById('save').onclick = () => {
const payload = {
apiUrl: document.getElementById('apiUrl').value,
trayKey: document.getElementById('trayKey').value,
email: document.getElementById('email').value,
label: document.getElementById('label').value,
};
document.title = 'SAVE:' + encodeURIComponent(JSON.stringify(payload));
};
@ -115,21 +102,15 @@ function openSettings() {
const payload = JSON.parse(decodeURIComponent(title.slice(5))) as {
apiUrl: string;
trayKey: string;
email: string;
label: string;
};
const acc = ensureAccount();
cfg.apiUrl = payload.apiUrl.trim().replace(/\/$/, "") || cfg.apiUrl;
cfg.trayKey = payload.trayKey.trim();
acc.email = payload.email.trim().toLowerCase() || acc.email;
acc.label = payload.label.trim() || acc.label;
cfg.accounts = [acc];
saveConfig(cfg);
rebuildMenu();
void dialog.showMessageBox({
type: "info",
message: "Instellingen opgeslagen",
detail: `Config: ${configPath()}`,
detail: `Accounts met wachtwoord staan in Admin → Scripts (Viaplay / F1TV).`,
});
settingsWin?.close();
} catch (err) {
@ -149,68 +130,30 @@ function escapeHtml(s: string): string {
.replace(/'/g, "&#39;");
}
async function openLoginWindow(account: TrayAccount) {
if (loginWin && !loginWin.isDestroyed()) {
loginWin.focus();
return;
}
loginWin = new BrowserWindow({
width: 1100,
height: 800,
title: `Viaplay — ${account.email}`,
autoHideMenuBar: true,
webPreferences: {
partition: `persist:${account.partition}`,
contextIsolation: true,
nodeIntegration: false,
},
});
await loginWin.loadURL("https://viaplay.com/nl-nl/login");
loginWin.on("closed", () => {
loginWin = null;
});
void dialog.showMessageBox(loginWin, {
type: "info",
message: "Log in bij Viaplay",
detail:
"Na een geslaagde login (CAPTCHA oké): kies in het tray-menu “Tokens naar server sturen”. Cookies blijven bewaard in dit browserprofiel.",
});
}
async function pushNow(account: TrayAccount) {
async function runSilent(headless: boolean) {
if (busy) return;
if (!cfg.trayKey.trim()) {
openSettings();
void dialog.showErrorBox(
"Tray key ontbreekt",
"Stel eerst API-URL en TOKEN_TRAY_SECRET in (Instellingen)."
);
return;
}
busy = true;
rebuildMenu();
try {
const tokens = await captureViaplayTokens(account.partition);
if (!tokens) {
throw new Error(
"Geen session/accessToken in browserprofiel — open eerst “Viaplay inloggen” en log in."
);
}
const result = await pushViaplayTokens(cfg, account, tokens);
cfg.lastPushAt = new Date().toISOString();
cfg.lastPushSummary = result.summary;
cfg.lastError = undefined;
saveConfig(cfg);
rebuildMenu();
if (Notification.isSupported()) {
new Notification({
title: "Viaplay tokens",
body: result.summary,
}).show();
}
const summary = await refreshAllSilent(cfg, { headless });
cfg = loadConfig();
void dialog.showMessageBox({
type: summary.failed ? "warning" : "info",
message: "Refresh klaar",
detail: [
`${summary.ok} ok · ${summary.failed} fout · ${summary.skipped} zonder wachtwoord`,
...summary.errors.slice(0, 5),
].join("\n"),
});
} catch (err) {
cfg.lastError = err instanceof Error ? err.message : String(err);
saveConfig(cfg);
void dialog.showErrorBox("Refresh mislukt", String(err));
} finally {
busy = false;
rebuildMenu();
void dialog.showErrorBox("Push mislukt", cfg.lastError);
}
}
@ -231,54 +174,26 @@ async function testConnection() {
}
}
async function watchExpiry() {
const account = ensureAccount();
try {
const tokens = await captureViaplayTokens(account.partition);
if (!tokens?.accessTokenExpMs) return;
const leftMs = tokens.accessTokenExpMs - Date.now();
if (leftMs < 0) {
tray?.setToolTip(`Token verlopen — vernieuw Viaplay-login\n${statusTooltip()}`);
return;
}
if (leftMs < 60 * 60 * 1000) {
const mins = Math.max(1, Math.round(leftMs / 60_000));
tray?.setToolTip(`Token verloopt over ~${mins} min\n${statusTooltip()}`);
if (Notification.isSupported() && leftMs < 45 * 60 * 1000) {
new Notification({
title: "Viaplay token bijna verlopen",
body: `Nog ~${mins} minuten — open tray → inloggen / vernieuwen`,
}).show();
}
}
} catch {
/* ignore */
}
}
function createTray() {
// 16x16 simple blue square PNG as data URL fallback
const png = nativeImage.createFromDataURL(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAALElEQVQ4T2NkYGD4z0ABYBzVMKoBQw0YNYPRDWBgYGBgZGBg+M8w6gYGBgYGABv0AQX1mVYpAAAAAElFTkSuQmCC"
);
tray = new Tray(png.isEmpty() ? nativeImage.createEmpty() : png);
tray.setToolTip("Media Cluster Token Tray");
rebuildMenu();
tray.on("double-click", () => void openLoginWindow(ensureAccount()));
tray.on("double-click", () => void runSilent(true));
}
app.whenReady().then(() => {
cfg = loadConfig();
createTray();
if (!cfg.trayKey || !cfg.accounts[0] || cfg.accounts[0].email === "viaplay@local") {
openSettings();
}
watchTimer = setInterval(() => void watchExpiry(), 5 * 60_000);
void watchExpiry();
if (!cfg.trayKey) openSettings();
// Elke 3 uur stil proberen (alleen accounts mét wachtwoord)
watchTimer = setInterval(() => void runSilent(true), 3 * 60 * 60 * 1000);
});
app.on("window-all-closed", () => {
// Tray blijft actief zolang het proces draait
// Tray blijft actief
});
app.on("before-quit", () => {