Add Windows token-tray MVP for Viaplay session refresh.

Agent API accepts X-Tray-Key pushes; Electron tray captures cookies and upserts accounts by email.
This commit is contained in:
Jos Vooges | STH 2026-09-22 23:24:44 +02:00
parent 0424ec4a10
commit 22d46ffac7
16 changed files with 3374 additions and 22 deletions

2
.gitignore vendored
View file

@ -16,6 +16,8 @@ node/media-node/media-node
node/media-node/media-node.exe
apps/admin-ui/.next/
apps/master-api/dist/
apps/token-tray/dist/
apps/token-tray/release/
packages/*/dist/
apps/android-tv/apk-patch/

View file

@ -398,7 +398,8 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
<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).
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.
Agenda: max 3 dagen vooruit (vervangt Viaplay uit de externe events-feed). Manifests en
licenties cache-first (±5 min vóór start).
</p>

View file

@ -12,6 +12,9 @@ ADMIN_PASSWORD=changeme123
PUBLIC_URL=http://localhost:3000
# Windows token-tray agent (X-Tray-Key). Leeg = agent-API uit.
# TOKEN_TRAY_SECRET=change-me-at-least-16-chars
TMDB_API_KEY=
# OMDb / IMDb — optionele env-fallback; liever Admin → Instellingen → OMDb (versleuteld in DB)
OMDB_API_KEY=

View file

@ -0,0 +1,50 @@
import type { FastifyInstance, FastifyRequest } from "fastify";
import type { Config } from "../config";
import { AppError } from "../security/errors";
function requireTrayKey(request: FastifyRequest, config: Config) {
const secret = config.TOKEN_TRAY_SECRET?.trim();
if (!secret) {
throw new AppError(
"FORBIDDEN",
"Token-tray is niet geconfigureerd (TOKEN_TRAY_SECRET ontbreekt)",
403
);
}
const header = String(request.headers["x-tray-key"] ?? "").trim();
if (!header || header !== secret) {
throw new AppError("UNAUTHORIZED", "Ongeldige X-Tray-Key", 401);
}
}
export function registerAgentRoutes(app: FastifyInstance, config: Config) {
app.get("/api/v1/agent/status", async (request) => {
requireTrayKey(request, config);
return {
ok: true,
trayEnabled: true,
providers: ["viaplay"],
};
});
app.post("/api/v1/agent/viaplay/tokens", async (request) => {
requireTrayKey(request, config);
const body = request.body as {
email?: string;
label?: string;
session?: string;
accessToken?: string;
profileId?: string;
persistentLogin?: string;
};
const { upsertViaplayAccountByEmail } = await import("../viaplay/settings");
return upsertViaplayAccountByEmail(config.SESSION_SECRET, {
email: String(body.email ?? ""),
label: body.label,
session: String(body.session ?? ""),
accessToken: String(body.accessToken ?? ""),
profileId: body.profileId,
persistentLogin: body.persistentLogin,
});
});
}

View file

@ -106,6 +106,8 @@ async function main() {
registerViewerRoutes(app, config, downloads);
const googlePlay = registerGooglePlayRoutes(app, config);
registerSettingsRoutes(app, config, googlePlay);
const { registerAgentRoutes } = await import("./agent/routes");
registerAgentRoutes(app, config);
startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS);
startIptvEpgWarmup(config);
startOdidoMpdRefreshWorker(() => config.SESSION_SECRET);

View file

@ -43,6 +43,14 @@ const configSchema = z.object({
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
z.string().url().optional()
),
/**
* Shared secret for the Windows token-tray agent.
* Header: X-Tray-Key. Empty/unset = agent endpoints disabled.
*/
TOKEN_TRAY_SECRET: z.preprocess(
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
z.string().min(16).optional()
),
});
export type Config = z.infer<typeof configSchema>;

View file

@ -220,6 +220,48 @@ export async function addViaplayAccount(
return probeViaplayAccounts(sessionSecret, account.id);
}
/** Tray/agent: voeg toe of vernieuw tokens op e-mail (case-insensitive). */
export async function upsertViaplayAccountByEmail(
sessionSecret: string,
input: {
email: string;
label?: string;
session: string;
accessToken: string;
profileId?: string;
persistentLogin?: string;
}
) {
const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_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.session?.trim() || !input.accessToken?.trim()) {
throw new AppError("INVALID_REQUEST", "session + accessToken verplicht", 400);
}
const idx = config.accounts.findIndex((a) => a.email === email);
if (idx < 0) {
return addViaplayAccount(sessionSecret, {
email,
label: input.label,
session: input.session,
accessToken: input.accessToken,
profileId: input.profileId,
persistentLogin: input.persistentLogin,
enabled: true,
});
}
return updateViaplayAccount(sessionSecret, config.accounts[idx]!.id, {
label: input.label,
session: input.session,
accessToken: input.accessToken,
profileId: input.profileId,
persistentLogin: input.persistentLogin,
});
}
export async function updateViaplayAccount(
sessionSecret: string,
accountId: string,

38
apps/token-tray/README.md Normal file
View file

@ -0,0 +1,38 @@
# Media Cluster Token Tray
Windows system-tray helper die Viaplay-sessie-tokens vernieuwt en naar de master-api pusht.
## Server
Zet in master-api `.env`:
```env
TOKEN_TRAY_SECRET=minstens-16-willekeurige-tekens
```
Endpoint:
- `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
```bash
cd apps/token-tray
pnpm install
pnpm dev
```
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

View file

@ -0,0 +1,35 @@
{
"name": "@media-cluster/token-tray",
"version": "0.1.0",
"private": true,
"description": "Windows system tray helper to refresh Viaplay session tokens",
"main": "dist/main.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json && electron .",
"start": "electron .",
"pack:win": "npm run build && electron-builder --win dir"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^22.10.7",
"electron": "^33.2.1",
"electron-builder": "^25.1.8",
"typescript": "^5.7.3"
},
"build": {
"appId": "nl.vonas.mediacluster.token-tray",
"productName": "Media Cluster Token Tray",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"package.json"
],
"win": {
"target": ["dir"],
"signAndEditExecutable": false
}
}
}

View file

@ -0,0 +1,64 @@
import fs from "node:fs";
import path from "node:path";
import { app } from "electron";
export type TrayAccount = {
email: string;
label: string;
/** Electron session partition name — one browser profile per account */
partition: string;
};
export type TrayConfig = {
apiUrl: string;
trayKey: string;
accounts: TrayAccount[];
lastPushAt?: string;
lastPushSummary?: string;
lastError?: string;
};
const DEFAULT_CONFIG: TrayConfig = {
apiUrl: "http://localhost:3000",
trayKey: "",
accounts: [],
};
export function configPath(): string {
return path.join(app.getPath("userData"), "tray-config.json");
}
export function loadConfig(): TrayConfig {
const file = configPath();
try {
if (!fs.existsSync(file)) return { ...DEFAULT_CONFIG, accounts: [] };
const raw = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<TrayConfig>;
return {
apiUrl: String(raw.apiUrl ?? DEFAULT_CONFIG.apiUrl).replace(/\/$/, ""),
trayKey: String(raw.trayKey ?? ""),
accounts: Array.isArray(raw.accounts)
? raw.accounts
.map((a) => ({
email: String(a.email ?? "")
.trim()
.toLowerCase(),
label: String(a.label ?? "").trim(),
partition: String(a.partition ?? "").trim() || `vp-${Math.random().toString(36).slice(2, 10)}`,
}))
.filter((a) => a.email)
: [],
lastPushAt: typeof raw.lastPushAt === "string" ? raw.lastPushAt : undefined,
lastPushSummary:
typeof raw.lastPushSummary === "string" ? raw.lastPushSummary : undefined,
lastError: typeof raw.lastError === "string" ? raw.lastError : undefined,
};
} catch {
return { ...DEFAULT_CONFIG, accounts: [] };
}
}
export function saveConfig(cfg: TrayConfig): void {
const file = configPath();
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(cfg, null, 2), "utf8");
}

286
apps/token-tray/src/main.ts Normal file
View file

@ -0,0 +1,286 @@
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";
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;
}
function statusTooltip(): string {
const parts = ["Media Cluster Token Tray"];
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");
}
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) },
{ type: "separator" },
{ label: "Verbinding testen", click: () => void testConnection() },
{ label: "Instellingen…", click: () => openSettings() },
{
label: "Configmap openen",
click: () => void shell.showItemInFolder(configPath()),
},
{ type: "separator" },
{ label: "Afsluiten", click: () => app.quit() },
]);
tray.setContextMenu(menu);
tray.setToolTip(statusTooltip());
}
function openSettings() {
if (settingsWin && !settingsWin.isDestroyed()) {
settingsWin.focus();
return;
}
settingsWin = new BrowserWindow({
width: 520,
height: 420,
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>
body{font-family:Segoe UI,system-ui,sans-serif;margin:1.25rem;background:#12141a;color:#e8eaed}
label{display:block;margin:0.75rem 0 0.25rem;font-size:0.85rem;opacity:.85}
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>API-URL van je master-api + <code>TOKEN_TRAY_SECRET</code> (zelfde waarde als op de server).</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));
};
</script>
</body></html>`;
settingsWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
settingsWin.on("page-title-updated", (e, title) => {
if (!title.startsWith("SAVE:")) return;
e.preventDefault();
try {
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()}`,
});
settingsWin?.close();
} catch (err) {
void dialog.showErrorBox("Opslaan mislukt", String(err));
}
});
settingsWin.on("closed", () => {
settingsWin = null;
});
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/"/g, "&quot;")
.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) {
if (!cfg.trayKey.trim()) {
openSettings();
void dialog.showErrorBox(
"Tray key ontbreekt",
"Stel eerst API-URL en TOKEN_TRAY_SECRET in (Instellingen)."
);
return;
}
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();
}
} catch (err) {
cfg.lastError = err instanceof Error ? err.message : String(err);
saveConfig(cfg);
rebuildMenu();
void dialog.showErrorBox("Push mislukt", cfg.lastError);
}
}
async function testConnection() {
if (!cfg.trayKey.trim()) {
openSettings();
return;
}
try {
const ok = await pingAgent(cfg);
void dialog.showMessageBox({
type: ok ? "info" : "warning",
message: ok ? "Verbinding OK" : "Geen geldige tray-key of API",
detail: cfg.apiUrl,
});
} catch (err) {
void dialog.showErrorBox("Verbinding mislukt", String(err));
}
}
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()));
}
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();
});
app.on("window-all-closed", () => {
// Tray blijft actief zolang het proces draait
});
app.on("before-quit", () => {
if (watchTimer) clearInterval(watchTimer);
});

View file

@ -0,0 +1,118 @@
import { session as electronSession } from "electron";
import type { TrayAccount, TrayConfig } from "./config";
export type CapturedTokens = {
session: string;
accessToken: string;
profileId: string | null;
persistentLogin: string | null;
accessTokenExpMs: number | null;
};
const COOKIE_DOMAINS = [
"viaplay.com",
".viaplay.com",
"login.viaplay.com",
"account.mtg-api.com",
".mtg-api.com",
];
function jwtExpMs(token: string): number | null {
try {
const payload = JSON.parse(
Buffer.from(token.split(".")[1]!.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString()
) as { exp?: number };
return payload.exp ? payload.exp * 1000 : null;
} catch {
return null;
}
}
export async function captureViaplayTokens(partition: string): Promise<CapturedTokens | null> {
const ses = electronSession.fromPartition(`persist:${partition}`);
const byName = new Map<string, string>();
for (const domain of COOKIE_DOMAINS) {
try {
const cookies = await ses.cookies.get({ domain });
for (const c of cookies) {
if (c.name && c.value) byName.set(c.name, c.value);
}
} catch {
/* ignore domain miss */
}
}
// Also scan without domain filter
try {
const all = await ses.cookies.get({});
for (const c of all) {
if (!c.name || !c.value) continue;
if (
/viaplay|mtg-api|login\.viaplay/i.test(c.domain || "") ||
["session", "accessToken", "viaplay_profileId", "persistentLogin"].includes(c.name)
) {
byName.set(c.name, c.value);
}
}
} catch {
/* ignore */
}
const sessionVal = 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 (!sessionVal || !accessToken) return null;
return {
session: sessionVal,
accessToken,
profileId,
persistentLogin,
accessTokenExpMs: jwtExpMs(accessToken),
};
}
export async function pushViaplayTokens(
cfg: TrayConfig,
account: TrayAccount,
tokens: CapturedTokens
): Promise<{ summary: string; subscription?: string }> {
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: account.email,
label: account.label || undefined,
session: tokens.session,
accessToken: tokens.accessToken,
profileId: tokens.profileId || undefined,
persistentLogin: tokens.persistentLogin || undefined,
}),
});
const data = (await res.json().catch(() => ({}))) as {
error?: { message?: string };
accounts?: Array<{ email?: string; subscriptionSummary?: string | null }>;
};
if (!res.ok) {
throw new Error(data.error?.message || `HTTP ${res.status}`);
}
const match = data.accounts?.find((a) => a.email === account.email);
const sub = match?.subscriptionSummary || undefined;
return {
summary: sub ? `ok · ${sub}` : "ok · tokens geüpdatet",
subscription: sub,
};
}
export async function pingAgent(cfg: TrayConfig): Promise<boolean> {
const res = await fetch(`${cfg.apiUrl}/api/v1/agent/status`, {
headers: { "X-Tray-Key": cfg.trayKey },
});
return res.ok;
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*"]
}

View file

@ -13,6 +13,9 @@ ADMIN_PASSWORD=changeme123
PUBLIC_URL=https://master.media.example.com
# Windows token-tray (X-Tray-Key). Leeg = agent-API uitgeschakeld.
# TOKEN_TRAY_SECRET=change-me-at-least-16-chars
# Optional TMDB / OMDb (OMDb preferred via Admin → Settings → OMDb)
TMDB_API_KEY=
OMDB_API_KEY=

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@ allowBuilds:
'@prisma/client': true
'@prisma/engines': true
argon2: true
electron: true
esbuild: true
prisma: true
sharp: true