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
|
||||
apps/admin-ui/.next/
|
||||
apps/master-api/dist/
|
||||
apps/token-tray/dist/
|
||||
apps/token-tray/release/
|
||||
packages/*/dist/
|
||||
|
||||
apps/android-tv/apk-patch/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
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);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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
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
|
||||
|
||||
# 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=
|
||||
|
|
|
|||
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/engines': true
|
||||
argon2: true
|
||||
electron: true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
sharp: true
|
||||
|
|
|
|||
Loading…
Reference in a new issue