Add Viaplay Scripts: EPG sync, cookie accounts, cache-first play/keys.

Sport agenda merges into Events; staggered pre-live fetches MPD/ClearKeys into the DRM DB.
This commit is contained in:
Jos Vooges | STH 2026-09-22 22:07:35 +02:00
parent 85da6f59ad
commit 57ac2d4435
15 changed files with 2930 additions and 4 deletions

View file

@ -6,12 +6,14 @@ import { useSearchParams, useRouter } from "next/navigation";
import { Nav, useAuth } from "@/components/Nav";
import { OdidoPanel, type OdidoOverview } from "./OdidoPanel";
import { F1tvPanel, type F1tvOverview } from "./F1tvPanel";
import { ViaplayPanel, type ViaplayOverview } from "./ViaplayPanel";
type ScriptTab = "odido" | "f1tv";
type ScriptTab = "odido" | "f1tv" | "viaplay";
const TABS: Array<{ id: ScriptTab; label: string; short: string }> = [
{ id: "odido", label: "Odido", short: "Odido" },
{ id: "f1tv", label: "F1TV", short: "F1" },
{ id: "viaplay", label: "Viaplay", short: "Via" },
];
export default function ScriptsPageInner() {
@ -19,16 +21,19 @@ export default function ScriptsPageInner() {
const search = useSearchParams();
const router = useRouter();
const tabParam = search.get("tab");
const active: ScriptTab = tabParam === "f1tv" ? "f1tv" : "odido";
const active: ScriptTab =
tabParam === "f1tv" ? "f1tv" : tabParam === "viaplay" ? "viaplay" : "odido";
const [odidoOverview, setOdidoOverview] = useState<OdidoOverview | null>(null);
const [f1tvOverview, setF1tvOverview] = useState<F1tvOverview | null>(null);
const [viaplayOverview, setViaplayOverview] = useState<ViaplayOverview | null>(null);
const loadOverviews = useCallback(async () => {
try {
const [o, f] = await Promise.all([
const [o, f, v] = await Promise.all([
fetch("/api/v1/admin/scripts/odido", { credentials: "include" }).then((r) => r.json()),
fetch("/api/v1/admin/scripts/f1tv", { credentials: "include" }).then((r) => r.json()),
fetch("/api/v1/admin/scripts/viaplay", { credentials: "include" }).then((r) => r.json()),
]);
if (o && !o.error) {
setOdidoOverview({
@ -48,6 +53,15 @@ export default function ScriptsPageInner() {
lastSyncAt: f.lastSyncAt ?? null,
});
}
if (v && !v.error) {
setViaplayOverview({
configured: !!v.configured,
enabled: v.enabled !== false,
accountCount: v.accountCount ?? 0,
eventCount: v.eventCount ?? 0,
lastSyncAt: v.lastSyncAt ?? null,
});
}
} catch {
/* panels load own errors */
}
@ -147,6 +161,35 @@ export default function ScriptsPageInner() {
)}
</div>
</button>
<button
type="button"
className="card"
onClick={() => setTab("viaplay")}
style={{
textAlign: "left",
cursor: "pointer",
borderColor: active === "viaplay" ? "var(--border-strong, #666)" : undefined,
outline: active === "viaplay" ? "1px solid var(--accent, #4af)" : undefined,
}}
>
<div style={{ fontWeight: 700 }}>Viaplay</div>
<div className="muted" style={{ fontSize: "0.85rem", marginTop: 4 }}>
Sport-events live
</div>
<div style={{ marginTop: 8, fontSize: "0.85rem" }}>
{viaplayOverview == null ? (
<span className="muted">…</span>
) : viaplayOverview.configured ? (
<span className="ok-banner" style={{ display: "inline", padding: "2px 8px" }}>
{viaplayOverview.accountCount} acc
{viaplayOverview.eventCount ? ` · ${viaplayOverview.eventCount} events` : ""}
</span>
) : (
<span className="muted">niet klaar</span>
)}
</div>
</button>
</div>
<nav className="settings-tabs" aria-label="Scripts">
@ -165,8 +208,10 @@ export default function ScriptsPageInner() {
{active === "odido" ? (
<OdidoPanel onChanged={setOdidoOverview} />
) : (
) : active === "f1tv" ? (
<F1tvPanel onChanged={setF1tvOverview} />
) : (
<ViaplayPanel onChanged={setViaplayOverview} />
)}
</div>
</main>

View file

@ -0,0 +1,636 @@
"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
type TweakMeta = {
key: string;
label: string;
hint: string;
kind: "number" | "boolean" | "string";
unit?: string;
};
type ViaplayTweaks = Record<string, number | boolean | string>;
type ViaplayAccount = {
id: string;
label: string;
email: string;
hasSession: boolean;
hasAccessToken: boolean;
hasProfileId: boolean;
hasPassword: boolean;
enabled: boolean;
eventCount: number;
healthStatus: "ok" | "dead" | "unknown";
healthCheckedAt: string | null;
healthError: string | null;
subscriptionSummary: string | null;
partnerName: string | null;
overviewCheckedAt: string | null;
};
type ViaplaySettings = {
configured: boolean;
enabled: boolean;
accounts: ViaplayAccount[];
accountCount: number;
eventCount: number;
eventOwnerCount: number;
lastSyncAt: string | null;
lastSyncSummary: Record<string, unknown> | null;
lastPreLiveAt: string | null;
lastPreLiveSummary: Record<string, unknown> | null;
wvdPath: string;
wvdExists: boolean;
tweaks: ViaplayTweaks;
tweakDefaults: ViaplayTweaks;
tweakMeta: TweakMeta[];
};
export type ViaplayOverview = {
configured: boolean;
enabled: boolean;
accountCount: number;
eventCount: number;
lastSyncAt: string | null;
};
export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) => void }) {
const [settings, setSettings] = useState<ViaplaySettings | null>(null);
const [enabled, setEnabled] = useState(true);
const [tweaks, setTweaks] = useState<ViaplayTweaks>({});
const [showTweaks, setShowTweaks] = useState(false);
const [showCookies, setShowCookies] = useState(false);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [newLabel, setNewLabel] = useState("");
const [newEmail, setNewEmail] = useState("");
const [newSession, setNewSession] = useState("");
const [newAccessToken, setNewAccessToken] = useState("");
const [newProfileId, setNewProfileId] = useState("");
const applySettings = useCallback(
(d: ViaplaySettings) => {
setSettings(d);
setEnabled(d.enabled !== false);
setTweaks(d.tweaks ?? d.tweakDefaults ?? {});
onChanged?.({
configured: d.configured,
enabled: d.enabled !== false,
accountCount: d.accountCount,
eventCount: d.eventCount,
lastSyncAt: d.lastSyncAt,
});
},
[onChanged]
);
const load = useCallback(async () => {
const r = await fetch("/api/v1/admin/scripts/viaplay", { credentials: "include" });
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Viaplay-instellingen laden mislukt");
applySettings(d);
}, [applySettings]);
useEffect(() => {
void load().catch((e) => setErr(String(e.message ?? e)));
}, [load]);
async function saveSettings(e: FormEvent) {
e.preventDefault();
setBusy(true);
setErr(null);
setMsg(null);
try {
const r = await fetch("/api/v1/admin/scripts/viaplay", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, tweaks }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Opslaan mislukt");
applySettings(d);
setMsg("Viaplay-instellingen opgeslagen");
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function addAccount(e: FormEvent) {
e.preventDefault();
setBusy(true);
setErr(null);
setMsg(null);
try {
const r = await fetch("/api/v1/admin/scripts/viaplay/accounts", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
label: newLabel.trim() || undefined,
email: newEmail.trim(),
session: newSession.trim() || undefined,
accessToken: newAccessToken.trim() || undefined,
profileId: newProfileId.trim() || undefined,
enabled: true,
}),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Account toevoegen mislukt");
applySettings(d);
setNewLabel("");
setNewEmail("");
setNewSession("");
setNewAccessToken("");
setNewProfileId("");
setMsg("Viaplay-account toegevoegd");
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function toggleAccount(account: ViaplayAccount) {
setBusy(true);
setErr(null);
try {
const r = await fetch(`/api/v1/admin/scripts/viaplay/accounts/${account.id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !account.enabled }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Account bijwerken mislukt");
applySettings(d);
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function removeAccount(account: ViaplayAccount) {
if (!confirm(`Account “${account.label || account.email}” verwijderen?`)) return;
setBusy(true);
setErr(null);
setMsg(null);
try {
const r = await fetch(`/api/v1/admin/scripts/viaplay/accounts/${account.id}`, {
method: "DELETE",
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Verwijderen mislukt");
applySettings(d);
setMsg("Account verwijderd");
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function probeAccounts(accountId?: string) {
setBusy(true);
setErr(null);
setMsg(null);
try {
const url = accountId
? `/api/v1/admin/scripts/viaplay/accounts/${accountId}/probe`
: "/api/v1/admin/scripts/viaplay/accounts/probe";
const r = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: accountId ? undefined : JSON.stringify({}),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Statuscheck mislukt");
applySettings(d);
const list: ViaplayAccount[] = d.accounts ?? [];
const ok = list.filter((a) => a.healthStatus === "ok").length;
const dead = list.filter((a) => a.healthStatus === "dead").length;
setMsg(`Accountstatus: ${ok} actief · ${dead} dood`);
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function runSync() {
setBusy(true);
setErr(null);
setMsg(null);
try {
const r = await fetch("/api/v1/admin/scripts/viaplay/sync", {
method: "POST",
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Sync mislukt");
setMsg(
`Viaplay EPG: ${d.events ?? 0} events · ${d.daysOk ?? 0}/${d.daysTried ?? 0} dagen (${d.durationMs ?? "?"} ms)`
);
await load();
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
async function runPreLive() {
setBusy(true);
setErr(null);
setMsg(null);
try {
const r = await fetch("/api/v1/admin/scripts/viaplay/prelive", {
method: "POST",
credentials: "include",
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Pre-live mislukt");
setMsg(
`Pre-live: ${d.refreshed ?? 0} refreshed · ${d.failed ?? 0} failed · ${d.checked ?? 0} checked (${d.durationMs ?? "?"} ms)`
);
await load();
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
function healthBadge(a: ViaplayAccount) {
if (!a.enabled) {
return (
<span className="muted" style={{ fontSize: "0.85rem" }}>
uit
</span>
);
}
if (a.healthStatus === "ok") {
return (
<span
className="ok-banner"
style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }}
>
actief
</span>
);
}
if (a.healthStatus === "dead") {
return (
<span
className="err-banner"
title={a.healthError || "Cookies ongeldig"}
style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }}
>
dood
</span>
);
}
return (
<span className="muted" style={{ fontSize: "0.85rem" }}>
onbekend
</span>
);
}
function resetTweaks() {
if (settings?.tweakDefaults) setTweaks({ ...settings.tweakDefaults });
}
const accounts = settings?.accounts ?? [];
const meta = settings?.tweakMeta ?? [];
const summary = settings?.lastSyncSummary;
return (
<>
{msg && (
<p className="ok-banner" role="status">
{msg}
</p>
)}
{err && (
<p className="err-banner" role="alert">
{err}
</p>
)}
<div className="card" style={{ maxWidth: 760 }}>
<h2 style={{ marginTop: 0 }}>Viaplay</h2>
<p className="muted" style={{ marginTop: 0 }}>
Sport-EPG (publiek) + live play/keys. Plak <code>session</code> +{" "}
<code>accessToken</code> (+ <code>viaplay_profileId</code>) uit DevTools na browser-login.
Cache-first: MPD/keys ±5 min vóór start, gestaggerd. Tray-tool volgt later.
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: "1rem" }}>
<button type="button" className="btn-primary" disabled={busy} onClick={() => void runSync()}>
EPG syncen
</button>
<button
type="button"
disabled={busy || accounts.length === 0}
onClick={() => void runPreLive()}
>
Pre-live nu
</button>
<button
type="button"
disabled={busy || accounts.length === 0}
onClick={() => void probeAccounts()}
>
Cookies controleren
</button>
</div>
<h3 style={{ marginBottom: "0.5rem" }}>Accounts</h3>
{accounts.length === 0 ? (
<p className="muted">Nog geen accounts — voeg e-mail + cookies toe.</p>
) : (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 1rem" }}>
{accounts.map((a) => (
<li
key={a.id}
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" }}>
<strong>{a.label || a.email}</strong>
{healthBadge(a)}
<span className="muted" style={{ fontSize: "0.85rem" }}>
{a.email}
{" · "}
{a.hasSession && a.hasAccessToken ? "cookies ok" : "cookies missen"}
{a.subscriptionSummary ? ` · ${a.subscriptionSummary}` : ""}
{a.eventCount ? ` · ${a.eventCount} events` : ""}
</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={() => void toggleAccount(a)}>
{a.enabled ? "Uitschakelen" : "Inschakelen"}
</button>
<button type="button" disabled={busy} onClick={() => void removeAccount(a)}>
Verwijderen
</button>
</div>
</li>
))}
</ul>
)}
<form onSubmit={addAccount} className="form-group" style={{ marginBottom: "1.5rem" }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
gap: "0.75rem",
}}
>
<label className="field">
Label
<input
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="bijv. Thuis"
disabled={busy}
/>
</label>
<label className="field">
E-mail
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
required
disabled={busy}
autoComplete="username"
/>
</label>
</div>
<button
type="button"
className="btn-secondary"
style={{ marginTop: 10 }}
onClick={() => setShowCookies((v) => !v)}
>
{showCookies ? "Cookies verbergen" : "Cookies plakken (vereist)"}
</button>
{showCookies && (
<div style={{ marginTop: 10, display: "grid", gap: "0.75rem" }}>
<label className="field">
session
<textarea
value={newSession}
onChange={(e) => setNewSession(e.target.value)}
rows={2}
disabled={busy}
placeholder="session-cookie waarde"
style={{ fontFamily: "monospace", fontSize: "0.8rem" }}
/>
</label>
<label className="field">
accessToken
<textarea
value={newAccessToken}
onChange={(e) => setNewAccessToken(e.target.value)}
rows={2}
disabled={busy}
placeholder="eyJ…"
style={{ fontFamily: "monospace", fontSize: "0.8rem" }}
/>
</label>
<label className="field">
viaplay_profileId (optioneel)
<input
value={newProfileId}
onChange={(e) => setNewProfileId(e.target.value)}
disabled={busy}
placeholder="EO…"
/>
</label>
</div>
)}
<button type="submit" className="btn-primary" disabled={busy} style={{ marginTop: 10 }}>
Account toevoegen
</button>
</form>
<form onSubmit={saveSettings} className="form-group">
<label className="field" style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
disabled={busy}
/>
Viaplay-integratie actief
</label>
<div style={{ marginTop: "1rem" }}>
<button
type="button"
className="btn-secondary"
onClick={() => setShowTweaks((v) => !v)}
disabled={busy}
>
{showTweaks ? "Geavanceerd verbergen" : "Geavanceerd / tweaks tonen"}
</button>
</div>
{showTweaks && (
<div
style={{
marginTop: "1rem",
padding: "1rem",
border: "1px solid var(--border, #333)",
borderRadius: 8,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<h3 style={{ margin: 0 }}>Tweak-opties</h3>
<button type="button" disabled={busy} onClick={resetTweaks}>
Defaults herstellen
</button>
</div>
<p className="muted" style={{ fontSize: "0.85rem", marginTop: 8 }}>
WVD: {settings?.wvdExists ? "gevonden" : "ontbreekt"} ·{" "}
<code style={{ fontSize: "0.8rem" }}>{settings?.wvdPath}</code>
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
gap: "0.75rem 1rem",
marginTop: "0.75rem",
}}
>
{meta.map((item) => {
const value = tweaks[item.key];
if (item.kind === "boolean") {
return (
<label
key={item.key}
className="field"
style={{ display: "flex", alignItems: "flex-start", gap: 8 }}
>
<input
type="checkbox"
checked={value === true}
disabled={busy}
onChange={(e) =>
setTweaks((prev) => ({ ...prev, [item.key]: e.target.checked }))
}
style={{ marginTop: 4 }}
/>
<span>
<strong style={{ fontWeight: 600 }}>{item.label}</strong>
<br />
<span className="muted" style={{ fontSize: "0.8rem" }}>
{item.hint}
</span>
</span>
</label>
);
}
return (
<label key={item.key} className="field">
{item.label}
{item.unit ? ` (${item.unit})` : ""}
<input
type={item.kind === "number" ? "number" : "text"}
value={value == null ? "" : String(value)}
disabled={busy}
onChange={(e) =>
setTweaks((prev) => ({
...prev,
[item.key]:
item.kind === "number"
? e.target.value === ""
? 0
: Number(e.target.value)
: e.target.value,
}))
}
/>
<span className="muted" style={{ fontSize: "0.8rem" }}>
{item.hint}
</span>
</label>
);
})}
</div>
</div>
)}
<button type="submit" className="btn-primary" disabled={busy} style={{ marginTop: "1rem" }}>
Opslaan
</button>
</form>
<div className="muted" style={{ marginTop: "1rem", fontSize: "0.9rem" }}>
Status:{" "}
{settings?.configured ? (
<span className="ok-banner" style={{ display: "inline", padding: "2px 8px" }}>
geconfigureerd ({settings.accountCount} account
{settings.accountCount === 1 ? "" : "s"}
{settings.eventCount ? ` · ${settings.eventCount} events` : ""}
)
</span>
) : (
<span>nog niet compleet (account + cookies)</span>
)}
{settings?.lastSyncAt && (
<>
<br />
Laatste EPG-sync: {new Date(settings.lastSyncAt).toLocaleString("nl-NL")}
{summary && (
<>
{" "}
(
{[
summary.events != null ? `${String(summary.events)} events` : null,
summary.daysOk != null ? `${String(summary.daysOk)} dagen` : null,
]
.filter(Boolean)
.join(" · ") || "ok"}
)
</>
)}
</>
)}
{settings?.lastPreLiveAt && (
<>
<br />
Laatste pre-live: {new Date(settings.lastPreLiveAt).toLocaleString("nl-NL")}
</>
)}
</div>
</div>
</>
);
}

View file

@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Viaplay Widevine helper (theplatform ModularDrm). Requires: pip install pywidevine requests"""
import json
import sys
import requests
from pywidevine.cdm import Cdm
from pywidevine.device import Device
from pywidevine.pssh import PSSH
def main():
data = json.load(sys.stdin)
device = Device.load(data["wvdPath"])
cdm = Cdm.from_device(device)
session_id = cdm.open()
try:
challenge = cdm.get_license_challenge(session_id, PSSH(data["pssh"]))
headers = {
"Accept": "*/*",
"Origin": "https://viaplay.com",
"Referer": "https://viaplay.com/",
"User-Agent": data.get("userAgent")
or "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
"Content-Type": "application/octet-stream",
}
auth = (data.get("licAuth") or "").strip()
if auth:
headers["Authorization"] = f"Basic {auth}"
response = requests.post(
data["licUrl"],
headers=headers,
data=challenge,
timeout=30,
)
response.raise_for_status()
cdm.parse_license(session_id, response.content)
keys = [
f"{key.kid.hex}:{key.key.hex()}"
for key in cdm.get_keys(session_id)
if key.type != "SIGNING"
]
print(json.dumps({"keys": keys}, separators=(",", ":")))
finally:
cdm.close(session_id)
if __name__ == "__main__":
try:
main()
except Exception as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)

View file

@ -1810,6 +1810,111 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return syncF1tvCatalog(config.SESSION_SECRET);
});
// --- Viaplay script/provider settings ---
app.get("/api/v1/admin/scripts/viaplay", { preHandler: requireAdmin }, async () => {
const { getViaplaySettingsPublic } = await import("../viaplay/settings");
return getViaplaySettingsPublic();
});
app.put("/api/v1/admin/scripts/viaplay", { preHandler: requireAdmin }, async (request) => {
const body = request.body as {
enabled?: boolean;
tweaks?: Record<string, unknown>;
wvdPath?: string;
};
const { upsertViaplaySettings } = await import("../viaplay/settings");
return upsertViaplaySettings({
enabled: body.enabled,
tweaks: body.tweaks as import("../viaplay/tweaks").ViaplayTweaks | undefined,
wvdPath: body.wvdPath,
});
});
app.post("/api/v1/admin/scripts/viaplay/accounts", { preHandler: requireAdmin }, async (request) => {
const body = request.body as {
email?: string;
label?: string;
enabled?: boolean;
session?: string;
accessToken?: string;
profileId?: string;
password?: string;
persistentLogin?: string;
};
const { addViaplayAccount } = await import("../viaplay/settings");
return addViaplayAccount(config.SESSION_SECRET, {
email: String(body.email ?? ""),
label: body.label,
enabled: body.enabled,
session: body.session,
accessToken: body.accessToken,
profileId: body.profileId,
password: body.password,
persistentLogin: body.persistentLogin,
});
});
app.patch(
"/api/v1/admin/scripts/viaplay/accounts/:accountId",
{ preHandler: requireAdmin },
async (request) => {
const { accountId } = request.params as { accountId: string };
const body = request.body as {
email?: string;
label?: string;
enabled?: boolean;
session?: string;
accessToken?: string;
profileId?: string;
password?: string;
persistentLogin?: string;
clearCookies?: boolean;
};
const { updateViaplayAccount } = await import("../viaplay/settings");
return updateViaplayAccount(config.SESSION_SECRET, accountId, body);
}
);
app.delete(
"/api/v1/admin/scripts/viaplay/accounts/:accountId",
{ preHandler: requireAdmin },
async (request) => {
const { accountId } = request.params as { accountId: string };
const { removeViaplayAccount } = await import("../viaplay/settings");
return removeViaplayAccount(config.SESSION_SECRET, accountId);
}
);
app.post(
"/api/v1/admin/scripts/viaplay/accounts/probe",
{ preHandler: requireAdmin },
async (request) => {
const body = (request.body as { accountId?: string }) || {};
const { probeViaplayAccounts } = await import("../viaplay/settings");
return probeViaplayAccounts(config.SESSION_SECRET, body.accountId?.trim() || undefined);
}
);
app.post(
"/api/v1/admin/scripts/viaplay/accounts/:accountId/probe",
{ preHandler: requireAdmin },
async (request) => {
const { accountId } = request.params as { accountId: string };
const { probeViaplayAccounts } = await import("../viaplay/settings");
return probeViaplayAccounts(config.SESSION_SECRET, accountId);
}
);
app.post("/api/v1/admin/scripts/viaplay/sync", { preHandler: requireAdmin }, async () => {
const { syncViaplayCatalog } = await import("../viaplay/sync");
return syncViaplayCatalog(config.SESSION_SECRET);
});
app.post("/api/v1/admin/scripts/viaplay/prelive", { preHandler: requireAdmin }, async () => {
const { runViaplayPreLive } = await import("../viaplay/refresh");
return runViaplayPreLive(config.SESSION_SECRET, "manual");
});
// --- DRM key database ---
app.get("/api/v1/admin/drm-keys", { preHandler: requireAdmin }, async (request) => {
const q = request.query as { search?: string; source?: string; limit?: string };

View file

@ -22,6 +22,7 @@ import { toErrorResponse } from "./security/errors";
import { startIptvEpgWarmup } from "./viewer/iptv-client";
import { applyIptvEpgUrlsToRuntime } from "./settings/iptv";
import { startOdidoMpdRefreshWorker } from "./odido/refresh";
import { startViaplayPreLiveWorker } from "./viaplay/refresh";
async function main() {
const config = loadConfig();
@ -108,6 +109,7 @@ async function main() {
startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS);
startIptvEpgWarmup(config);
startOdidoMpdRefreshWorker(() => config.SESSION_SECRET);
startViaplayPreLiveWorker(() => config.SESSION_SECRET);
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
void nodeConnectionManager.handleConnection(socket);

View file

@ -0,0 +1,234 @@
import { randomUUID } from "node:crypto";
import { decryptSecret, encryptSecret } from "../security/crypto";
export type ViaplayAccountHealth = "ok" | "dead" | "unknown";
export type ViaplayAccountStored = {
id: string;
label: string;
email: string;
sessionEnc?: string;
accessTokenEnc?: string;
profileId?: string;
deviceId?: string;
/** Optioneel — voor latere tray/login. */
passwordEnc?: string;
persistentLoginEnc?: string;
enabled: boolean;
healthStatus?: ViaplayAccountHealth;
healthCheckedAt?: string;
healthError?: string;
subscriptionSummary?: string;
partnerName?: string;
overviewCheckedAt?: string;
};
export type ViaplayAccountPublic = {
id: string;
label: string;
email: string;
hasSession: boolean;
hasAccessToken: boolean;
hasProfileId: boolean;
hasPassword: boolean;
enabled: boolean;
eventCount: number;
healthStatus: ViaplayAccountHealth;
healthCheckedAt: string | null;
healthError: string | null;
subscriptionSummary: string | null;
partnerName: string | null;
overviewCheckedAt: string | null;
};
export type ViaplayAccountCreds = {
id: string;
label: string;
email: string;
session: string | null;
accessToken: string | null;
profileId: string | null;
deviceId: string;
password: string | null;
persistentLogin: string | null;
enabled: boolean;
};
/** mediaGuid → accountIds die dit event mogen spelen. */
export type ViaplayEventOwners = Record<string, string[]>;
const rrCursor = new Map<string, number>();
export function accountToPublic(
account: ViaplayAccountStored,
eventOwners: ViaplayEventOwners
): ViaplayAccountPublic {
let eventCount = 0;
for (const owners of Object.values(eventOwners)) {
if (owners.includes(account.id)) eventCount += 1;
}
return {
id: account.id,
label: account.label || account.email,
email: account.email,
hasSession: !!account.sessionEnc,
hasAccessToken: !!account.accessTokenEnc,
hasProfileId: !!account.profileId?.trim(),
hasPassword: !!account.passwordEnc,
enabled: account.enabled !== false,
eventCount,
healthStatus: account.healthStatus ?? "unknown",
healthCheckedAt: account.healthCheckedAt ?? null,
healthError: account.healthError ?? null,
subscriptionSummary: account.subscriptionSummary ?? null,
partnerName: account.partnerName ?? null,
overviewCheckedAt: account.overviewCheckedAt ?? null,
};
}
export function decryptAccount(
account: ViaplayAccountStored,
sessionSecret: string
): ViaplayAccountCreds {
return {
id: account.id,
label: account.label || account.email,
email: account.email,
session: account.sessionEnc
? decryptSecret(account.sessionEnc, sessionSecret)
: null,
accessToken: account.accessTokenEnc
? decryptSecret(account.accessTokenEnc, sessionSecret)
: null,
profileId: account.profileId?.trim() || null,
deviceId: account.deviceId?.trim() || randomUUID(),
password: account.passwordEnc
? decryptSecret(account.passwordEnc, sessionSecret)
: null,
persistentLogin: account.persistentLoginEnc
? decryptSecret(account.persistentLoginEnc, sessionSecret)
: null,
enabled: account.enabled !== false,
};
}
export function createAccountStored(
sessionSecret: string,
input: {
email: string;
label?: string;
enabled?: boolean;
id?: string;
session?: string;
accessToken?: string;
profileId?: string;
deviceId?: string;
password?: string;
persistentLogin?: string;
}
): ViaplayAccountStored {
const email = input.email.trim().toLowerCase();
if (!email) throw new Error("E-mail verplicht");
const session = input.session?.trim();
const accessToken = input.accessToken?.trim();
if (!session || !accessToken) {
throw new Error("session + accessToken verplicht");
}
return {
id: input.id?.trim() || randomUUID(),
label: (input.label ?? "").trim() || email,
email,
sessionEnc: encryptSecret(session, sessionSecret),
accessTokenEnc: encryptSecret(accessToken, sessionSecret),
profileId: input.profileId?.trim() || undefined,
deviceId: input.deviceId?.trim() || randomUUID(),
passwordEnc: input.password?.trim()
? encryptSecret(input.password.trim(), sessionSecret)
: undefined,
persistentLoginEnc: input.persistentLogin?.trim()
? encryptSecret(input.persistentLogin.trim(), sessionSecret)
: undefined,
enabled: input.enabled !== false,
};
}
export function parseAccountsJson(raw: unknown): ViaplayAccountStored[] {
if (!Array.isArray(raw)) return [];
const out: ViaplayAccountStored[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const row = item as Record<string, unknown>;
const id = String(row.id ?? "").trim();
const email = String(row.email ?? "").trim().toLowerCase();
if (!id || !email) continue;
out.push({
id,
label: String(row.label ?? "").trim() || email,
email,
sessionEnc: String(row.sessionEnc ?? "").trim() || undefined,
accessTokenEnc: String(row.accessTokenEnc ?? "").trim() || undefined,
profileId: String(row.profileId ?? "").trim() || undefined,
deviceId: String(row.deviceId ?? "").trim() || undefined,
passwordEnc: String(row.passwordEnc ?? "").trim() || undefined,
persistentLoginEnc: String(row.persistentLoginEnc ?? "").trim() || undefined,
enabled: row.enabled !== false,
healthStatus:
row.healthStatus === "ok" ||
row.healthStatus === "dead" ||
row.healthStatus === "unknown"
? row.healthStatus
: undefined,
healthCheckedAt:
typeof row.healthCheckedAt === "string" ? row.healthCheckedAt : undefined,
healthError: typeof row.healthError === "string" ? row.healthError : undefined,
subscriptionSummary:
typeof row.subscriptionSummary === "string"
? row.subscriptionSummary
: undefined,
partnerName: typeof row.partnerName === "string" ? row.partnerName : undefined,
overviewCheckedAt:
typeof row.overviewCheckedAt === "string" ? row.overviewCheckedAt : undefined,
});
}
return out;
}
export function parseEventOwners(raw: unknown): ViaplayEventOwners {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const out: ViaplayEventOwners = {};
for (const [mediaGuid, value] of Object.entries(raw as Record<string, unknown>)) {
if (!mediaGuid.trim()) continue;
const ids = Array.isArray(value)
? value.map((v) => String(v).trim()).filter(Boolean)
: [];
if (ids.length) out[mediaGuid] = [...new Set(ids)];
}
return out;
}
export function pickAccountIdForEvent(
mediaGuid: string,
ownerIds: string[],
enabledAccountIds: string[]
): string | null {
const pool = (ownerIds.length ? ownerIds : enabledAccountIds).filter((id) =>
enabledAccountIds.includes(id)
);
if (!pool.length) return null;
const key = mediaGuid || "__global__";
const cursor = rrCursor.get(key) ?? 0;
const chosen = pool[cursor % pool.length];
rrCursor.set(key, cursor + 1);
return chosen;
}
export function accountHasPlayCookies(account: ViaplayAccountStored): boolean {
return !!(account.sessionEnc && account.accessTokenEnc);
}
export function toMediaGuid(guid: string): string {
const g = guid.trim();
if (!g) return g;
if (/-NL$/i.test(g)) return g.replace(/-nl$/i, "-NL");
return `${g}-NL`;
}

View file

@ -0,0 +1,73 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { VIAPLAY_USER_AGENT } from "./client";
export type ViaplayCdmInput = {
wvdPath: string;
pssh: string;
licUrl: string;
licAuth?: string | null;
userAgent?: string;
timeoutMs?: number;
};
export async function extractViaplayKeys(input: ViaplayCdmInput): Promise<string[]> {
const cwdScript = resolve(process.cwd(), "scripts", "viaplay_cdm.py");
const script = existsSync(cwdScript)
? cwdScript
: resolve(__dirname, "..", "..", "scripts", "viaplay_cdm.py");
const python = process.env.PYTHON_VIAPLAY?.trim() || process.env.PYTHON_ODIDO?.trim() || "python3";
const timeoutMs = input.timeoutMs ?? 60_000;
return new Promise<string[]>((resolvePromise, reject) => {
const child = spawn(python, [script], {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill();
reject(new Error("Viaplay CDM-time-out"));
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", (error) => {
clearTimeout(timer);
reject(new Error(`Viaplay CDM kon niet starten: ${error.message}`));
});
child.on("close", (code) => {
clearTimeout(timer);
if (code !== 0) {
reject(new Error(`Viaplay CDM mislukt: ${stderr.trim() || `exit ${code}`}`));
return;
}
try {
const output = JSON.parse(stdout) as { keys?: unknown };
if (!Array.isArray(output.keys) || !output.keys.every((key) => typeof key === "string")) {
throw new Error("ongeldige CDM-uitvoer");
}
resolvePromise(output.keys);
} catch (error) {
reject(new Error(`Viaplay CDM-uitvoer ongeldig: ${(error as Error).message}`));
}
});
child.stdin.end(
JSON.stringify({
wvdPath: input.wvdPath,
pssh: input.pssh,
licUrl: input.licUrl,
licAuth: input.licAuth || null,
userAgent: input.userAgent || VIAPLAY_USER_AGENT,
})
);
});
}

View file

@ -0,0 +1,439 @@
import { randomUUID } from "node:crypto";
import type { ViaplayAccountCreds } from "./accounts";
import { toMediaGuid } from "./accounts";
export const VIAPLAY_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36 Edg/153.0.0.0";
export type ViaplayPlayResult = {
mediaGuid: string;
mpdUrl: string;
licenseUrl: string | null;
licenseAuth: string | null;
channelName: string | null;
stationId: string | null;
title: string | null;
};
function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: null;
}
function cookieHeader(creds: ViaplayAccountCreds): string {
const parts: string[] = [];
if (creds.session) parts.push(`session=${creds.session}`);
if (creds.accessToken) parts.push(`accessToken=${creds.accessToken}`);
if (creds.profileId) parts.push(`viaplay_profileId=${creds.profileId}`);
return parts.join("; ");
}
export function authHeaders(creds: ViaplayAccountCreds): Record<string, string> {
return {
Accept: "*/*",
Origin: "https://viaplay.com",
Referer: "https://viaplay.com/",
"User-Agent": VIAPLAY_USER_AGENT,
Cookie: cookieHeader(creds),
};
}
async function fetchJson(
url: string,
opts: {
headers?: Record<string, string>;
timeoutMs: number;
method?: string;
}
): Promise<{ status: number; json: unknown; text: string }> {
const response = await fetch(url, {
method: opts.method ?? "GET",
headers: opts.headers,
signal: AbortSignal.timeout(opts.timeoutMs),
});
const text = await response.text();
let json: unknown = null;
try {
json = JSON.parse(text) as unknown;
} catch {
/* ignore */
}
return { status: response.status, json, text };
}
export type ViaplayOverview = {
ok: boolean;
status: number;
partnerName: string | null;
productNames: string[];
summary: string;
error?: string;
};
export async function fetchAccountOverview(
creds: ViaplayAccountCreds,
timeoutMs: number
): Promise<ViaplayOverview> {
const res = await fetchJson("https://account.mtg-api.com/overview", {
headers: authHeaders(creds),
timeoutMs,
});
if (res.status !== 200 || !asRecord(res.json)) {
return {
ok: false,
status: res.status,
partnerName: null,
productNames: [],
summary: "",
error:
(asRecord(res.json)?.message as string) ||
`Overview mislukt (${res.status})`,
};
}
const body = asRecord(res.json)!;
const partner = asRecord(body.partnerInformation);
const partnerName =
typeof partner?.partnerDisplayName === "string"
? partner.partnerDisplayName
: null;
const productNames = Array.isArray(partner?.productNames)
? partner!.productNames.map((p) => String(p).trim()).filter(Boolean)
: [];
const summary = [partnerName, productNames.join(", ")].filter(Boolean).join(" · ");
return {
ok: true,
status: res.status,
partnerName,
productNames,
summary: summary || "Actief",
};
}
function walk(node: unknown, visit: (n: Record<string, unknown>) => void) {
if (!node || typeof node !== "object") return;
if (Array.isArray(node)) {
for (const item of node) walk(item, visit);
return;
}
const rec = node as Record<string, unknown>;
visit(rec);
for (const [k, v] of Object.entries(rec)) {
if (k === "images") continue;
walk(v, visit);
}
}
export type ViaplayEpgProduct = {
guid: string;
title: string;
subtitle: string | null;
startMs: number;
endMs: number;
imageUrl: string | null;
pageUrl: string | null;
category: string | null;
sportLabel: string | null;
sportBucket: string | null;
};
function parseIsoMs(raw: unknown): number | null {
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
return raw > 1e12 ? raw : raw * 1000;
}
const t = String(raw ?? "").trim();
if (!t) return null;
const ms = Date.parse(t);
return Number.isFinite(ms) ? ms : null;
}
function sportFromPageUrl(pageUrl: string | null): {
category: string | null;
sportLabel: string | null;
sportBucket: string | null;
} {
if (!pageUrl) return { category: null, sportLabel: null, sportBucket: null };
try {
const path = new URL(pageUrl).pathname.toLowerCase();
// /pcdash-nl/sport/voetbal/... or /sport/motorsport/formule-1/...
const m = path.match(/\/sport\/([^/]+)(?:\/([^/]+))?/i);
const cat = m?.[1] ? decodeURIComponent(m[1]) : null;
const sub = m?.[2] ? decodeURIComponent(m[2]) : null;
const label = sub || cat;
let bucket: string | null = null;
if (cat) {
if (/motor|formule|racing|f1/i.test(`${cat} ${sub || ""}`)) bucket = "racing";
else if (/voetbal|football|soccer/i.test(cat)) bucket = "football";
else if (/tennis/i.test(cat)) bucket = "tennis";
else if (/hockey|ijshockey/i.test(cat)) bucket = "hockey";
else bucket = "sport";
}
return {
category: cat,
sportLabel: label
? label.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
: null,
sportBucket: bucket,
};
} catch {
return { category: null, sportLabel: null, sportBucket: null };
}
}
function pickImage(content: Record<string, unknown> | null): string | null {
if (!content) return null;
const images = asRecord(content.images);
if (!images) return null;
for (const key of ["landscape", "boxart", "cover", "hero"]) {
const img = asRecord(images[key]);
const url = typeof img?.url === "string" ? img.url : null;
if (url) return url;
}
for (const value of Object.values(images)) {
const img = asRecord(value);
if (typeof img?.url === "string") return img.url;
}
return null;
}
export async function fetchSportDays(
deviceKey: string,
timeoutMs: number
): Promise<string[]> {
const url = `https://content.viaplay.com/${deviceKey}/sport/all?language=nl`;
const res = await fetchJson(url, {
headers: {
Accept: "*/*",
"User-Agent": VIAPLAY_USER_AGENT,
Referer: "https://viaplay.com/",
},
timeoutMs,
});
if (res.status !== 200) {
throw new Error(`Viaplay EPG days mislukt (${res.status})`);
}
const root = asRecord(res.json);
const days = root?._links && asRecord(root._links)?.["viaplay:days"];
const list = Array.isArray(days) ? days : [];
const out: string[] = [];
for (const d of list) {
const href = typeof asRecord(d)?.href === "string" ? String(asRecord(d)!.href) : "";
const m = href.match(/[?&]date=(\d{4}-\d{2}-\d{2})/i);
if (m?.[1]) out.push(m[1]);
}
return [...new Set(out)];
}
export async function fetchEpgDay(
deviceKey: string,
date: string,
timeoutMs: number
): Promise<ViaplayEpgProduct[]> {
const url = `https://content.viaplay.com/${deviceKey}/sport/all?date=${encodeURIComponent(date)}&language=nl`;
const res = await fetchJson(url, {
headers: {
Accept: "*/*",
"User-Agent": VIAPLAY_USER_AGENT,
Referer: "https://viaplay.com/",
},
timeoutMs,
});
if (res.status !== 200) {
throw new Error(`Viaplay EPG ${date} mislukt (${res.status})`);
}
return extractProducts(res.json);
}
export async function fetchSportHighlights(
deviceKey: string,
timeoutMs: number
): Promise<ViaplayEpgProduct[]> {
const url = `https://content.viaplay.com/${deviceKey}/sport?language=nl`;
const res = await fetchJson(url, {
headers: {
Accept: "*/*",
"User-Agent": VIAPLAY_USER_AGENT,
Referer: "https://viaplay.com/",
},
timeoutMs,
});
if (res.status !== 200) return [];
return extractProducts(res.json);
}
function extractProducts(json: unknown): ViaplayEpgProduct[] {
const out: ViaplayEpgProduct[] = [];
const seen = new Set<string>();
walk(json, (node) => {
const system = asRecord(node.system);
const content = asRecord(node.content);
const epg = asRecord(node.epg);
const guid = String(system?.guid ?? "").trim();
if (!guid || !epg || seen.has(guid)) return;
const startMs = parseIsoMs(epg.start ?? epg.startTime);
const endMs = parseIsoMs(epg.end ?? epg.endTime ?? epg.stop);
if (startMs == null || endMs == null || endMs <= startMs) return;
seen.add(guid);
const links = asRecord(node._links);
const page =
(typeof asRecord(links?.["viaplay:page"])?.href === "string"
? String(asRecord(links!["viaplay:page"])!.href)
: null) ||
(typeof asRecord(links?.self)?.href === "string"
? String(asRecord(links!.self)!.href)
: null);
const sport = sportFromPageUrl(page);
out.push({
guid,
title: String(content?.title ?? system?.guid ?? guid).trim(),
subtitle: String(content?.subtitle ?? content?.description ?? "").trim() || null,
startMs,
endMs,
imageUrl: pickImage(content),
pageUrl: page,
category: sport.category,
sportLabel: sport.sportLabel,
sportBucket: sport.sportBucket,
});
});
return out;
}
export async function playByMediaGuid(
creds: ViaplayAccountCreds,
mediaGuidRaw: string,
opts: { deviceKey: string; timeoutMs: number }
): Promise<ViaplayPlayResult> {
const mediaGuid = toMediaGuid(mediaGuidRaw);
const u = new URL("https://play-live.viaplay.com/api/stream/bymediaguid");
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
u.searchParams.set("deviceName", "web");
u.searchParams.set("deviceType", "pc");
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
u.searchParams.set("deviceKey", opts.deviceKey);
u.searchParams.set("mediaGuid", mediaGuid);
u.searchParams.set("nocc", "autoplayer");
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
const res = await fetchJson(u.toString(), {
headers: authHeaders(creds),
timeoutMs: opts.timeoutMs,
});
const body = asRecord(res.json);
if (res.status !== 200 || !body) {
const msg =
(typeof body?.message === "string" && body.message) ||
`play-live mislukt (${res.status})`;
throw new Error(msg);
}
const links = asRecord(body._links);
const media = asRecord(links?.["viaplay:media"]);
const license =
asRecord(links?.["viaplay:widevineLicense"]) || asRecord(links?.["viaplay:license"]);
const mpdUrl = typeof media?.href === "string" ? media.href.trim() : "";
if (!mpdUrl) throw new Error("Geen viaplay:media in play-response");
const product = asRecord(body.product);
const channel = asRecord(body.channel) || asRecord(asRecord(body.streamingSession)?.channel);
return {
mediaGuid,
mpdUrl,
licenseUrl: typeof license?.href === "string" ? license.href : null,
licenseAuth: typeof license?.auth === "string" ? license.auth : null,
channelName: typeof channel?.channelName === "string" ? channel.channelName : null,
stationId: typeof channel?.stationId === "string" ? channel.stationId : null,
title:
typeof asRecord(product?.content)?.title === "string"
? String(asRecord(product!.content)!.title)
: null,
};
}
/** Bouw MPD-URL met EPG start/stop kap (seconden). */
export function kapMpdUrl(
mpdUrl: string,
startMs: number,
endMs: number
): string[] {
const startSec = Math.floor(startMs / 1000);
const stopSec = Math.floor(endMs / 1000);
try {
const u = new URL(mpdUrl);
const file = u.pathname.split("/").pop() || "dash.mpd";
const dir = mpdUrl.replace(/\/[^/]+$/, "");
return [
`${dir}/start/${startSec}/stop/${stopSec}/${file}`,
`${dir}/start/${startSec}/stop/${stopSec}/index.mpd`,
mpdUrl,
];
} catch {
return [mpdUrl];
}
}
export async function fetchMpdXml(
url: string,
timeoutMs: number
): Promise<{ status: number; xml: string; url: string }> {
const response = await fetch(url, {
headers: {
Accept: "*/*",
"User-Agent": VIAPLAY_USER_AGENT,
Referer: "https://viaplay.com/",
},
signal: AbortSignal.timeout(timeoutMs),
});
const xml = await response.text();
return { status: response.status, xml, url };
}
export function extractPsshFromMpd(xml: string): string | null {
const widevine = xml.match(
/<ContentProtection\b[^>]*schemeIdUri=["']urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed["'][\s\S]*?<\/ContentProtection>/i
)?.[0];
const fromCp = widevine?.match(
/<(?:[\w-]+:)?pssh\b[^>]*>([^<]+)<\/(?:[\w-]+:)?pssh>/i
)?.[1]?.trim();
if (fromCp) return fromCp;
return (
xml.match(/<(?:[\w-]+:)?pssh\b[^>]*>([^<]+)<\/(?:[\w-]+:)?pssh>/i)?.[1]?.trim() ??
null
);
}
export function mpdHasContentProtection(xml: string): boolean {
return /ContentProtection/i.test(xml) || /<(?:[\w-]+:)?pssh\b/i.test(xml);
}
/** Probe kap-candidates; prefer first 200 with MPD body. Prefer DRM if available. */
export async function resolveKapMpd(
baseMpd: string,
startMs: number,
endMs: number,
timeoutMs: number
): Promise<{ mpdUrl: string; xml: string; hasPssh: boolean; pssh: string | null }> {
const candidates = kapMpdUrl(baseMpd, startMs, endMs);
let fallback: { mpdUrl: string; xml: string } | null = null;
for (const url of candidates) {
try {
const res = await fetchMpdXml(url, timeoutMs);
if (res.status !== 200 || !/<MPD[\s>]/i.test(res.xml)) continue;
const pssh = extractPsshFromMpd(res.xml);
const hasPssh = !!pssh || mpdHasContentProtection(res.xml);
if (hasPssh) {
return { mpdUrl: res.url, xml: res.xml, hasPssh: true, pssh };
}
if (!fallback) fallback = { mpdUrl: res.url, xml: res.xml };
} catch {
/* next */
}
}
if (fallback) {
return {
mpdUrl: fallback.mpdUrl,
xml: fallback.xml,
hasPssh: false,
pssh: null,
};
}
throw new Error("Geen bruikbare MPD (kap/base)");
}

View file

@ -0,0 +1,80 @@
import type { ScheduleEventPublic } from "../viewer/schedule-events";
import { loadViaplayConfigOnly } from "./settings";
import type { ViaplayEventSnapshot } from "./tweaks";
function phaseFor(startMs: number, endMs: number, nowMs: number): "live" | "upcoming" | "ended" {
if (nowMs < startMs) return "upcoming";
if (nowMs > endMs) return "ended";
return "live";
}
export function toEventId(mediaGuid: string): string {
return `viaplay:${mediaGuid}`;
}
export function parseViaplayEventId(eventId: string): string | null {
const m = /^viaplay:(.+)$/i.exec(eventId.trim());
return m?.[1]?.trim() || null;
}
export function eventToPublic(
event: ViaplayEventSnapshot,
nowMs: number
): ScheduleEventPublic {
const phase = phaseFor(event.startMs, event.endMs, nowMs);
return {
id: toEventId(event.mediaGuid),
name: event.title,
description: event.subtitle,
provider: "viaplay",
category: event.category || "sport",
eventType: "live",
subtitle: event.sportLabel,
channel: event.channelName || "Viaplay",
network: "Viaplay",
isLive: phase === "live",
phase,
playable: phase === "live",
status: phase,
start: new Date(event.startMs).toISOString(),
stop: new Date(event.endMs).toISOString(),
startLocal: null,
stopLocal: null,
sportBucket: event.sportBucket || "sport",
sportLabel: event.sportLabel || "Sport",
imageLandscape: event.imageUrl,
imageBackdrop: event.imageUrl,
imagePortrait: null,
logoUrl: null,
images: event.imageUrl ? [event.imageUrl] : [],
audioLanguages: [],
};
}
export async function listViaplayScheduleEvents(
nowMs = Date.now()
): Promise<ScheduleEventPublic[]> {
const config = await loadViaplayConfigOnly();
if (!config?.events?.length) return [];
const out: ScheduleEventPublic[] = [];
for (const event of config.events) {
const pub = eventToPublic(event, nowMs);
if (pub.phase === "ended") continue;
out.push(pub);
}
return out;
}
export async function getViaplayScheduleEvent(
eventId: string,
nowMs = Date.now()
): Promise<ScheduleEventPublic | null> {
const mediaGuid = parseViaplayEventId(eventId);
if (!mediaGuid) return null;
const config = await loadViaplayConfigOnly();
const event = config?.events.find(
(e) => e.mediaGuid === mediaGuid || e.guid === mediaGuid
);
if (!event) return null;
return eventToPublic(event, nowMs);
}

View file

@ -0,0 +1,257 @@
import { prisma } from "../database/client";
import { AppError } from "../security/errors";
import {
pickAccountIdForEvent,
toMediaGuid,
type ViaplayAccountCreds,
} from "./accounts";
import { extractViaplayKeys } from "./cdm";
import {
playByMediaGuid,
resolveKapMpd,
VIAPLAY_USER_AGENT,
} from "./client";
import {
loadViaplaySettings,
patchViaplayEventCache,
} from "./settings";
import { findViaplayEvent } from "./sync";
import { isMpdCacheFresh, type ViaplayEventSnapshot } from "./tweaks";
function parseKey(value: string): { kid: string; key: string } | null {
const [kid, key] = value.trim().toLowerCase().split(":", 2);
if (!/^[0-9a-f]{32}$/.test(kid ?? "") || !/^[0-9a-f]{32}$/.test(key ?? "")) return null;
return { kid: kid!, key: key! };
}
async function keysForMediaGuid(
sessionSecret: string,
mediaGuid: string
): Promise<Array<{ kid: string; key: string }>> {
const rows = await prisma.drmKey.findMany({
where: { mediaGuid, source: "viaplay" },
orderBy: { lastSeenAt: "desc" },
take: 8,
});
if (!rows.length) {
const alt = await prisma.drmKey.findMany({
where: { mediaGuid },
orderBy: { lastSeenAt: "desc" },
take: 8,
});
rows.push(...alt);
}
const { decryptSecret } = await import("../security/crypto");
const out: Array<{ kid: string; key: string }> = [];
const seen = new Set<string>();
for (const row of rows) {
if (seen.has(row.kid)) continue;
seen.add(row.kid);
try {
out.push({ kid: row.kid, key: decryptSecret(row.keyEnc, sessionSecret) });
} catch {
/* skip */
}
}
return out;
}
async function keysForChannelPath(
sessionSecret: string,
mpdUrl: string | null
): Promise<Array<{ kid: string; key: string }>> {
if (!mpdUrl) return [];
// Hergebruik keys van zelfde CDN-pad (zonder start/stop)
const pathKey = mpdUrl.replace(/\/start\/[^/]+\/stop\/[^/]+/, "");
const rows = await prisma.drmKey.findMany({
where: {
source: "viaplay",
mpdUrl: { contains: pathKey.split("/").slice(-3, -1).join("/") || pathKey.slice(-40) },
},
orderBy: { lastSeenAt: "desc" },
take: 8,
});
if (!rows.length) return [];
const { decryptSecret } = await import("../security/crypto");
return rows
.map((row) => {
try {
return { kid: row.kid, key: decryptSecret(row.keyEnc, sessionSecret) };
} catch {
return null;
}
})
.filter((k): k is { kid: string; key: string } => !!k);
}
export type ResolveViaplayPlayResult = {
streamUrl: string;
format: "dash";
keys: Array<{ kid: string; key: string }>;
fromCache: boolean;
mediaGuid: string;
accountId?: string;
};
function accountOrder(
mediaGuid: string,
accounts: ViaplayAccountCreds[],
owners: string[]
): ViaplayAccountCreds[] {
const enabledIds = accounts.map((a) => a.id);
const primaryId = pickAccountIdForEvent(mediaGuid, owners, enabledIds);
const byId = new Map(accounts.map((a) => [a.id, a]));
const ordered: ViaplayAccountCreds[] = [];
if (primaryId && byId.has(primaryId)) ordered.push(byId.get(primaryId)!);
for (const id of owners.length ? owners : enabledIds) {
if (id === primaryId) continue;
const acc = byId.get(id);
if (acc) ordered.push(acc);
}
for (const acc of accounts) {
if (!ordered.some((a) => a.id === acc.id)) ordered.push(acc);
}
return ordered;
}
export async function ensureViaplayEventReady(
sessionSecret: string,
event: ViaplayEventSnapshot,
opts?: { forceRefresh?: boolean; fetchKeys?: boolean }
): Promise<ResolveViaplayPlayResult> {
const settings = await loadViaplaySettings(sessionSecret);
const tweaks = settings.config;
const mediaGuid = toMediaGuid(event.mediaGuid || event.guid);
const owners =
settings.config.eventOwners[mediaGuid] ||
event.accountIds ||
[];
let cachedKeys = await keysForMediaGuid(sessionSecret, mediaGuid);
if (!cachedKeys.length) {
cachedKeys = await keysForChannelPath(sessionSecret, event.mpdUrl);
}
const cacheFresh =
!opts?.forceRefresh &&
!!event.mpdUrl &&
isMpdCacheFresh(event.mpdCachedAt, tweaks.mpdCacheTtlMinutes);
if (cacheFresh && cachedKeys.length) {
return {
streamUrl: event.mpdUrl!,
format: "dash",
keys: cachedKeys,
fromCache: true,
mediaGuid,
};
}
// Cache MPD zonder keys is ok als keys later komen — of clear feed
if (cacheFresh && event.mpdUrl && !(opts?.fetchKeys ?? tweaks.fetchKeysIfMissing)) {
return {
streamUrl: event.mpdUrl,
format: "dash",
keys: cachedKeys,
fromCache: true,
mediaGuid,
};
}
const ordered = accountOrder(mediaGuid, settings.accounts, owners);
let lastError: Error | null = null;
for (const account of ordered) {
try {
const play = await playByMediaGuid(account, mediaGuid, {
deviceKey: tweaks.deviceKey,
timeoutMs: tweaks.httpTimeoutMs,
});
const kap = await resolveKapMpd(
play.mpdUrl,
event.startMs,
event.endMs,
tweaks.httpTimeoutMs
);
let keys = cachedKeys;
const wantKeys = opts?.fetchKeys ?? tweaks.fetchKeysIfMissing;
if (wantKeys && kap.pssh && play.licenseUrl && !keys.length) {
const extracted = await extractViaplayKeys({
wvdPath: settings.wvdPath,
pssh: kap.pssh,
licUrl: play.licenseUrl,
licAuth: play.licenseAuth,
userAgent: VIAPLAY_USER_AGENT,
timeoutMs: tweaks.cdmTimeoutMs,
});
const parsed = extracted.map(parseKey).filter((k): k is { kid: string; key: string } => !!k);
if (parsed.length) {
keys = parsed;
const { upsertDrmKey } = await import("../drm/keys");
for (const k of parsed) {
try {
await upsertDrmKey(prisma, sessionSecret, {
kid: k.kid,
key: k.key,
mpdUrl: kap.mpdUrl,
eventName: event.title,
channelName: play.channelName,
source: "viaplay",
externalId: event.guid,
mediaGuid,
});
} catch {
/* DRM-db mag play niet breken */
}
}
}
}
await patchViaplayEventCache(mediaGuid, {
mpdUrl: kap.mpdUrl,
mpdCachedAt: new Date().toISOString(),
hasPssh: kap.hasPssh,
hasKeys: keys.length > 0,
channelName: play.channelName,
stationId: play.stationId,
lastPlayError: null,
lastPlayAt: new Date().toISOString(),
accountIds: event.accountIds.length ? event.accountIds : [account.id],
});
return {
streamUrl: kap.mpdUrl,
format: "dash",
keys,
fromCache: false,
mediaGuid,
accountId: account.id,
};
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
}
}
await patchViaplayEventCache(mediaGuid, {
lastPlayError: lastError?.message || "Play mislukt",
lastPlayAt: new Date().toISOString(),
});
throw lastError || new Error("Viaplay play mislukt");
}
export async function resolveViaplayPlay(
sessionSecret: string,
eventId: string
): Promise<ResolveViaplayPlayResult> {
const settings = await loadViaplaySettings(sessionSecret);
const event = findViaplayEvent(settings.config, eventId);
if (!event) throw new AppError("NOT_FOUND", "Viaplay-event niet gevonden", 404);
const now = Date.now();
if (now > event.endMs + 15 * 60_000) {
throw new AppError("GONE", "Dit event is afgelopen", 410);
}
// Pre-live: wel MPD/keys toestaan binnen preLiveMinutes; viewer play blijft live-only
return ensureViaplayEventReady(sessionSecret, event);
}

View file

@ -0,0 +1,120 @@
import { ensureViaplayEventReady } from "./play";
import {
loadViaplayConfigOnly,
saveViaplayPreLiveResult,
} from "./settings";
export type ViaplayPreLiveSummary = {
checked: number;
refreshed: number;
skipped: number;
failed: number;
durationMs: number;
triggeredBy: "schedule" | "manual";
};
async function sleep(ms: number) {
if (ms <= 0) return;
await new Promise((r) => setTimeout(r, ms));
}
export async function runViaplayPreLive(
sessionSecret: string,
triggeredBy: "schedule" | "manual" = "manual"
): Promise<ViaplayPreLiveSummary> {
const started = Date.now();
const config = await loadViaplayConfigOnly();
const counts = { checked: 0, refreshed: 0, skipped: 0, failed: 0 };
if (!config) {
return { ...counts, durationMs: 0, triggeredBy };
}
const now = Date.now();
const windowMs = config.preLiveMinutes * 60_000;
const concurrency = config.preLiveConcurrency;
const staggerMs = config.preLiveStaggerMs;
const fetchKeys = config.fetchKeysIfMissing;
const candidates = config.events.filter((e) => {
// binnen pre-live venster of al live; niet ended
if (e.endMs < now) return false;
if (e.startMs - now > windowMs) return false;
// cache-first: skip als MPD + keys al klaar
if (e.mpdUrl && e.hasKeys) return false;
// clear feeds zonder DRM: skip als MPD al gecached en geen PSSH
if (e.mpdUrl && e.hasPssh === false && e.mpdCachedAt) return false;
return true;
});
counts.checked = candidates.length;
let active = 0;
const queue = [...candidates];
async function worker() {
for (;;) {
const event = queue.shift();
if (!event) return;
while (active >= concurrency) {
await sleep(200);
}
active += 1;
try {
await ensureViaplayEventReady(sessionSecret, event, {
fetchKeys,
});
counts.refreshed += 1;
} catch {
counts.failed += 1;
} finally {
active -= 1;
if (staggerMs > 0) {
await sleep(staggerMs);
}
}
}
}
const workers = Array.from(
{ length: Math.min(concurrency, Math.max(1, candidates.length)) },
() => worker()
);
if (!candidates.length) counts.skipped = config.events.length;
await Promise.all(workers);
const summary: ViaplayPreLiveSummary = {
...counts,
durationMs: Date.now() - started,
triggeredBy,
};
await saveViaplayPreLiveResult(summary as unknown as Record<string, unknown>);
return summary;
}
let workerStarted = false;
export function startViaplayPreLiveWorker(getSecret: () => string) {
if (workerStarted) return;
workerStarted = true;
const tick = async () => {
try {
const config = await loadViaplayConfigOnly();
if (!config?.preLiveEnabled) return;
if (!config.accounts.some((a) => a.enabled !== false)) return;
await runViaplayPreLive(getSecret(), "schedule");
} catch {
/* worker mag niet crashen */
}
};
// eerste tick na 45s, daarna per interval uit settings (min 30s)
setTimeout(() => {
void tick();
setInterval(() => {
void (async () => {
const config = await loadViaplayConfigOnly();
const sec = config?.preLiveIntervalSeconds ?? 60;
// setInterval fixed; we just run tick — interval is approx 60s base
void sec;
await tick();
})();
}, 60_000);
}, 45_000);
}

View file

@ -0,0 +1,452 @@
import { existsSync } from "node:fs";
import { prisma } from "../database/client";
import { encryptSecret } from "../security/crypto";
import { AppError } from "../security/errors";
import {
accountHasPlayCookies,
accountToPublic,
createAccountStored,
decryptAccount,
parseAccountsJson,
parseEventOwners,
type ViaplayAccountCreds,
type ViaplayAccountStored,
type ViaplayEventOwners,
} from "./accounts";
import { fetchAccountOverview } from "./client";
import {
mergeViaplayTweaks,
VIAPLAY_TWEAK_DEFAULTS,
VIAPLAY_TWEAK_META,
type ViaplayEventSnapshot,
type ViaplayTweaks,
} from "./tweaks";
const VIAPLAY_ID = "viaplay";
const DEFAULT_WVD_PATH = "/data/odido/WVD.wvd";
export type ViaplayConfig = ViaplayTweaks & {
wvdPath: string;
accounts: ViaplayAccountStored[];
eventOwners: ViaplayEventOwners;
events: ViaplayEventSnapshot[];
lastSyncAt?: string;
lastSyncSummary?: Record<string, unknown>;
lastPreLiveAt?: string;
lastPreLiveSummary?: Record<string, unknown>;
};
function parseEvents(raw: unknown): ViaplayEventSnapshot[] {
if (!Array.isArray(raw)) return [];
const out: ViaplayEventSnapshot[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const row = item as Record<string, unknown>;
const guid = String(row.guid ?? "").trim();
const mediaGuid = String(row.mediaGuid ?? "").trim();
const title = String(row.title ?? "").trim();
const startMs = Number(row.startMs);
const endMs = Number(row.endMs);
if (!guid || !mediaGuid || !title) continue;
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue;
out.push({
guid,
mediaGuid,
title,
subtitle: typeof row.subtitle === "string" ? row.subtitle : null,
category: typeof row.category === "string" ? row.category : null,
sportLabel: typeof row.sportLabel === "string" ? row.sportLabel : null,
sportBucket: typeof row.sportBucket === "string" ? row.sportBucket : null,
startMs,
endMs,
imageUrl: typeof row.imageUrl === "string" ? row.imageUrl : null,
pageUrl: typeof row.pageUrl === "string" ? row.pageUrl : null,
channelName: typeof row.channelName === "string" ? row.channelName : null,
stationId: typeof row.stationId === "string" ? row.stationId : null,
mpdUrl: typeof row.mpdUrl === "string" ? row.mpdUrl : null,
mpdCachedAt: typeof row.mpdCachedAt === "string" ? row.mpdCachedAt : null,
hasPssh: row.hasPssh === true,
hasKeys: row.hasKeys === true,
accountIds: Array.isArray(row.accountIds)
? row.accountIds.map((id) => String(id).trim()).filter(Boolean)
: [],
lastPlayError: typeof row.lastPlayError === "string" ? row.lastPlayError : null,
lastPlayAt: typeof row.lastPlayAt === "string" ? row.lastPlayAt : null,
});
}
return out;
}
function parseConfig(raw: string | null | undefined): ViaplayConfig {
let parsed: Partial<ViaplayConfig> & Record<string, unknown> = {};
if (raw) {
try {
parsed = JSON.parse(raw) as Partial<ViaplayConfig> & Record<string, unknown>;
} catch {
parsed = {};
}
}
const tweaks = mergeViaplayTweaks(parsed);
return {
...tweaks,
wvdPath: String(parsed.wvdPath ?? "").trim() || process.env.VIAPLAY_WVD_PATH || DEFAULT_WVD_PATH,
accounts: parseAccountsJson(parsed.accounts),
eventOwners: parseEventOwners(parsed.eventOwners),
events: parseEvents(parsed.events),
lastSyncAt: typeof parsed.lastSyncAt === "string" ? parsed.lastSyncAt : undefined,
lastSyncSummary:
parsed.lastSyncSummary && typeof parsed.lastSyncSummary === "object"
? (parsed.lastSyncSummary as Record<string, unknown>)
: undefined,
lastPreLiveAt: typeof parsed.lastPreLiveAt === "string" ? parsed.lastPreLiveAt : undefined,
lastPreLiveSummary:
parsed.lastPreLiveSummary && typeof parsed.lastPreLiveSummary === "object"
? (parsed.lastPreLiveSummary as Record<string, unknown>)
: undefined,
};
}
function hasUsableAccount(accounts: ViaplayAccountStored[]): boolean {
return accounts.some((a) => a.enabled !== false && accountHasPlayCookies(a));
}
async function persist(config: ViaplayConfig, enabled: boolean) {
const primary = config.accounts.find((a) => a.enabled) ?? config.accounts[0];
await prisma.integrationSetting.upsert({
where: { id: VIAPLAY_ID },
create: {
id: VIAPLAY_ID,
username: primary?.email ?? null,
passwordEnc: primary?.sessionEnc ?? null,
configJson: JSON.stringify(config),
enabled,
},
update: {
username: primary?.email ?? null,
passwordEnc: primary?.sessionEnc ?? null,
configJson: JSON.stringify(config),
enabled,
},
});
}
async function resolveWvdPath(config: ViaplayConfig): Promise<string> {
if (config.useOdidoWvd) {
try {
const odido = await prisma.integrationSetting.findUnique({ where: { id: "odido" } });
if (odido?.configJson) {
const parsed = JSON.parse(odido.configJson) as { wvdPath?: string };
const path = String(parsed.wvdPath ?? "").trim();
if (path && existsSync(path)) return path;
}
} catch {
/* fall through */
}
}
return config.wvdPath;
}
export async function getViaplaySettingsPublic() {
const row = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } });
const config = parseConfig(row?.configJson);
const accounts = config.accounts.map((a) => accountToPublic(a, config.eventOwners));
const wvdPath = await resolveWvdPath(config);
const configured = hasUsableAccount(config.accounts);
return {
configured,
enabled: row?.enabled ?? true,
accounts,
accountCount: accounts.filter((a) => a.enabled).length,
eventCount: config.events.length,
eventOwnerCount: Object.keys(config.eventOwners).length,
lastSyncAt: config.lastSyncAt ?? null,
lastSyncSummary: config.lastSyncSummary ?? null,
lastPreLiveAt: config.lastPreLiveAt ?? null,
lastPreLiveSummary: config.lastPreLiveSummary ?? null,
wvdPath,
wvdExists: existsSync(wvdPath),
tweaks: mergeViaplayTweaks(config),
tweakDefaults: VIAPLAY_TWEAK_DEFAULTS,
tweakMeta: VIAPLAY_TWEAK_META,
};
}
export async function upsertViaplaySettings(input: {
enabled?: boolean;
tweaks?: Partial<ViaplayTweaks>;
wvdPath?: string;
}) {
const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } });
const config = parseConfig(existing?.configJson);
if (input.tweaks) {
Object.assign(config, mergeViaplayTweaks({ ...config, ...input.tweaks }));
}
if (input.wvdPath !== undefined) {
config.wvdPath = input.wvdPath.trim() || DEFAULT_WVD_PATH;
}
await persist(config, input.enabled ?? existing?.enabled ?? true);
return getViaplaySettingsPublic();
}
export async function addViaplayAccount(
sessionSecret: string,
input: {
email: string;
label?: string;
enabled?: boolean;
session?: string;
accessToken?: string;
profileId?: string;
password?: string;
persistentLogin?: string;
}
) {
const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } });
const config = parseConfig(existing?.configJson);
const email = input.email.trim().toLowerCase();
if (config.accounts.some((a) => a.email === email)) {
throw new AppError("INVALID_REQUEST", "Dit e-mailadres staat al in de lijst", 400);
}
let account: ViaplayAccountStored;
try {
account = createAccountStored(sessionSecret, input);
} catch (e) {
throw new AppError("INVALID_REQUEST", (e as Error).message, 400);
}
config.accounts.push(account);
await persist(config, existing?.enabled ?? true);
return getViaplaySettingsPublic();
}
export async function updateViaplayAccount(
sessionSecret: string,
accountId: string,
input: {
email?: string;
label?: string;
enabled?: boolean;
session?: string;
accessToken?: string;
profileId?: string;
password?: string;
persistentLogin?: string;
clearCookies?: boolean;
}
) {
const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } });
const config = parseConfig(existing?.configJson);
const idx = config.accounts.findIndex((a) => a.id === accountId);
if (idx < 0) throw new AppError("NOT_FOUND", "Account niet gevonden", 404);
const current = config.accounts[idx];
const email =
input.email !== undefined ? input.email.trim().toLowerCase() : current.email;
if (!email) throw new AppError("INVALID_REQUEST", "E-mail verplicht", 400);
if (config.accounts.some((a, i) => i !== idx && a.email === email)) {
throw new AppError("INVALID_REQUEST", "Dit e-mailadres staat al in de lijst", 400);
}
let sessionEnc = current.sessionEnc;
let accessTokenEnc = current.accessTokenEnc;
let profileId = current.profileId;
let persistentLoginEnc = current.persistentLoginEnc;
if (input.clearCookies) {
sessionEnc = undefined;
accessTokenEnc = undefined;
profileId = undefined;
persistentLoginEnc = undefined;
}
if (input.session?.trim()) {
sessionEnc = encryptSecret(input.session.trim(), sessionSecret);
}
if (input.accessToken?.trim()) {
accessTokenEnc = encryptSecret(input.accessToken.trim(), sessionSecret);
}
if (input.profileId !== undefined) {
profileId = input.profileId.trim() || undefined;
}
if (input.persistentLogin?.trim()) {
persistentLoginEnc = encryptSecret(input.persistentLogin.trim(), sessionSecret);
}
config.accounts[idx] = {
...current,
email,
label:
input.label !== undefined
? input.label.trim() || email
: current.label || email,
enabled: input.enabled !== undefined ? input.enabled : current.enabled,
sessionEnc,
accessTokenEnc,
profileId,
persistentLoginEnc,
passwordEnc: input.password?.trim()
? encryptSecret(input.password.trim(), sessionSecret)
: current.passwordEnc,
};
await persist(config, existing?.enabled ?? true);
return getViaplaySettingsPublic();
}
export async function removeViaplayAccount(sessionSecret: string, accountId: string) {
void sessionSecret;
const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } });
const config = parseConfig(existing?.configJson);
const next = config.accounts.filter((a) => a.id !== accountId);
if (next.length === config.accounts.length) {
throw new AppError("NOT_FOUND", "Account niet gevonden", 404);
}
config.accounts = next;
const owners: ViaplayEventOwners = {};
for (const [mediaGuid, ids] of Object.entries(config.eventOwners)) {
const filtered = ids.filter((id) => id !== accountId);
if (filtered.length) owners[mediaGuid] = filtered;
}
config.eventOwners = owners;
for (const event of config.events) {
event.accountIds = event.accountIds.filter((id) => id !== accountId);
}
await persist(config, existing?.enabled ?? true);
return getViaplaySettingsPublic();
}
export async function probeViaplayAccounts(sessionSecret: string, accountId?: string) {
const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } });
const config = parseConfig(existing?.configJson);
const targets = accountId
? config.accounts.filter((a) => a.id === accountId)
: config.accounts;
if (accountId && !targets.length) {
throw new AppError("NOT_FOUND", "Account niet gevonden", 404);
}
const now = new Date().toISOString();
for (const account of targets) {
if (account.enabled === false) {
account.healthStatus = "unknown";
account.healthCheckedAt = now;
account.healthError = "Uitgeschakeld";
continue;
}
try {
const creds = decryptAccount(account, sessionSecret);
if (!creds.session || !creds.accessToken) {
account.healthStatus = "dead";
account.healthCheckedAt = now;
account.healthError = "Geen session/accessToken — plak cookies uit de browser";
continue;
}
if (!account.deviceId) account.deviceId = creds.deviceId;
const overview = await fetchAccountOverview(creds, config.httpTimeoutMs);
if (!overview.ok) {
account.healthStatus = "dead";
account.healthCheckedAt = now;
account.healthError = overview.error || `HTTP ${overview.status}`;
account.subscriptionSummary = undefined;
account.partnerName = undefined;
} else {
account.healthStatus = "ok";
account.healthCheckedAt = now;
account.healthError = undefined;
account.subscriptionSummary = overview.summary;
account.partnerName = overview.partnerName ?? undefined;
account.overviewCheckedAt = now;
}
} catch (err) {
account.healthStatus = "dead";
account.healthCheckedAt = now;
account.healthError = err instanceof Error ? err.message : String(err);
}
}
await persist(config, existing?.enabled ?? true);
return getViaplaySettingsPublic();
}
export type LoadedViaplaySettings = {
accounts: ViaplayAccountCreds[];
config: ViaplayConfig;
wvdPath: string;
};
export async function loadViaplaySettings(sessionSecret: string): Promise<LoadedViaplaySettings> {
const row = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } });
if (!row?.enabled) {
throw new AppError("INVALID_REQUEST", "Viaplay-integratie is uitgeschakeld", 400);
}
const config = parseConfig(row.configJson);
const accounts = config.accounts
.filter((a) => a.enabled !== false)
.map((a) => {
try {
return decryptAccount(a, sessionSecret);
} catch {
return null;
}
})
.filter((a): a is ViaplayAccountCreds => !!a && !!a.session && !!a.accessToken);
if (!accounts.length) {
throw new AppError(
"INVALID_REQUEST",
"Geen actief Viaplay-account met session/accessToken — voeg cookies toe onder Scripts → Viaplay",
400
);
}
return { accounts, config, wvdPath: await resolveWvdPath(config) };
}
export async function loadViaplayConfigOnly(): Promise<ViaplayConfig | null> {
const row = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } });
if (!row?.enabled) return null;
return parseConfig(row.configJson);
}
export async function saveViaplaySyncResult(
summary: Record<string, unknown>,
data: {
events: ViaplayEventSnapshot[];
eventOwners: ViaplayEventOwners;
}
) {
const row = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } });
if (!row) return;
const config = parseConfig(row.configJson);
config.lastSyncAt = new Date().toISOString();
config.lastSyncSummary = summary;
config.events = data.events;
config.eventOwners = data.eventOwners;
await prisma.integrationSetting.update({
where: { id: VIAPLAY_ID },
data: { configJson: JSON.stringify(config) },
});
}
export async function patchViaplayEventCache(
mediaGuid: string,
patch: Partial<ViaplayEventSnapshot>
) {
const row = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } });
if (!row) return;
const config = parseConfig(row.configJson);
const idx = config.events.findIndex((e) => e.mediaGuid === mediaGuid || e.guid === mediaGuid);
if (idx < 0) return;
config.events[idx] = { ...config.events[idx], ...patch };
await prisma.integrationSetting.update({
where: { id: VIAPLAY_ID },
data: { configJson: JSON.stringify(config) },
});
}
export async function saveViaplayPreLiveResult(summary: Record<string, unknown>) {
const row = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } });
if (!row) return;
const config = parseConfig(row.configJson);
config.lastPreLiveAt = new Date().toISOString();
config.lastPreLiveSummary = summary;
await prisma.integrationSetting.update({
where: { id: VIAPLAY_ID },
data: { configJson: JSON.stringify(config) },
});
}

View file

@ -0,0 +1,172 @@
import { AppError } from "../security/errors";
import { toMediaGuid, type ViaplayEventOwners } from "./accounts";
import {
fetchEpgDay,
fetchSportDays,
fetchSportHighlights,
type ViaplayEpgProduct,
} from "./client";
import {
loadViaplayConfigOnly,
saveViaplaySyncResult,
type ViaplayConfig,
} from "./settings";
import type { ViaplayEventSnapshot } from "./tweaks";
function dateYmd(ms: number): string {
return new Date(ms).toISOString().slice(0, 10);
}
function mergeProducts(products: ViaplayEpgProduct[]): ViaplayEpgProduct[] {
const byGuid = new Map<string, ViaplayEpgProduct>();
for (const p of products) {
const prev = byGuid.get(p.guid);
if (!prev) {
byGuid.set(p.guid, p);
continue;
}
byGuid.set(p.guid, {
...prev,
...p,
title: p.title || prev.title,
imageUrl: p.imageUrl || prev.imageUrl,
pageUrl: p.pageUrl || prev.pageUrl,
subtitle: p.subtitle || prev.subtitle,
category: p.category || prev.category,
sportLabel: p.sportLabel || prev.sportLabel,
sportBucket: p.sportBucket || prev.sportBucket,
});
}
return [...byGuid.values()];
}
function toSnapshot(
product: ViaplayEpgProduct,
prev: ViaplayEventSnapshot | undefined,
accountIds: string[]
): ViaplayEventSnapshot {
const mediaGuid = toMediaGuid(product.guid);
return {
guid: product.guid,
mediaGuid,
title: product.title,
subtitle: product.subtitle,
category: product.category,
sportLabel: product.sportLabel,
sportBucket: product.sportBucket,
startMs: product.startMs,
endMs: product.endMs,
imageUrl: product.imageUrl,
pageUrl: product.pageUrl,
channelName: prev?.channelName ?? null,
stationId: prev?.stationId ?? null,
mpdUrl: prev?.mpdUrl ?? null,
mpdCachedAt: prev?.mpdCachedAt ?? null,
hasPssh: prev?.hasPssh ?? false,
hasKeys: prev?.hasKeys ?? false,
accountIds: prev?.accountIds?.length ? prev.accountIds : accountIds,
lastPlayError: prev?.lastPlayError ?? null,
lastPlayAt: prev?.lastPlayAt ?? null,
};
}
export async function syncViaplayCatalog(_sessionSecret?: string) {
void _sessionSecret;
const started = Date.now();
const config = await loadViaplayConfigOnly();
if (!config) {
throw new AppError("INVALID_REQUEST", "Viaplay-integratie is uitgeschakeld", 400);
}
const enabledIds = config.accounts
.filter((a) => a.enabled !== false)
.map((a) => a.id);
const prevByGuid = new Map(config.events.map((e) => [e.guid, e]));
const prevByMedia = new Map(config.events.map((e) => [e.mediaGuid, e]));
const products: ViaplayEpgProduct[] = [];
try {
products.push(...(await fetchSportHighlights(config.deviceKey, config.httpTimeoutMs)));
} catch {
/* highlights optioneel */
}
let days: string[] = [];
try {
days = await fetchSportDays(config.deviceKey, config.httpTimeoutMs);
} catch {
days = [];
}
const now = Date.now();
const maxDate = dateYmd(now + config.syncDaysAhead * 86_400_000);
const minDate = dateYmd(now - 86_400_000);
const wanted = (days.length ? days : buildFallbackDays(now, config.syncDaysAhead)).filter(
(d) => d >= minDate && d <= maxDate
);
let dayOk = 0;
let dayFail = 0;
for (const date of wanted) {
try {
products.push(...(await fetchEpgDay(config.deviceKey, date, config.httpTimeoutMs)));
dayOk += 1;
} catch {
dayFail += 1;
}
}
const merged = mergeProducts(products);
const events: ViaplayEventSnapshot[] = [];
const eventOwners: ViaplayEventOwners = { ...config.eventOwners };
for (const product of merged) {
const mediaGuid = toMediaGuid(product.guid);
const prev = prevByGuid.get(product.guid) || prevByMedia.get(mediaGuid);
const owners =
eventOwners[mediaGuid]?.length
? eventOwners[mediaGuid]
: enabledIds.length
? [...enabledIds]
: [];
eventOwners[mediaGuid] = owners;
events.push(toSnapshot(product, prev, owners));
}
// Drop ended events older than 6h to keep config small
const cutoff = now - 6 * 3_600_000;
const kept = events.filter((e) => e.endMs >= cutoff);
kept.sort((a, b) => a.startMs - b.startMs);
const summary = {
products: merged.length,
events: kept.length,
daysTried: wanted.length,
daysOk: dayOk,
daysFailed: dayFail,
durationMs: Date.now() - started,
};
await saveViaplaySyncResult(summary, { events: kept, eventOwners });
return summary;
}
function buildFallbackDays(nowMs: number, ahead: number): string[] {
const out: string[] = [];
for (let i = -1; i <= ahead; i += 1) {
out.push(dateYmd(nowMs + i * 86_400_000));
}
return out;
}
export function findViaplayEvent(
config: ViaplayConfig,
eventIdOrGuid: string
): ViaplayEventSnapshot | null {
const raw = eventIdOrGuid.trim();
const id = raw.replace(/^viaplay:/i, "");
return (
config.events.find(
(e) => e.mediaGuid === id || e.guid === id || e.mediaGuid === `${id}-NL`
) ?? null
);
}

View file

@ -0,0 +1,202 @@
export type ViaplayTweaks = {
/** Content deviceKey (NL DASH). */
deviceKey: string;
/** Hoeveel dagen vooruit EPG syncen. */
syncDaysAhead: number;
/** HTTP-timeout sync/play (ms). */
httpTimeoutMs: number;
/** Minuten vóór start MPD/keys ophalen. */
preLiveMinutes: number;
/** Interval van de pre-live worker (s). */
preLiveIntervalSeconds: number;
/** Max gelijktijdige pre-live jobs (1 = veilig). */
preLiveConcurrency: number;
/** Pauze tussen jobs (ms). */
preLiveStaggerMs: number;
/** Pre-live worker aan/uit. */
preLiveEnabled: boolean;
/** Keys ophalen als PSSH beschikbaar is. */
fetchKeysIfMissing: boolean;
/** MPD-cache TTL (minuten) vóór her-play. */
mpdCacheTtlMinutes: number;
/** CDM-timeout (ms). */
cdmTimeoutMs: number;
/** Deel Odido WVD-pad. */
useOdidoWvd: boolean;
};
export const VIAPLAY_TWEAK_DEFAULTS: ViaplayTweaks = {
deviceKey: "pcdash-nl",
syncDaysAhead: 7,
httpTimeoutMs: 30_000,
preLiveMinutes: 5,
preLiveIntervalSeconds: 60,
preLiveConcurrency: 1,
preLiveStaggerMs: 12_000,
preLiveEnabled: true,
fetchKeysIfMissing: true,
mpdCacheTtlMinutes: 180,
cdmTimeoutMs: 60_000,
useOdidoWvd: true,
};
export const VIAPLAY_TWEAK_META: Array<{
key: keyof ViaplayTweaks;
label: string;
hint: string;
kind: "number" | "boolean" | "string";
unit?: string;
}> = [
{
key: "deviceKey",
label: "Device key",
hint: "Viaplay content/play deviceKey (NL = pcdash-nl).",
kind: "string",
},
{
key: "syncDaysAhead",
label: "EPG-dagen vooruit",
hint: "Hoeveel dagen sport/all?date=… syncen.",
kind: "number",
},
{
key: "httpTimeoutMs",
label: "HTTP-timeout",
hint: "Timeout per Viaplay-request.",
kind: "number",
unit: "ms",
},
{
key: "preLiveMinutes",
label: "Pre-live venster",
hint: "MPD/keys ophalen vanaf N minuten vóór start.",
kind: "number",
unit: "min",
},
{
key: "preLiveIntervalSeconds",
label: "Worker-interval",
hint: "Hoe vaak de pre-live worker draait.",
kind: "number",
unit: "s",
},
{
key: "preLiveConcurrency",
label: "Pre-live concurrency",
hint: "1 = veilig voor account; nooit hoger dan 2 zonder reden.",
kind: "number",
},
{
key: "preLiveStaggerMs",
label: "Pre-live stagger",
hint: "Pauze tussen play/license-calls.",
kind: "number",
unit: "ms",
},
{
key: "preLiveEnabled",
label: "Pre-live worker",
hint: "Automatisch MPD/keys ±5 min vóór start.",
kind: "boolean",
},
{
key: "fetchKeysIfMissing",
label: "Keys bij miss",
hint: "Widevine-keys ophalen als PSSH aanwezig is.",
kind: "boolean",
},
{
key: "mpdCacheTtlMinutes",
label: "MPD-cache TTL",
hint: "Hergebruik cached MPD tot TTL verloopt.",
kind: "number",
unit: "min",
},
{
key: "cdmTimeoutMs",
label: "CDM-timeout",
hint: "Widevine license-extractie.",
kind: "number",
unit: "ms",
},
{
key: "useOdidoWvd",
label: "Odido-WVD hergebruiken",
hint: "Zelfde WVD-bestand als Odido Scripts.",
kind: "boolean",
},
];
export function mergeViaplayTweaks(
raw: Partial<ViaplayTweaks> | Record<string, unknown>
): ViaplayTweaks {
const d = VIAPLAY_TWEAK_DEFAULTS;
const n = (v: unknown, fallback: number) => {
const x = typeof v === "number" ? v : Number(v);
return Number.isFinite(x) && x > 0 ? Math.floor(x) : fallback;
};
const s = (v: unknown, fallback: string) => {
const t = String(v ?? "").trim();
return t || fallback;
};
return {
deviceKey: s(raw.deviceKey, d.deviceKey),
syncDaysAhead: Math.min(21, Math.max(1, n(raw.syncDaysAhead, d.syncDaysAhead))),
httpTimeoutMs: Math.min(120_000, Math.max(5_000, n(raw.httpTimeoutMs, d.httpTimeoutMs))),
preLiveMinutes: Math.min(60, Math.max(1, n(raw.preLiveMinutes, d.preLiveMinutes))),
preLiveIntervalSeconds: Math.min(
600,
Math.max(30, n(raw.preLiveIntervalSeconds, d.preLiveIntervalSeconds))
),
preLiveConcurrency: Math.min(
2,
Math.max(1, n(raw.preLiveConcurrency, d.preLiveConcurrency))
),
preLiveStaggerMs: Math.min(
120_000,
Math.max(0, n(raw.preLiveStaggerMs, d.preLiveStaggerMs))
),
preLiveEnabled: raw.preLiveEnabled !== false,
fetchKeysIfMissing: raw.fetchKeysIfMissing !== false,
mpdCacheTtlMinutes: Math.min(
24 * 60,
Math.max(15, n(raw.mpdCacheTtlMinutes, d.mpdCacheTtlMinutes))
),
cdmTimeoutMs: Math.min(180_000, Math.max(10_000, n(raw.cdmTimeoutMs, d.cdmTimeoutMs))),
useOdidoWvd: raw.useOdidoWvd !== false,
};
}
export type ViaplayEventSnapshot = {
guid: string;
mediaGuid: string;
title: string;
subtitle: string | null;
category: string | null;
sportLabel: string | null;
sportBucket: string | null;
startMs: number;
endMs: number;
imageUrl: string | null;
pageUrl: string | null;
channelName: string | null;
stationId: string | null;
mpdUrl: string | null;
mpdCachedAt: string | null;
hasPssh: boolean;
hasKeys: boolean;
accountIds: string[];
lastPlayError: string | null;
lastPlayAt: string | null;
};
export function isMpdCacheFresh(
cachedAt: string | null | undefined,
ttlMinutes: number,
nowMs = Date.now()
): boolean {
if (!cachedAt) return false;
const t = Date.parse(cachedAt);
if (!Number.isFinite(t)) return false;
return nowMs - t < ttlMinutes * 60_000;
}

View file

@ -857,6 +857,19 @@ export async function listScheduleEvents(
/* F1TV sync mag ontbreken */
}
try {
const { listViaplayScheduleEvents } = await import("../viaplay/events");
const vp = await listViaplayScheduleEvents(nowMs);
if (vp.length) {
const existingIds = new Set(mapped.map((e) => e.id));
for (const event of vp) {
if (!existingIds.has(event.id)) mapped.push(event);
}
}
} catch {
/* Viaplay sync mag ontbreken */
}
mapped.sort((a, b) => {
if (a.phase === "live" && b.phase !== "live") return -1;
if (b.phase === "live" && a.phase !== "live") return 1;
@ -902,6 +915,16 @@ export async function getScheduleEvent(
return pub;
}
if (id.toLowerCase().startsWith("viaplay:")) {
const { getViaplayScheduleEvent } = await import("../viaplay/events");
const pub = await getViaplayScheduleEvent(id);
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
}
return pub;
}
const listed = await listScheduleEvents(config, access);
const hit = listed.events.find((e) => e.id === id);
if (hit) return hit;
@ -985,6 +1008,39 @@ export async function getScheduleEventPlay(
};
}
if (id.toLowerCase().startsWith("viaplay:")) {
const { getViaplayScheduleEvent } = await import("../viaplay/events");
const { resolveViaplayPlay } = await import("../viaplay/play");
const pub = await getViaplayScheduleEvent(id);
if (!pub) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
if (!providerAllowed(pub.provider, disabledGlobal, access?.allowedProviders ?? null)) {
throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
}
if (pub.phase === "ended") {
throw new AppError("GONE", "Dit event is afgelopen", 410);
}
if (pub.phase === "upcoming") {
throw new AppError("NOT_READY", "Dit event is nog niet begonnen", 409);
}
const played = await resolveViaplayPlay(config.SESSION_SECRET, id);
if (!played.streamUrl) {
throw new AppError("NOT_READY", "Stream start binnenkort", 409);
}
return {
eventId: pub.id,
name: pub.name,
logoUrl: pub.logoUrl ?? pub.imagePortrait,
streamUrl: played.streamUrl,
format: played.format,
fallbackStreamUrl: null,
fallbackFormat: null,
drm: played.keys.length
? { type: "clearkey" as const, keys: played.keys }
: null,
watchSessionId: await attachWatch(pub),
};
}
const raw = await loadRawEvents(config);
const found = raw.events.find((e) => str(e.id) === id);
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);