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:
parent
0424ec4a10
commit
22d46ffac7
16 changed files with 3374 additions and 22 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -16,6 +16,8 @@ node/media-node/media-node
|
||||||
node/media-node/media-node.exe
|
node/media-node/media-node.exe
|
||||||
apps/admin-ui/.next/
|
apps/admin-ui/.next/
|
||||||
apps/master-api/dist/
|
apps/master-api/dist/
|
||||||
|
apps/token-tray/dist/
|
||||||
|
apps/token-tray/release/
|
||||||
packages/*/dist/
|
packages/*/dist/
|
||||||
|
|
||||||
apps/android-tv/apk-patch/
|
apps/android-tv/apk-patch/
|
||||||
|
|
|
||||||
|
|
@ -398,7 +398,8 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
|
||||||
<h2 style={{ marginTop: 0 }}>Viaplay</h2>
|
<h2 style={{ marginTop: 0 }}>Viaplay</h2>
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
Sportagenda en live streams voor gekoppelde Viaplay-accounts. Voeg een account toe met
|
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
|
Agenda: max 3 dagen vooruit (vervangt Viaplay uit de externe events-feed). Manifests en
|
||||||
licenties cache-first (±5 min vóór start).
|
licenties cache-first (±5 min vóór start).
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ ADMIN_PASSWORD=changeme123
|
||||||
|
|
||||||
PUBLIC_URL=http://localhost:3000
|
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=
|
TMDB_API_KEY=
|
||||||
# OMDb / IMDb — optionele env-fallback; liever Admin → Instellingen → OMDb (versleuteld in DB)
|
# OMDb / IMDb — optionele env-fallback; liever Admin → Instellingen → OMDb (versleuteld in DB)
|
||||||
OMDB_API_KEY=
|
OMDB_API_KEY=
|
||||||
|
|
|
||||||
50
apps/master-api/src/agent/routes.ts
Normal file
50
apps/master-api/src/agent/routes.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -106,6 +106,8 @@ async function main() {
|
||||||
registerViewerRoutes(app, config, downloads);
|
registerViewerRoutes(app, config, downloads);
|
||||||
const googlePlay = registerGooglePlayRoutes(app, config);
|
const googlePlay = registerGooglePlayRoutes(app, config);
|
||||||
registerSettingsRoutes(app, config, googlePlay);
|
registerSettingsRoutes(app, config, googlePlay);
|
||||||
|
const { registerAgentRoutes } = await import("./agent/routes");
|
||||||
|
registerAgentRoutes(app, config);
|
||||||
startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS);
|
startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS);
|
||||||
startIptvEpgWarmup(config);
|
startIptvEpgWarmup(config);
|
||||||
startOdidoMpdRefreshWorker(() => config.SESSION_SECRET);
|
startOdidoMpdRefreshWorker(() => config.SESSION_SECRET);
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,14 @@ const configSchema = z.object({
|
||||||
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
|
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
|
||||||
z.string().url().optional()
|
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>;
|
export type Config = z.infer<typeof configSchema>;
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,48 @@ export async function addViaplayAccount(
|
||||||
return probeViaplayAccounts(sessionSecret, account.id);
|
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(
|
export async function updateViaplayAccount(
|
||||||
sessionSecret: string,
|
sessionSecret: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
|
|
|
||||||
38
apps/token-tray/README.md
Normal file
38
apps/token-tray/README.md
Normal 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
|
||||||
35
apps/token-tray/package.json
Normal file
35
apps/token-tray/package.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
apps/token-tray/src/config.ts
Normal file
64
apps/token-tray/src/config.ts
Normal 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
286
apps/token-tray/src/main.ts
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
118
apps/token-tray/src/viaplay.ts
Normal file
118
apps/token-tray/src/viaplay.ts
Normal 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;
|
||||||
|
}
|
||||||
16
apps/token-tray/tsconfig.json
Normal file
16
apps/token-tray/tsconfig.json
Normal 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/**/*"]
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,9 @@ ADMIN_PASSWORD=changeme123
|
||||||
|
|
||||||
PUBLIC_URL=https://master.media.example.com
|
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)
|
# Optional TMDB / OMDb (OMDb preferred via Admin → Settings → OMDb)
|
||||||
TMDB_API_KEY=
|
TMDB_API_KEY=
|
||||||
OMDB_API_KEY=
|
OMDB_API_KEY=
|
||||||
|
|
|
||||||
2725
pnpm-lock.yaml
2725
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,7 @@ allowBuilds:
|
||||||
'@prisma/client': true
|
'@prisma/client': true
|
||||||
'@prisma/engines': true
|
'@prisma/engines': true
|
||||||
argon2: true
|
argon2: true
|
||||||
|
electron: true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
prisma: true
|
prisma: true
|
||||||
sharp: true
|
sharp: true
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue