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 { Nav, useAuth } from "@/components/Nav";
|
||||||
import { OdidoPanel, type OdidoOverview } from "./OdidoPanel";
|
import { OdidoPanel, type OdidoOverview } from "./OdidoPanel";
|
||||||
import { F1tvPanel, type F1tvOverview } from "./F1tvPanel";
|
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 }> = [
|
const TABS: Array<{ id: ScriptTab; label: string; short: string }> = [
|
||||||
{ id: "odido", label: "Odido", short: "Odido" },
|
{ id: "odido", label: "Odido", short: "Odido" },
|
||||||
{ id: "f1tv", label: "F1TV", short: "F1" },
|
{ id: "f1tv", label: "F1TV", short: "F1" },
|
||||||
|
{ id: "viaplay", label: "Viaplay", short: "Via" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ScriptsPageInner() {
|
export default function ScriptsPageInner() {
|
||||||
|
|
@ -19,16 +21,19 @@ export default function ScriptsPageInner() {
|
||||||
const search = useSearchParams();
|
const search = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const tabParam = search.get("tab");
|
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 [odidoOverview, setOdidoOverview] = useState<OdidoOverview | null>(null);
|
||||||
const [f1tvOverview, setF1tvOverview] = useState<F1tvOverview | null>(null);
|
const [f1tvOverview, setF1tvOverview] = useState<F1tvOverview | null>(null);
|
||||||
|
const [viaplayOverview, setViaplayOverview] = useState<ViaplayOverview | null>(null);
|
||||||
|
|
||||||
const loadOverviews = useCallback(async () => {
|
const loadOverviews = useCallback(async () => {
|
||||||
try {
|
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/odido", { credentials: "include" }).then((r) => r.json()),
|
||||||
fetch("/api/v1/admin/scripts/f1tv", { 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) {
|
if (o && !o.error) {
|
||||||
setOdidoOverview({
|
setOdidoOverview({
|
||||||
|
|
@ -48,6 +53,15 @@ export default function ScriptsPageInner() {
|
||||||
lastSyncAt: f.lastSyncAt ?? null,
|
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 {
|
} catch {
|
||||||
/* panels load own errors */
|
/* panels load own errors */
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +161,35 @@ export default function ScriptsPageInner() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<nav className="settings-tabs" aria-label="Scripts">
|
<nav className="settings-tabs" aria-label="Scripts">
|
||||||
|
|
@ -165,8 +208,10 @@ export default function ScriptsPageInner() {
|
||||||
|
|
||||||
{active === "odido" ? (
|
{active === "odido" ? (
|
||||||
<OdidoPanel onChanged={setOdidoOverview} />
|
<OdidoPanel onChanged={setOdidoOverview} />
|
||||||
) : (
|
) : active === "f1tv" ? (
|
||||||
<F1tvPanel onChanged={setF1tvOverview} />
|
<F1tvPanel onChanged={setF1tvOverview} />
|
||||||
|
) : (
|
||||||
|
<ViaplayPanel onChanged={setViaplayOverview} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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);
|
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 ---
|
// --- DRM key database ---
|
||||||
app.get("/api/v1/admin/drm-keys", { preHandler: requireAdmin }, async (request) => {
|
app.get("/api/v1/admin/drm-keys", { preHandler: requireAdmin }, async (request) => {
|
||||||
const q = request.query as { search?: string; source?: string; limit?: string };
|
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 { startIptvEpgWarmup } from "./viewer/iptv-client";
|
||||||
import { applyIptvEpgUrlsToRuntime } from "./settings/iptv";
|
import { applyIptvEpgUrlsToRuntime } from "./settings/iptv";
|
||||||
import { startOdidoMpdRefreshWorker } from "./odido/refresh";
|
import { startOdidoMpdRefreshWorker } from "./odido/refresh";
|
||||||
|
import { startViaplayPreLiveWorker } from "./viaplay/refresh";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
@ -108,6 +109,7 @@ async function main() {
|
||||||
startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS);
|
startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS);
|
||||||
startIptvEpgWarmup(config);
|
startIptvEpgWarmup(config);
|
||||||
startOdidoMpdRefreshWorker(() => config.SESSION_SECRET);
|
startOdidoMpdRefreshWorker(() => config.SESSION_SECRET);
|
||||||
|
startViaplayPreLiveWorker(() => config.SESSION_SECRET);
|
||||||
|
|
||||||
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
|
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
|
||||||
void nodeConnectionManager.handleConnection(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 */
|
/* 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) => {
|
mapped.sort((a, b) => {
|
||||||
if (a.phase === "live" && b.phase !== "live") return -1;
|
if (a.phase === "live" && b.phase !== "live") return -1;
|
||||||
if (b.phase === "live" && a.phase !== "live") return 1;
|
if (b.phase === "live" && a.phase !== "live") return 1;
|
||||||
|
|
@ -902,6 +915,16 @@ export async function getScheduleEvent(
|
||||||
return pub;
|
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 listed = await listScheduleEvents(config, access);
|
||||||
const hit = listed.events.find((e) => e.id === id);
|
const hit = listed.events.find((e) => e.id === id);
|
||||||
if (hit) return hit;
|
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 raw = await loadRawEvents(config);
|
||||||
const found = raw.events.find((e) => str(e.id) === id);
|
const found = raw.events.find((e) => str(e.id) === id);
|
||||||
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
if (!found) throw new AppError("NOT_FOUND", "Event niet gevonden", 404);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue