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 }}>
|
<div className="card" style={{ maxWidth: 760 }}>
|
||||||
<h2 style={{ marginTop: 0 }}>F1TV</h2>
|
<h2 style={{ marginTop: 0 }}>F1TV</h2>
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
Events + F1-hub. Imperva blokkeert server-login: plak{" "}
|
Events + F1-hub. Zet e-mail + wachtwoord voor de Token Tray (stille browser-login).
|
||||||
<code>ascendontoken</code> + <code>entitlementtoken</code> uit een ingelogde
|
Tokens plakken blijft optioneel. Meerdere accounts: sessies als unie; play round-robint.
|
||||||
browsersessie. Meerdere accounts: sessies worden als unie samengevoegd; play
|
|
||||||
round-robint over owners.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: "1rem" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: "1rem" }}>
|
||||||
|
|
@ -322,42 +320,86 @@ export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void
|
||||||
|
|
||||||
<h3 style={{ marginBottom: "0.5rem" }}>Accounts</h3>
|
<h3 style={{ marginBottom: "0.5rem" }}>Accounts</h3>
|
||||||
{accounts.length === 0 ? (
|
{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" }}>
|
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 1rem" }}>
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
<li
|
<li
|
||||||
key={a.id}
|
key={a.id}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 8,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "0.6rem 0",
|
padding: "0.6rem 0",
|
||||||
borderBottom: "1px solid var(--border, #333)",
|
borderBottom: "1px solid var(--border, #333)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
<div
|
||||||
<strong>{a.label || a.email}</strong>
|
style={{
|
||||||
{healthBadge(a)}
|
display: "flex",
|
||||||
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
flexWrap: "wrap",
|
||||||
{a.email}
|
gap: 8,
|
||||||
{" · "}
|
alignItems: "center",
|
||||||
{a.hasAscendonToken && a.hasEntitlementToken ? "tokens ok" : "tokens missen"}
|
justifyContent: "space-between",
|
||||||
{a.sessionCount ? ` · ${a.sessionCount} sessies` : ""}
|
}}
|
||||||
</span>
|
>
|
||||||
</div>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
||||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
<strong>{a.label || a.email}</strong>
|
||||||
<button type="button" disabled={busy} onClick={() => void probeAccounts(a.id)}>
|
{healthBadge(a)}
|
||||||
Check
|
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
||||||
</button>
|
{a.email}
|
||||||
<button type="button" disabled={busy} onClick={() => void toggleAccount(a)}>
|
{" · "}
|
||||||
{a.enabled ? "Uitschakelen" : "Inschakelen"}
|
{a.hasPassword ? "wachtwoord ok" : "geen wachtwoord"}
|
||||||
</button>
|
{" · "}
|
||||||
<button type="button" disabled={busy} onClick={() => void removeAccount(a)}>
|
{a.hasAscendonToken && a.hasEntitlementToken ? "tokens ok" : "tokens missen"}
|
||||||
Verwijderen
|
{a.sessionCount ? ` · ${a.sessionCount} sessies` : ""}
|
||||||
</button>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||||
|
<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>
|
||||||
|
<button type="button" disabled={busy} onClick={() => void removeAccount(a)}>
|
||||||
|
Verwijderen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -393,7 +435,7 @@ export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
Wachtwoord (optioneel)
|
Wachtwoord (voor Token Tray)
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
|
|
@ -410,7 +452,7 @@ export function F1tvPanel({ onChanged }: { onChanged?: (o: F1tvOverview) => void
|
||||||
style={{ marginTop: 10 }}
|
style={{ marginTop: 10 }}
|
||||||
onClick={() => setShowTokens((v) => !v)}
|
onClick={() => setShowTokens((v) => !v)}
|
||||||
>
|
>
|
||||||
{showTokens ? "Tokens verbergen" : "Tokens plakken (vereist)"}
|
{showTokens ? "Tokens verbergen" : "Tokens plakken (optioneel)"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showTokens && (
|
{showTokens && (
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
|
||||||
|
|
||||||
const [newLabel, setNewLabel] = useState("");
|
const [newLabel, setNewLabel] = useState("");
|
||||||
const [newEmail, setNewEmail] = useState("");
|
const [newEmail, setNewEmail] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [newSession, setNewSession] = useState("");
|
const [newSession, setNewSession] = useState("");
|
||||||
const [newAccessToken, setNewAccessToken] = useState("");
|
const [newAccessToken, setNewAccessToken] = useState("");
|
||||||
const [newProfileId, setNewProfileId] = useState("");
|
const [newProfileId, setNewProfileId] = useState("");
|
||||||
|
|
@ -76,6 +77,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
|
||||||
const [editSession, setEditSession] = useState("");
|
const [editSession, setEditSession] = useState("");
|
||||||
const [editAccessToken, setEditAccessToken] = useState("");
|
const [editAccessToken, setEditAccessToken] = useState("");
|
||||||
const [editProfileId, setEditProfileId] = useState("");
|
const [editProfileId, setEditProfileId] = useState("");
|
||||||
|
const [editPassword, setEditPassword] = useState("");
|
||||||
|
|
||||||
const applySettings = useCallback(
|
const applySettings = useCallback(
|
||||||
(d: ViaplaySettings) => {
|
(d: ViaplaySettings) => {
|
||||||
|
|
@ -140,6 +142,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
label: newLabel.trim() || undefined,
|
label: newLabel.trim() || undefined,
|
||||||
email: newEmail.trim(),
|
email: newEmail.trim(),
|
||||||
|
password: newPassword.trim() || undefined,
|
||||||
session: newSession.trim() || undefined,
|
session: newSession.trim() || undefined,
|
||||||
accessToken: newAccessToken.trim() || undefined,
|
accessToken: newAccessToken.trim() || undefined,
|
||||||
profileId: newProfileId.trim() || undefined,
|
profileId: newProfileId.trim() || undefined,
|
||||||
|
|
@ -152,6 +155,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
|
||||||
applySettings(d);
|
applySettings(d);
|
||||||
setNewLabel("");
|
setNewLabel("");
|
||||||
setNewEmail("");
|
setNewEmail("");
|
||||||
|
setNewPassword("");
|
||||||
setNewSession("");
|
setNewSession("");
|
||||||
setNewAccessToken("");
|
setNewAccessToken("");
|
||||||
setNewProfileId("");
|
setNewProfileId("");
|
||||||
|
|
@ -162,7 +166,9 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
|
||||||
setMsg(
|
setMsg(
|
||||||
sub
|
sub
|
||||||
? `Account toegevoegd · abonnement: ${sub}`
|
? `Account toegevoegd · abonnement: ${sub}`
|
||||||
: "Account toegevoegd — abonnement nog niet zichtbaar, druk Check"
|
: newPassword.trim()
|
||||||
|
? "Account toegevoegd — Token Tray kan stil inloggen met wachtwoord"
|
||||||
|
: "Account toegevoegd — abonnement nog niet zichtbaar, druk Check"
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(String((e as Error).message ?? e));
|
setErr(String((e as Error).message ?? e));
|
||||||
|
|
@ -196,13 +202,19 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
|
||||||
setEditSession("");
|
setEditSession("");
|
||||||
setEditAccessToken("");
|
setEditAccessToken("");
|
||||||
setEditProfileId("");
|
setEditProfileId("");
|
||||||
|
setEditPassword("");
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAccountTokens(account: ViaplayAccount) {
|
async function saveAccountTokens(account: ViaplayAccount) {
|
||||||
if (!editSession.trim() && !editAccessToken.trim() && !editProfileId.trim()) {
|
if (
|
||||||
setErr("Plak minstens session, accessToken of profileId");
|
!editSession.trim() &&
|
||||||
|
!editAccessToken.trim() &&
|
||||||
|
!editProfileId.trim() &&
|
||||||
|
!editPassword.trim()
|
||||||
|
) {
|
||||||
|
setErr("Plak minstens wachtwoord, session, accessToken of profileId");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|
@ -213,6 +225,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
|
||||||
if (editSession.trim()) body.session = editSession.trim();
|
if (editSession.trim()) body.session = editSession.trim();
|
||||||
if (editAccessToken.trim()) body.accessToken = editAccessToken.trim();
|
if (editAccessToken.trim()) body.accessToken = editAccessToken.trim();
|
||||||
if (editProfileId.trim()) body.profileId = editProfileId.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}`, {
|
const r = await fetch(`/api/v1/admin/scripts/viaplay/accounts/${account.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
|
@ -226,12 +239,13 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
|
||||||
setEditSession("");
|
setEditSession("");
|
||||||
setEditAccessToken("");
|
setEditAccessToken("");
|
||||||
setEditProfileId("");
|
setEditProfileId("");
|
||||||
|
setEditPassword("");
|
||||||
const updated = (d.accounts as ViaplayAccount[] | undefined)?.find((a) => a.id === account.id);
|
const updated = (d.accounts as ViaplayAccount[] | undefined)?.find((a) => a.id === account.id);
|
||||||
const sub = updated?.subscriptionSummary;
|
const sub = updated?.subscriptionSummary;
|
||||||
setMsg(
|
setMsg(
|
||||||
sub
|
sub
|
||||||
? `Tokens bijgewerkt · abonnement: ${sub}`
|
? `Bijgewerkt · abonnement: ${sub}`
|
||||||
: "Tokens bijgewerkt — druk Check als abonnement nog ontbreekt"
|
: "Opgeslagen — Token Tray gebruikt wachtwoord voor stille login"
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(String((e as Error).message ?? 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 }}>
|
<div className="card" style={{ maxWidth: 760 }}>
|
||||||
<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. Zet e-mail + wachtwoord
|
||||||
sessie-token, access token en optioneel profile-id (uit een ingelogde browsersessie),
|
(Token Tray logt stil in via de browser-UI). Tokens plakken blijft optioneel.
|
||||||
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>
|
||||||
|
|
@ -452,6 +465,8 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
|
||||||
{healthBadge(a)}
|
{healthBadge(a)}
|
||||||
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
||||||
{a.email}
|
{a.email}
|
||||||
|
{a.hasPassword ? " · wachtwoord" : ""}
|
||||||
|
{a.hasSession && a.hasAccessToken ? " · tokens" : ""}
|
||||||
{a.eventCount ? ` · ${a.eventCount} events` : ""}
|
{a.eventCount ? ` · ${a.eventCount} events` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -495,7 +510,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
|
||||||
Check
|
Check
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={busy} onClick={() => openTokenEdit(a)}>
|
<button type="button" disabled={busy} onClick={() => openTokenEdit(a)}>
|
||||||
{tokenEditId === a.id ? "Annuleren" : "Tokens vernieuwen"}
|
{tokenEditId === a.id ? "Annuleren" : "Tokens / wachtwoord"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={busy} onClick={() => void toggleAccount(a)}>
|
<button type="button" disabled={busy} onClick={() => void toggleAccount(a)}>
|
||||||
{a.enabled ? "Uitschakelen" : "Inschakelen"}
|
{a.enabled ? "Uitschakelen" : "Inschakelen"}
|
||||||
|
|
@ -517,8 +532,21 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
<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>
|
</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">
|
<label className="field">
|
||||||
session
|
session
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -593,6 +621,17 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -601,7 +640,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
|
||||||
style={{ marginTop: 10 }}
|
style={{ marginTop: 10 }}
|
||||||
onClick={() => setShowCookies((v) => !v)}
|
onClick={() => setShowCookies((v) => !v)}
|
||||||
>
|
>
|
||||||
{showCookies ? "Tokens verbergen" : "Sessie-tokens plakken (vereist)"}
|
{showCookies ? "Tokens verbergen" : "Sessie-tokens plakken (optioneel)"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showCookies && (
|
{showCookies && (
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||||
import type { Config } from "../config";
|
import type { Config } from "../config";
|
||||||
import { AppError } from "../security/errors";
|
import { AppError } from "../security/errors";
|
||||||
import { resolveTokenTraySecret } from "../settings/token-tray";
|
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) {
|
async function requireTrayKey(request: FastifyRequest, config: Config) {
|
||||||
const secret = await resolveTokenTraySecret(
|
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) {
|
export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
||||||
app.get("/api/v1/agent/status", async (request) => {
|
app.get("/api/v1/agent/status", async (request) => {
|
||||||
await requireTrayKey(request, config);
|
await requireTrayKey(request, config);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
trayEnabled: 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) => {
|
app.post("/api/v1/agent/viaplay/tokens", async (request) => {
|
||||||
await requireTrayKey(request, config);
|
await requireTrayKey(request, config);
|
||||||
const body = request.body as {
|
const body = request.body as {
|
||||||
|
|
@ -51,4 +118,23 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
||||||
persistentLogin: body.persistentLogin,
|
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();
|
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(
|
export async function updateF1tvAccount(
|
||||||
sessionSecret: string,
|
sessionSecret: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
|
|
|
||||||
|
|
@ -134,20 +134,21 @@ export function createAccountStored(
|
||||||
?.trim()
|
?.trim()
|
||||||
.replace(/^(VIAPLAY-AT|MTG-AT|Bearer)\s+/i, "")
|
.replace(/^(VIAPLAY-AT|MTG-AT|Bearer)\s+/i, "")
|
||||||
.trim();
|
.trim();
|
||||||
if (!session || !accessToken) {
|
const password = input.password?.trim();
|
||||||
throw new Error("session + accessToken verplicht");
|
if ((!session || !accessToken) && !password) {
|
||||||
|
throw new Error("Wachtwoord of session+accessToken verplicht");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: input.id?.trim() || randomUUID(),
|
id: input.id?.trim() || randomUUID(),
|
||||||
label: (input.label ?? "").trim() || email,
|
label: (input.label ?? "").trim() || email,
|
||||||
email,
|
email,
|
||||||
sessionEnc: encryptSecret(session, sessionSecret),
|
sessionEnc: session ? encryptSecret(session, sessionSecret) : undefined,
|
||||||
accessTokenEnc: encryptSecret(accessToken, sessionSecret),
|
accessTokenEnc: accessToken
|
||||||
|
? encryptSecret(accessToken, sessionSecret)
|
||||||
|
: undefined,
|
||||||
profileId: input.profileId?.trim() || undefined,
|
profileId: input.profileId?.trim() || undefined,
|
||||||
deviceId: input.deviceId?.trim() || randomUUID(),
|
deviceId: input.deviceId?.trim() || randomUUID(),
|
||||||
passwordEnc: input.password?.trim()
|
passwordEnc: password ? encryptSecret(password, sessionSecret) : undefined,
|
||||||
? encryptSecret(input.password.trim(), sessionSecret)
|
|
||||||
: undefined,
|
|
||||||
persistentLoginEnc: input.persistentLogin?.trim()
|
persistentLoginEnc: input.persistentLogin?.trim()
|
||||||
? encryptSecret(input.persistentLogin.trim(), sessionSecret)
|
? encryptSecret(input.persistentLogin.trim(), sessionSecret)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,11 @@ function parseConfig(raw: string | null | undefined): ViaplayConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasUsableAccount(accounts: ViaplayAccountStored[]): boolean {
|
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) {
|
async function persist(config: ViaplayConfig, enabled: boolean) {
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,31 @@
|
||||||
# Media Cluster Token Tray
|
# 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**
|
## Admin
|
||||||
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)
|
|
||||||
|
|
||||||
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`
|
## Tray
|
||||||
- `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):
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd apps\token-tray
|
cd apps\token-tray
|
||||||
pnpm.cmd install
|
pnpm.cmd install
|
||||||
|
pnpm.cmd exec playwright install chromium
|
||||||
pnpm.cmd run build
|
pnpm.cmd run build
|
||||||
pnpm.cmd run start
|
pnpm.cmd run start
|
||||||
```
|
```
|
||||||
|
|
||||||
Of eenmalig voor je user-account:
|
Menu:
|
||||||
|
|
||||||
```powershell
|
- **Stil vernieuwen** — headless Chromium klikt inloggen, pusht tokens
|
||||||
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
- **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`)
|
- `GET /api/v1/agent/accounts` — accounts + wachtwoorden
|
||||||
2. Vul het **zelfde e-mailadres** als het Scripts → Viaplay account
|
- `POST /api/v1/agent/viaplay/tokens`
|
||||||
3. Tray → **Viaplay inloggen / vernieuwen** (CAPTCHA in het venster)
|
- `POST /api/v1/agent/f1tv/tokens`
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,18 @@
|
||||||
"name": "@media-cluster/token-tray",
|
"name": "@media-cluster/token-tray",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"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",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"dev": "tsc -p tsconfig.json && electron .",
|
"dev": "tsc -p tsconfig.json && electron .",
|
||||||
"start": "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"
|
"pack:win": "npm run build && electron-builder --win dir"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
|
"playwright": "^1.49.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"electron": "^33.2.1",
|
"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,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
Menu,
|
Menu,
|
||||||
Notification,
|
|
||||||
Tray,
|
Tray,
|
||||||
dialog,
|
dialog,
|
||||||
nativeImage,
|
nativeImage,
|
||||||
shell,
|
shell,
|
||||||
} from "electron";
|
} from "electron";
|
||||||
import path from "node:path";
|
import { configPath, loadConfig, saveConfig, type TrayConfig } from "./config";
|
||||||
import { configPath, loadConfig, saveConfig, type TrayAccount, type TrayConfig } from "./config";
|
import { pingAgent } from "./viaplay";
|
||||||
import { captureViaplayTokens, pingAgent, pushViaplayTokens } from "./viaplay";
|
import { refreshAllSilent } from "./jobs";
|
||||||
|
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
let settingsWin: BrowserWindow | null = null;
|
let settingsWin: BrowserWindow | null = null;
|
||||||
let loginWin: BrowserWindow | null = null;
|
|
||||||
let cfg: TrayConfig = loadConfig();
|
let cfg: TrayConfig = loadConfig();
|
||||||
let watchTimer: ReturnType<typeof setInterval> | null = null;
|
let watchTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let busy = false;
|
||||||
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 {
|
function statusTooltip(): string {
|
||||||
const parts = ["Media Cluster Token Tray"];
|
const parts = ["Media Cluster Token Tray"];
|
||||||
|
if (busy) parts.push("Bezig met stille refresh…");
|
||||||
if (cfg.lastPushSummary) parts.push(cfg.lastPushSummary);
|
if (cfg.lastPushSummary) parts.push(cfg.lastPushSummary);
|
||||||
if (cfg.lastError) parts.push(`Fout: ${cfg.lastError}`);
|
if (cfg.lastError) parts.push(`Fout: ${cfg.lastError}`);
|
||||||
if (cfg.lastPushAt) {
|
if (cfg.lastPushAt) {
|
||||||
|
|
@ -43,10 +30,17 @@ function statusTooltip(): string {
|
||||||
|
|
||||||
function rebuildMenu() {
|
function rebuildMenu() {
|
||||||
if (!tray) return;
|
if (!tray) return;
|
||||||
const account = ensureAccount();
|
|
||||||
const menu = Menu.buildFromTemplate([
|
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" },
|
{ type: "separator" },
|
||||||
{ label: "Verbinding testen", click: () => void testConnection() },
|
{ label: "Verbinding testen", click: () => void testConnection() },
|
||||||
{ label: "Instellingen…", click: () => openSettings() },
|
{ label: "Instellingen…", click: () => openSettings() },
|
||||||
|
|
@ -68,12 +62,11 @@ function openSettings() {
|
||||||
}
|
}
|
||||||
settingsWin = new BrowserWindow({
|
settingsWin = new BrowserWindow({
|
||||||
width: 520,
|
width: 520,
|
||||||
height: 420,
|
height: 360,
|
||||||
title: "Token Tray — instellingen",
|
title: "Token Tray — instellingen",
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: { contextIsolation: true, nodeIntegration: false },
|
webPreferences: { contextIsolation: true, nodeIntegration: false },
|
||||||
});
|
});
|
||||||
const account = ensureAccount();
|
|
||||||
const html = `<!doctype html>
|
const html = `<!doctype html>
|
||||||
<html lang="nl"><head><meta charset="utf-8"/><title>Instellingen</title>
|
<html lang="nl"><head><meta charset="utf-8"/><title>Instellingen</title>
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -85,23 +78,17 @@ function openSettings() {
|
||||||
code{font-size:0.8rem;background:#1c1f28;padding:2px 5px;border-radius:4px}
|
code{font-size:0.8rem;background:#1c1f28;padding:2px 5px;border-radius:4px}
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<h2 style="margin-top:0">Token Tray</h2>
|
<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>
|
<label>API URL</label>
|
||||||
<input id="apiUrl" value="${escapeHtml(cfg.apiUrl)}" />
|
<input id="apiUrl" value="${escapeHtml(cfg.apiUrl)}" />
|
||||||
<label>Tray key (X-Tray-Key)</label>
|
<label>Tray key (X-Tray-Key)</label>
|
||||||
<input id="trayKey" value="${escapeHtml(cfg.trayKey)}" />
|
<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>
|
<button id="save">Opslaan</button>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('save').onclick = () => {
|
document.getElementById('save').onclick = () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
apiUrl: document.getElementById('apiUrl').value,
|
apiUrl: document.getElementById('apiUrl').value,
|
||||||
trayKey: document.getElementById('trayKey').value,
|
trayKey: document.getElementById('trayKey').value,
|
||||||
email: document.getElementById('email').value,
|
|
||||||
label: document.getElementById('label').value,
|
|
||||||
};
|
};
|
||||||
document.title = 'SAVE:' + encodeURIComponent(JSON.stringify(payload));
|
document.title = 'SAVE:' + encodeURIComponent(JSON.stringify(payload));
|
||||||
};
|
};
|
||||||
|
|
@ -115,21 +102,15 @@ function openSettings() {
|
||||||
const payload = JSON.parse(decodeURIComponent(title.slice(5))) as {
|
const payload = JSON.parse(decodeURIComponent(title.slice(5))) as {
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
trayKey: string;
|
trayKey: string;
|
||||||
email: string;
|
|
||||||
label: string;
|
|
||||||
};
|
};
|
||||||
const acc = ensureAccount();
|
|
||||||
cfg.apiUrl = payload.apiUrl.trim().replace(/\/$/, "") || cfg.apiUrl;
|
cfg.apiUrl = payload.apiUrl.trim().replace(/\/$/, "") || cfg.apiUrl;
|
||||||
cfg.trayKey = payload.trayKey.trim();
|
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);
|
saveConfig(cfg);
|
||||||
rebuildMenu();
|
rebuildMenu();
|
||||||
void dialog.showMessageBox({
|
void dialog.showMessageBox({
|
||||||
type: "info",
|
type: "info",
|
||||||
message: "Instellingen opgeslagen",
|
message: "Instellingen opgeslagen",
|
||||||
detail: `Config: ${configPath()}`,
|
detail: `Accounts met wachtwoord staan in Admin → Scripts (Viaplay / F1TV).`,
|
||||||
});
|
});
|
||||||
settingsWin?.close();
|
settingsWin?.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -149,68 +130,30 @@ function escapeHtml(s: string): string {
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openLoginWindow(account: TrayAccount) {
|
async function runSilent(headless: boolean) {
|
||||||
if (loginWin && !loginWin.isDestroyed()) {
|
if (busy) return;
|
||||||
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()) {
|
if (!cfg.trayKey.trim()) {
|
||||||
openSettings();
|
openSettings();
|
||||||
void dialog.showErrorBox(
|
|
||||||
"Tray key ontbreekt",
|
|
||||||
"Stel eerst API-URL en TOKEN_TRAY_SECRET in (Instellingen)."
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
busy = true;
|
||||||
|
rebuildMenu();
|
||||||
try {
|
try {
|
||||||
const tokens = await captureViaplayTokens(account.partition);
|
const summary = await refreshAllSilent(cfg, { headless });
|
||||||
if (!tokens) {
|
cfg = loadConfig();
|
||||||
throw new Error(
|
void dialog.showMessageBox({
|
||||||
"Geen session/accessToken in browserprofiel — open eerst “Viaplay inloggen” en log in."
|
type: summary.failed ? "warning" : "info",
|
||||||
);
|
message: "Refresh klaar",
|
||||||
}
|
detail: [
|
||||||
const result = await pushViaplayTokens(cfg, account, tokens);
|
`${summary.ok} ok · ${summary.failed} fout · ${summary.skipped} zonder wachtwoord`,
|
||||||
cfg.lastPushAt = new Date().toISOString();
|
...summary.errors.slice(0, 5),
|
||||||
cfg.lastPushSummary = result.summary;
|
].join("\n"),
|
||||||
cfg.lastError = undefined;
|
});
|
||||||
saveConfig(cfg);
|
|
||||||
rebuildMenu();
|
|
||||||
if (Notification.isSupported()) {
|
|
||||||
new Notification({
|
|
||||||
title: "Viaplay tokens",
|
|
||||||
body: result.summary,
|
|
||||||
}).show();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cfg.lastError = err instanceof Error ? err.message : String(err);
|
void dialog.showErrorBox("Refresh mislukt", String(err));
|
||||||
saveConfig(cfg);
|
} finally {
|
||||||
|
busy = false;
|
||||||
rebuildMenu();
|
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() {
|
function createTray() {
|
||||||
// 16x16 simple blue square PNG as data URL fallback
|
|
||||||
const png = nativeImage.createFromDataURL(
|
const png = nativeImage.createFromDataURL(
|
||||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAALElEQVQ4T2NkYGD4z0ABYBzVMKoBQw0YNYPRDWBgYGBgZGBg+M8w6gYGBgYGABv0AQX1mVYpAAAAAElFTkSuQmCC"
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAALElEQVQ4T2NkYGD4z0ABYBzVMKoBQw0YNYPRDWBgYGBgZGBg+M8w6gYGBgYGABv0AQX1mVYpAAAAAElFTkSuQmCC"
|
||||||
);
|
);
|
||||||
tray = new Tray(png.isEmpty() ? nativeImage.createEmpty() : png);
|
tray = new Tray(png.isEmpty() ? nativeImage.createEmpty() : png);
|
||||||
tray.setToolTip("Media Cluster Token Tray");
|
tray.setToolTip("Media Cluster Token Tray");
|
||||||
rebuildMenu();
|
rebuildMenu();
|
||||||
tray.on("double-click", () => void openLoginWindow(ensureAccount()));
|
tray.on("double-click", () => void runSilent(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
cfg = loadConfig();
|
cfg = loadConfig();
|
||||||
createTray();
|
createTray();
|
||||||
if (!cfg.trayKey || !cfg.accounts[0] || cfg.accounts[0].email === "viaplay@local") {
|
if (!cfg.trayKey) openSettings();
|
||||||
openSettings();
|
// Elke 3 uur stil proberen (alleen accounts mét wachtwoord)
|
||||||
}
|
watchTimer = setInterval(() => void runSilent(true), 3 * 60 * 60 * 1000);
|
||||||
watchTimer = setInterval(() => void watchExpiry(), 5 * 60_000);
|
|
||||||
void watchExpiry();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
// Tray blijft actief zolang het proces draait
|
// Tray blijft actief
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("before-quit", () => {
|
app.on("before-quit", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue