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:
parent
85da6f59ad
commit
57ac2d4435
15 changed files with 2930 additions and 4 deletions
|
|
@ -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>
|
||||
|
|
|
|||
636
apps/admin-ui/src/app/scripts/ViaplayPanel.tsx
Normal file
636
apps/admin-ui/src/app/scripts/ViaplayPanel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
apps/master-api/scripts/viaplay_cdm.py
Normal file
53
apps/master-api/scripts/viaplay_cdm.py
Normal 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)
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
234
apps/master-api/src/viaplay/accounts.ts
Normal file
234
apps/master-api/src/viaplay/accounts.ts
Normal 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`;
|
||||
}
|
||||
73
apps/master-api/src/viaplay/cdm.ts
Normal file
73
apps/master-api/src/viaplay/cdm.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
439
apps/master-api/src/viaplay/client.ts
Normal file
439
apps/master-api/src/viaplay/client.ts
Normal 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)");
|
||||
}
|
||||
80
apps/master-api/src/viaplay/events.ts
Normal file
80
apps/master-api/src/viaplay/events.ts
Normal 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);
|
||||
}
|
||||
257
apps/master-api/src/viaplay/play.ts
Normal file
257
apps/master-api/src/viaplay/play.ts
Normal 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);
|
||||
}
|
||||
120
apps/master-api/src/viaplay/refresh.ts
Normal file
120
apps/master-api/src/viaplay/refresh.ts
Normal 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);
|
||||
}
|
||||
452
apps/master-api/src/viaplay/settings.ts
Normal file
452
apps/master-api/src/viaplay/settings.ts
Normal 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) },
|
||||
});
|
||||
}
|
||||
172
apps/master-api/src/viaplay/sync.ts
Normal file
172
apps/master-api/src/viaplay/sync.ts
Normal 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
|
||||
);
|
||||
}
|
||||
202
apps/master-api/src/viaplay/tweaks.ts
Normal file
202
apps/master-api/src/viaplay/tweaks.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue