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:
parent
c425b2b839
commit
2c14e2ce64
12 changed files with 736 additions and 208 deletions
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
150
apps/token-tray/src/jobs.ts
Normal 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;
|
||||
}
|
||||
137
apps/token-tray/src/login/f1tv.ts
Normal file
137
apps/token-tray/src/login/f1tv.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
128
apps/token-tray/src/login/viaplay.ts
Normal file
128
apps/token-tray/src/login/viaplay.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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, "'");
|
||||
}
|
||||
|
||||
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", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue