Add Odido live provider with admin WVD upload and auto-migrate on deploy.
This commit is contained in:
parent
8bfa8935b0
commit
03e5c9c661
14 changed files with 1284 additions and 11 deletions
|
|
@ -7,6 +7,7 @@ type LiveListSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
provider?: string | null;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
channelCount: number;
|
channelCount: number;
|
||||||
|
|
@ -16,6 +17,9 @@ type LiveListSummary = {
|
||||||
|
|
||||||
type LiveChannelRow = {
|
type LiveChannelRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
externalId?: string | null;
|
||||||
|
mediaId?: string | null;
|
||||||
|
contentId?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
mpdUrl: string;
|
mpdUrl: string;
|
||||||
|
|
@ -32,6 +36,7 @@ type LiveListDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
provider?: string | null;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
channels: LiveChannelRow[];
|
channels: LiveChannelRow[];
|
||||||
|
|
@ -77,6 +82,7 @@ function formFromChannel(c: LiveChannelRow): ChannelForm {
|
||||||
/** Korte preview in zender-overzicht (volledige URL via title/tooltip). */
|
/** Korte preview in zender-overzicht (volledige URL via title/tooltip). */
|
||||||
function shortUrl(url: string, max = 42): string {
|
function shortUrl(url: string, max = 42): string {
|
||||||
const s = url.trim();
|
const s = url.trim();
|
||||||
|
if (s.startsWith("odido://")) return "Odido (live resolve)";
|
||||||
if (s.length <= max) return s;
|
if (s.length <= max) return s;
|
||||||
try {
|
try {
|
||||||
const u = new URL(s);
|
const u = new URL(s);
|
||||||
|
|
@ -104,6 +110,8 @@ export default function LiveListsPage() {
|
||||||
const [editingChannelId, setEditingChannelId] = useState<string | null>(null);
|
const [editingChannelId, setEditingChannelId] = useState<string | null>(null);
|
||||||
const [channelForm, setChannelForm] = useState<ChannelForm>(emptyChannelForm());
|
const [channelForm, setChannelForm] = useState<ChannelForm>(emptyChannelForm());
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [syncBusy, setSyncBusy] = useState(false);
|
||||||
|
const [odidoConfigured, setOdidoConfigured] = useState(false);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -133,6 +141,10 @@ export default function LiveListsPage() {
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setLogoOptions(d.logos ?? []))
|
.then((d) => setLogoOptions(d.logos ?? []))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
fetch("/api/v1/admin/scripts/odido", { credentials: "include" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => setOdidoConfigured(!!d.configured && d.enabled !== false))
|
||||||
|
.catch(() => setOdidoConfigured(false));
|
||||||
}, [loadLists]);
|
}, [loadLists]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -248,6 +260,87 @@ export default function LiveListsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function linkOdido() {
|
||||||
|
if (!selectedId) return;
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/v1/admin/live-lists/${selectedId}/odido/link`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error?.message ?? "Odido koppelen mislukt");
|
||||||
|
setMsg("Odido gekoppeld aan deze lijst");
|
||||||
|
await loadLists();
|
||||||
|
await loadDetail(selectedId);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(String((e as Error).message ?? e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkOdido() {
|
||||||
|
if (!selectedId || !detail) return;
|
||||||
|
if (!confirm("Odido ontkoppelen? Bestaande zenders blijven staan.")) return;
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/v1/admin/live-lists/${selectedId}/odido/unlink`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error?.message ?? "Ontkoppelen mislukt");
|
||||||
|
setMsg("Odido ontkoppeld");
|
||||||
|
await loadLists();
|
||||||
|
await loadDetail(selectedId);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(String((e as Error).message ?? e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncOdido() {
|
||||||
|
if (!selectedId) return;
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Volledige Odido-sync starten? Alleen zenders in je abonnement (geen radio). Dit kan enkele minuten duren."
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSyncBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
setMsg("Odido-sync bezig…");
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/v1/admin/live-lists/${selectedId}/odido/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error?.message ?? "Sync mislukt");
|
||||||
|
const parts = [
|
||||||
|
d.added != null ? `+${d.added} nieuw` : null,
|
||||||
|
d.updated != null ? `${d.updated} bijgewerkt` : null,
|
||||||
|
d.disabled != null ? `${d.disabled} uit` : null,
|
||||||
|
d.skipped != null ? `${d.skipped} overgeslagen` : null,
|
||||||
|
d.failed != null ? `${d.failed} mislukt` : null,
|
||||||
|
d.durationMs != null ? `${Math.round(d.durationMs / 1000)}s` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
setMsg(`Odido-sync klaar: ${parts.join(" · ") || "ok"}`);
|
||||||
|
await loadLists();
|
||||||
|
await loadDetail(selectedId);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(String((e as Error).message ?? e));
|
||||||
|
setMsg(null);
|
||||||
|
} finally {
|
||||||
|
setSyncBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startCreateChannel() {
|
function startCreateChannel() {
|
||||||
setEditingChannelId("new");
|
setEditingChannelId("new");
|
||||||
setChannelForm(emptyChannelForm());
|
setChannelForm(emptyChannelForm());
|
||||||
|
|
@ -345,7 +438,7 @@ export default function LiveListsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted page-lead">
|
<p className="muted page-lead">
|
||||||
ClearKey/DASH-zenderlijsten (MPD + KID + KEY). Koppel per gebruiker onder Gebruikers.
|
ClearKey/DASH-zenderlijsten (MPD + KID + KEY). Koppel Odido via Scripts → Live-lijsten.
|
||||||
EPG komt uit de cluster-XMLTV (Instellingen → TV).
|
EPG komt uit de cluster-XMLTV (Instellingen → TV).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -389,6 +482,7 @@ export default function LiveListsPage() {
|
||||||
{l.name}
|
{l.name}
|
||||||
<span className="muted" style={{ display: "block", fontSize: "0.8rem" }}>
|
<span className="muted" style={{ display: "block", fontSize: "0.8rem" }}>
|
||||||
{l.channelCount} zenders · {l.enabled ? "aan" : "uit"}
|
{l.channelCount} zenders · {l.enabled ? "aan" : "uit"}
|
||||||
|
{l.provider === "odido" ? " · Odido" : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -432,12 +526,69 @@ export default function LiveListsPage() {
|
||||||
<button type="button" className="danger" disabled={busy} onClick={() => void deleteList()}>
|
<button type="button" className="danger" disabled={busy} onClick={() => void deleteList()}>
|
||||||
Verwijderen
|
Verwijderen
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled={busy} onClick={startCreateChannel}>
|
<button type="button" disabled={busy || syncBusy} onClick={startCreateChannel}>
|
||||||
Zender toevoegen
|
Zender toevoegen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: "1rem" }}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>Odido-script</h3>
|
||||||
|
{detail.provider === "odido" ? (
|
||||||
|
<>
|
||||||
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
|
Deze lijst is gekoppeld aan Odido. “Volledig ophalen” checkt welke TV-zenders
|
||||||
|
in je abonnement zitten (geen radio) en vult/werkt de lijst bij.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={busy || syncBusy || !odidoConfigured}
|
||||||
|
onClick={() => void syncOdido()}
|
||||||
|
>
|
||||||
|
{syncBusy ? "Bezig met ophalen…" : "Volledig ophalen"}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={busy || syncBusy} onClick={() => void unlinkOdido()}>
|
||||||
|
Ontkoppelen
|
||||||
|
</button>
|
||||||
|
<a className="btn" href="/scripts" style={{ display: "inline-flex", alignItems: "center" }}>
|
||||||
|
Instellingen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{!odidoConfigured && (
|
||||||
|
<p className="err-banner" style={{ marginTop: "0.75rem" }}>
|
||||||
|
Stel eerst klantnummer, pincode en WVD in onder Scripts.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
|
Koppel Odido om automatisch TV-zenders uit je abonnement in deze lijst te zetten.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={busy || !odidoConfigured}
|
||||||
|
onClick={() => void linkOdido()}
|
||||||
|
>
|
||||||
|
Odido koppelen
|
||||||
|
</button>
|
||||||
|
<a className="btn" href="/scripts" style={{ display: "inline-flex", alignItems: "center" }}>
|
||||||
|
Scripts / Odido-instellingen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{!odidoConfigured && (
|
||||||
|
<p className="muted" style={{ marginTop: "0.75rem" }}>
|
||||||
|
Nog niet geconfigureerd — vul eerst Scripts → Odido in.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{editingChannelId && (
|
{editingChannelId && (
|
||||||
<div className="card" style={{ marginBottom: "1rem" }}>
|
<div className="card" style={{ marginBottom: "1rem" }}>
|
||||||
<h3 style={{ marginTop: 0 }}>
|
<h3 style={{ marginTop: 0 }}>
|
||||||
|
|
|
||||||
258
apps/admin-ui/src/app/scripts/page.tsx
Normal file
258
apps/admin-ui/src/app/scripts/page.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Nav, useAuth } from "@/components/Nav";
|
||||||
|
|
||||||
|
type OdidoSettings = {
|
||||||
|
configured: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
username: string;
|
||||||
|
hasPin: boolean;
|
||||||
|
hasWvd: boolean;
|
||||||
|
wvdPath: string;
|
||||||
|
wvdUploadedAt: string | null;
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
lastSyncSummary: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readFileAsBase64(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = String(reader.result ?? "");
|
||||||
|
const comma = result.indexOf(",");
|
||||||
|
resolve(comma >= 0 ? result.slice(comma + 1) : result);
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error("Bestand lezen mislukt"));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScriptsPage() {
|
||||||
|
useAuth();
|
||||||
|
const [odido, setOdido] = useState<OdidoSettings | null>(null);
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [pin, setPin] = useState("");
|
||||||
|
const [wvdPath, setWvdPath] = useState("");
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const wvdInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const r = await fetch("/api/v1/admin/scripts/odido", { credentials: "include" });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error?.message ?? "Odido-instellingen laden mislukt");
|
||||||
|
setOdido(d);
|
||||||
|
setUsername(d.username ?? "");
|
||||||
|
setWvdPath(d.wvdPath ?? "");
|
||||||
|
setEnabled(d.enabled !== false);
|
||||||
|
setPin("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load().catch((e) => setErr(String(e.message ?? e)));
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function saveOdido(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
username: username.trim(),
|
||||||
|
wvdPath: wvdPath.trim(),
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
|
if (pin.trim()) body.pin = pin.trim();
|
||||||
|
const r = await fetch("/api/v1/admin/scripts/odido", {
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error?.message ?? "Opslaan mislukt");
|
||||||
|
setOdido(d);
|
||||||
|
setPin("");
|
||||||
|
setMsg("Odido-instellingen opgeslagen");
|
||||||
|
} catch (e) {
|
||||||
|
setErr(String((e as Error).message ?? e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadWvd(file: File | null) {
|
||||||
|
if (!file) return;
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
throw new Error("WVD-bestand mag max. 2 MB zijn");
|
||||||
|
}
|
||||||
|
const contentBase64 = await readFileAsBase64(file);
|
||||||
|
const r = await fetch("/api/v1/admin/scripts/odido/wvd", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ contentBase64 }),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.error?.message ?? "WVD-upload mislukt");
|
||||||
|
setOdido(d);
|
||||||
|
setWvdPath(d.wvdPath ?? "");
|
||||||
|
setMsg(`WVD.wvd geüpload (${d.bytes ?? file.size} bytes)`);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(String((e as Error).message ?? e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
if (wvdInputRef.current) wvdInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = odido?.lastSyncSummary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Nav />
|
||||||
|
<main className="page">
|
||||||
|
<div className="container">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-kicker">Integraties</div>
|
||||||
|
<h1>Scripts</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="muted page-lead">
|
||||||
|
Provider-scripts voor live-lijsten. Stel hier Odido in, koppel daarna een lijst onder{" "}
|
||||||
|
<Link href="/live-lists">Live-lijsten</Link> en doe daar “Volledig ophalen”.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{msg && (
|
||||||
|
<p className="ok-banner" role="status">
|
||||||
|
{msg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{err && (
|
||||||
|
<p className="err-banner" role="alert">
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card" style={{ maxWidth: 640 }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Odido</h2>
|
||||||
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
|
Haalt TV-zenders uit je abonnement (geen radio). Play gebruikt verse MPD + gecachte
|
||||||
|
ClearKey; CDM alleen bij key-fout.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={saveOdido} className="form-group">
|
||||||
|
<label className="field">
|
||||||
|
Klantnummer
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="TV-klantnummer"
|
||||||
|
disabled={busy}
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
Pincode
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pin}
|
||||||
|
onChange={(e) => setPin(e.target.value)}
|
||||||
|
placeholder={odido?.hasPin ? "•••• (leeg = behouden)" : "4-cijferige pincode"}
|
||||||
|
disabled={busy}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="field">
|
||||||
|
<span>WVD.wvd (Widevine device)</span>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center", marginTop: 6 }}>
|
||||||
|
<input
|
||||||
|
ref={wvdInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".wvd,application/octet-stream"
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => void uploadWvd(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="muted" style={{ margin: "6px 0 0", fontSize: "0.85rem" }}>
|
||||||
|
{odido?.hasWvd ? (
|
||||||
|
<>
|
||||||
|
Aanwezig op server
|
||||||
|
{odido.wvdUploadedAt
|
||||||
|
? ` · geüpload ${new Date(odido.wvdUploadedAt).toLocaleString("nl-NL")}`
|
||||||
|
: ""}
|
||||||
|
{odido.wvdPath ? ` · ${odido.wvdPath}` : ""}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Nog niet geüpload — kies je lokale WVD.wvd (max. 2 MB).</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="field">
|
||||||
|
Pad op de server (optioneel)
|
||||||
|
<input
|
||||||
|
value={wvdPath}
|
||||||
|
onChange={(e) => setWvdPath(e.target.value)}
|
||||||
|
placeholder="/data/odido/WVD.wvd"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
Odido-integratie actief
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn-primary" disabled={busy}>
|
||||||
|
Opslaan
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="muted" style={{ marginTop: "1rem", fontSize: "0.9rem" }}>
|
||||||
|
Status:{" "}
|
||||||
|
{odido?.configured ? (
|
||||||
|
<span className="ok-banner" style={{ display: "inline", padding: "2px 8px" }}>
|
||||||
|
geconfigureerd
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>nog niet compleet (klantnummer + pincode + WVD)</span>
|
||||||
|
)}
|
||||||
|
{odido?.lastSyncAt && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
Laatste sync: {new Date(odido.lastSyncAt).toLocaleString("nl-NL")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{summary && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
Resultaat:{" "}
|
||||||
|
{[
|
||||||
|
summary.added != null ? `+${String(summary.added)}` : null,
|
||||||
|
summary.updated != null ? `~${String(summary.updated)}` : null,
|
||||||
|
summary.disabled != null ? `uit ${String(summary.disabled)}` : null,
|
||||||
|
summary.skipped != null ? `skip ${String(summary.skipped)}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || JSON.stringify(summary)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ const desktopLinks = [
|
||||||
{ href: "/downloads", label: "Downloads" },
|
{ href: "/downloads", label: "Downloads" },
|
||||||
{ href: "/streams", label: "Streams" },
|
{ href: "/streams", label: "Streams" },
|
||||||
{ href: "/live-lists", label: "Live-lijsten" },
|
{ href: "/live-lists", label: "Live-lijsten" },
|
||||||
|
{ href: "/scripts", label: "Scripts" },
|
||||||
{ href: "/viewers", label: "Gebruikers" },
|
{ href: "/viewers", label: "Gebruikers" },
|
||||||
{ href: "/settings", label: "Instellingen" },
|
{ href: "/settings", label: "Instellingen" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Odido provider metadata and stable channel references
|
||||||
|
ALTER TABLE "live_channel_lists" ADD COLUMN "provider" TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE "live_channels"
|
||||||
|
ADD COLUMN "external_id" TEXT,
|
||||||
|
ADD COLUMN "media_id" TEXT,
|
||||||
|
ADD COLUMN "content_id" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX "live_channels_list_id_external_id_idx"
|
||||||
|
ON "live_channels"("list_id", "external_id");
|
||||||
|
|
@ -312,6 +312,7 @@ model LiveChannelList {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
|
provider String? @map("provider")
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
@ -327,6 +328,9 @@ model LiveChannelList {
|
||||||
model LiveChannel {
|
model LiveChannel {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
listId String @map("list_id")
|
listId String @map("list_id")
|
||||||
|
externalId String? @map("external_id")
|
||||||
|
mediaId String? @map("media_id")
|
||||||
|
contentId String? @map("content_id")
|
||||||
name String
|
name String
|
||||||
logoUrl String? @map("logo_url")
|
logoUrl String? @map("logo_url")
|
||||||
mpdUrl String @map("mpd_url") @db.Text
|
mpdUrl String @map("mpd_url") @db.Text
|
||||||
|
|
@ -344,6 +348,7 @@ model LiveChannel {
|
||||||
list LiveChannelList @relation(fields: [listId], references: [id], onDelete: Cascade)
|
list LiveChannelList @relation(fields: [listId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([listId, sortOrder])
|
@@index([listId, sortOrder])
|
||||||
|
@@index([listId, externalId])
|
||||||
@@map("live_channels")
|
@@map("live_channels")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
52
apps/master-api/scripts/odido_cdm.py
Normal file
52
apps/master-api/scripts/odido_cdm.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Odido Widevine helper. 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": "*/*",
|
||||||
|
"AcquireLicense.CustomData": data["licToken"],
|
||||||
|
"CADeviceType": "Widevine OTT client",
|
||||||
|
"Origin": "https://tv.odido.nl",
|
||||||
|
"Referer": "https://tv.odido.nl/inloggen/index.html",
|
||||||
|
"User-Agent": data["userAgent"],
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
}
|
||||||
|
response = requests.post(
|
||||||
|
data["licUrl"],
|
||||||
|
headers=headers,
|
||||||
|
cookies=data.get("cookies") or {},
|
||||||
|
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)
|
||||||
|
|
@ -1607,6 +1607,32 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
return { movie };
|
return { movie };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Odido script/provider settings ---
|
||||||
|
app.get("/api/v1/admin/scripts/odido", { preHandler: requireAdmin }, async () => {
|
||||||
|
const { getOdidoSettingsPublic } = await import("../odido/settings");
|
||||||
|
return getOdidoSettingsPublic();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/v1/admin/scripts/odido", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const body = request.body as {
|
||||||
|
username?: string;
|
||||||
|
pin?: string;
|
||||||
|
wvdPath?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
const { upsertOdidoSettings } = await import("../odido/settings");
|
||||||
|
return upsertOdidoSettings(config.SESSION_SECRET, body);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/scripts/odido/wvd", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const body = request.body as { contentBase64?: string };
|
||||||
|
if (!body.contentBase64?.trim()) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "contentBase64 verplicht", 400);
|
||||||
|
}
|
||||||
|
const { saveOdidoWvdUpload } = await import("../odido/settings");
|
||||||
|
return saveOdidoWvdUpload(body.contentBase64);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Custom ClearKey/DASH live channel lists ---
|
// --- Custom ClearKey/DASH live channel lists ---
|
||||||
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
|
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
|
||||||
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels");
|
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels");
|
||||||
|
|
@ -1637,11 +1663,46 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
|
|
||||||
app.patch("/api/v1/admin/live-lists/:id", { preHandler: requireAdmin }, async (request) => {
|
app.patch("/api/v1/admin/live-lists/:id", { preHandler: requireAdmin }, async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const body = request.body as { name?: string; sortOrder?: number; enabled?: boolean };
|
const body = request.body as {
|
||||||
|
name?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
provider?: string | null;
|
||||||
|
};
|
||||||
const { updateLiveChannelList } = await import("../viewer/live-channels");
|
const { updateLiveChannelList } = await import("../viewer/live-channels");
|
||||||
return updateLiveChannelList(id, body);
|
return updateLiveChannelList(id, body);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/v1/admin/live-lists/:id/odido/link",
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const { updateLiveChannelList } = await import("../viewer/live-channels");
|
||||||
|
return updateLiveChannelList(id, { provider: "odido" });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/v1/admin/live-lists/:id/odido/unlink",
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const { updateLiveChannelList } = await import("../viewer/live-channels");
|
||||||
|
return updateLiveChannelList(id, { provider: null });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/v1/admin/live-lists/:id/odido/sync",
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const { syncOdidoList } = await import("../odido/sync");
|
||||||
|
return syncOdidoList(id, config.SESSION_SECRET);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
app.delete("/api/v1/admin/live-lists/:id", { preHandler: requireAdmin }, async (request) => {
|
app.delete("/api/v1/admin/live-lists/:id", { preHandler: requireAdmin }, async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const { deleteLiveChannelList } = await import("../viewer/live-channels");
|
const { deleteLiveChannelList } = await import("../viewer/live-channels");
|
||||||
|
|
|
||||||
60
apps/master-api/src/odido/cdm.ts
Normal file
60
apps/master-api/src/odido/cdm.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import type { OdidoCookies } from "./client";
|
||||||
|
|
||||||
|
export type OdidoCdmInput = {
|
||||||
|
wvdPath: string;
|
||||||
|
pssh: string;
|
||||||
|
licUrl: string;
|
||||||
|
licToken: string;
|
||||||
|
cookies: OdidoCookies;
|
||||||
|
userAgent: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function extractOdidoKeys(input: OdidoCdmInput): Promise<string[]> {
|
||||||
|
const cwdScript = resolve(process.cwd(), "scripts", "odido_cdm.py");
|
||||||
|
const script = existsSync(cwdScript)
|
||||||
|
? cwdScript
|
||||||
|
: resolve(__dirname, "..", "..", "scripts", "odido_cdm.py");
|
||||||
|
const python = process.env.PYTHON_ODIDO?.trim() || "python3";
|
||||||
|
|
||||||
|
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("Odido CDM-time-out"));
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
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(`Odido CDM kon niet starten: ${error.message}`));
|
||||||
|
});
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`Odido 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(`Odido CDM-uitvoer ongeldig: ${(error as Error).message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.stdin.end(JSON.stringify(input));
|
||||||
|
});
|
||||||
|
}
|
||||||
254
apps/master-api/src/odido/client.ts
Normal file
254
apps/master-api/src/odido/client.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
const BASE_URL = "https://tv.odido.nl";
|
||||||
|
export const ODIDO_USER_AGENT =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||||
|
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||||
|
const DEFAULT_DEVICE_ID = "MzkzNjIwMTU1NjAxMDNydA==";
|
||||||
|
const CACHE_MS = 20 * 60_000;
|
||||||
|
|
||||||
|
export type OdidoCookies = Record<string, string>;
|
||||||
|
|
||||||
|
export type OdidoChannel = {
|
||||||
|
ID?: string;
|
||||||
|
name?: string;
|
||||||
|
channelName?: string;
|
||||||
|
contentType?: string;
|
||||||
|
channelNO?: string | number;
|
||||||
|
channelNo?: string | number;
|
||||||
|
contentID?: string;
|
||||||
|
contentId?: string;
|
||||||
|
logo?: { icons?: string[] } | string | null;
|
||||||
|
picture?: { icons?: string[] } | null;
|
||||||
|
pictures?: Array<{ href?: string; url?: string; posterType?: string }>;
|
||||||
|
physicalChannels?: Array<{
|
||||||
|
ID?: string;
|
||||||
|
mediaName?: string;
|
||||||
|
channelNO?: string | number;
|
||||||
|
contentID?: string;
|
||||||
|
contentId?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OdidoPlayResult = {
|
||||||
|
playUrl: string;
|
||||||
|
licenseUrl: string | null;
|
||||||
|
licenseToken: string | null;
|
||||||
|
contentId: string | null;
|
||||||
|
cookies: OdidoCookies;
|
||||||
|
userAgent: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Session = { cookies: OdidoCookies; expiresAt: number };
|
||||||
|
const sessions = new Map<string, Session>();
|
||||||
|
|
||||||
|
function cookieHeader(cookies: OdidoCookies): string {
|
||||||
|
return Object.entries(cookies)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseCookies(response: Response): OdidoCookies {
|
||||||
|
const headers = response.headers as Headers & { getSetCookie?: () => string[] };
|
||||||
|
const values = headers.getSetCookie?.() ?? [response.headers.get("set-cookie") ?? ""];
|
||||||
|
const result: OdidoCookies = {};
|
||||||
|
for (const value of values) {
|
||||||
|
const match = /^([^=;,\s]+)=([^;,]*)/.exec(value);
|
||||||
|
if (match) result[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSuccess(body: Record<string, unknown>): boolean {
|
||||||
|
const code = body.retCode;
|
||||||
|
return code === undefined || code === null || code === "" || code === 0 ||
|
||||||
|
code === "0" || code === "000000000";
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiError(operation: string, body: Record<string, unknown>): Error {
|
||||||
|
const message = String(body.retMsg ?? body.resultDesc ?? body.retCode ?? "onbekende fout");
|
||||||
|
return new Error(`Odido ${operation} mislukt: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(
|
||||||
|
path: string,
|
||||||
|
from: "throughMSAAccess" | "inMSAAccess",
|
||||||
|
data: unknown,
|
||||||
|
cookies?: OdidoCookies
|
||||||
|
): Promise<{ body: Record<string, unknown>; cookies: OdidoCookies }> {
|
||||||
|
const response = await fetch(`${BASE_URL}${path}?from=${from}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/plain;charset=UTF-8",
|
||||||
|
Origin: BASE_URL,
|
||||||
|
Referer: `${BASE_URL}/inloggen/index.html`,
|
||||||
|
"User-Agent": ODIDO_USER_AGENT,
|
||||||
|
...(cookies?.CSRFSESSION ? { X_CSRFToken: cookies.CSRFSESSION } : {}),
|
||||||
|
...(cookies ? { Cookie: cookieHeader(cookies) } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
signal: AbortSignal.timeout(path.includes("QueryAllChannel") ? 60_000 : 30_000),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(text) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Odido gaf geen geldige JSON terug (${response.status})`);
|
||||||
|
}
|
||||||
|
if (!response.ok) throw apiError(path.split("/").pop() ?? "request", body);
|
||||||
|
return { body, cookies: { ...cookies, ...responseCookies(response) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithDevice(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
deviceId: string,
|
||||||
|
switched = false
|
||||||
|
): Promise<OdidoCookies> {
|
||||||
|
const { body, cookies } = await post(
|
||||||
|
"/VSP/V3/Authenticate",
|
||||||
|
"throughMSAAccess",
|
||||||
|
{
|
||||||
|
authenticateBasic: {
|
||||||
|
userID: username,
|
||||||
|
userType: "0",
|
||||||
|
needPosterTypes: ["1", "2", "3", "4", "5", "6", "7"],
|
||||||
|
timeZone: "Europe/Amsterdam",
|
||||||
|
isSupportWebpImgFormat: "0",
|
||||||
|
clientPasswd: password,
|
||||||
|
lang: "nl",
|
||||||
|
},
|
||||||
|
authenticateDevice: {
|
||||||
|
physicalDeviceID: deviceId,
|
||||||
|
terminalID: deviceId,
|
||||||
|
deviceModel: "3103_PCClient",
|
||||||
|
CADeviceInfos: [{ CADeviceType: "7", CADeviceID: deviceId }],
|
||||||
|
},
|
||||||
|
authenticateTolerant: {
|
||||||
|
areaCode: "",
|
||||||
|
templateName: "",
|
||||||
|
subnetID: "",
|
||||||
|
bossID: "",
|
||||||
|
userGroup: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const devices = body.devices as Array<{ physicalDeviceID?: string }> | undefined;
|
||||||
|
if (devices?.[0]?.physicalDeviceID && !switched) {
|
||||||
|
return loginWithDevice(username, password, devices[0].physicalDeviceID, true);
|
||||||
|
}
|
||||||
|
if (!isSuccess(body)) throw apiError("inloggen", body);
|
||||||
|
for (const required of ["CSESSIONID", "XSESSIONID", "CSRFSESSION", "JSESSIONID"]) {
|
||||||
|
if (!cookies[required]) throw new Error(`Odido-login mist cookie ${required}`);
|
||||||
|
}
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username: string, password: string): Promise<OdidoCookies> {
|
||||||
|
const cached = sessions.get(username);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.cookies;
|
||||||
|
const cookies = await loginWithDevice(username, password, DEFAULT_DEVICE_ID);
|
||||||
|
sessions.set(username, { cookies, expiresAt: Date.now() + CACHE_MS });
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function heartbeat(cookies: OdidoCookies): Promise<boolean> {
|
||||||
|
const { body } = await post("/VSP/V3/OnLineHeartbeat", "inMSAAccess", {}, cookies);
|
||||||
|
return isSuccess(body) && body.userValid !== false && body.userValid !== "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryAllChannels(cookies: OdidoCookies): Promise<OdidoChannel[]> {
|
||||||
|
const { body } = await post(
|
||||||
|
"/VSP/V3/QueryAllChannel",
|
||||||
|
"inMSAAccess",
|
||||||
|
{ isReturnAllMedia: "0" },
|
||||||
|
cookies
|
||||||
|
);
|
||||||
|
if (!isSuccess(body)) throw apiError("zenderlijst ophalen", body);
|
||||||
|
const channels = Array.isArray(body.channelDetails)
|
||||||
|
? (body.channelDetails as OdidoChannel[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Gidsnummers (channelNO) komen uit DynamicProperties — zelfde bron als de TV-gids.
|
||||||
|
try {
|
||||||
|
const dyn = await post(
|
||||||
|
"/VSP/V3/QueryAllChannelDynamicProperties",
|
||||||
|
"inMSAAccess",
|
||||||
|
{},
|
||||||
|
cookies
|
||||||
|
);
|
||||||
|
const rows = Array.isArray(dyn.body.channelDynamaicProp)
|
||||||
|
? (dyn.body.channelDynamaicProp as Array<{ ID?: string; channelNO?: string | number }>)
|
||||||
|
: [];
|
||||||
|
const byId = new Map(
|
||||||
|
rows
|
||||||
|
.filter((row) => row.ID)
|
||||||
|
.map((row) => [String(row.ID), row.channelNO] as const)
|
||||||
|
);
|
||||||
|
for (const channel of channels) {
|
||||||
|
const id = channel.ID?.trim();
|
||||||
|
if (!id) continue;
|
||||||
|
const no = byId.get(id);
|
||||||
|
if (no !== undefined && no !== null && no !== "") {
|
||||||
|
channel.channelNO = no;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Volgorde is nice-to-have; sync mag zonder channelNO doorgaan.
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playChannel(
|
||||||
|
cookies: OdidoCookies,
|
||||||
|
channelId: string,
|
||||||
|
mediaId: string
|
||||||
|
): Promise<OdidoPlayResult> {
|
||||||
|
const { body, cookies: refreshedCookies } = await post(
|
||||||
|
"/VSP/V3/PlayChannel",
|
||||||
|
"throughMSAAccess",
|
||||||
|
{
|
||||||
|
channelID: channelId,
|
||||||
|
mediaID: mediaId,
|
||||||
|
businessType: "BTV",
|
||||||
|
isReturnProduct: "1",
|
||||||
|
isHTTPS: "1",
|
||||||
|
checkLock: { checkType: "0" },
|
||||||
|
},
|
||||||
|
cookies
|
||||||
|
);
|
||||||
|
const playUrl = typeof body.playURL === "string" ? body.playURL : "";
|
||||||
|
if (!isSuccess(body) || !playUrl) throw apiError("zender starten", body);
|
||||||
|
const authorize = body.authorizeResult as
|
||||||
|
| { triggers?: Array<Record<string, unknown>> }
|
||||||
|
| undefined;
|
||||||
|
const trigger = authorize?.triggers?.[0];
|
||||||
|
return {
|
||||||
|
playUrl,
|
||||||
|
licenseUrl: typeof trigger?.licenseURL === "string" ? trigger.licenseURL : null,
|
||||||
|
licenseToken: typeof trigger?.customData === "string" ? trigger.customData : null,
|
||||||
|
contentId: [body.contentID, body.contentId, trigger?.contentID, trigger?.contentId]
|
||||||
|
.find((value): value is string => typeof value === "string" && !!value) ?? null,
|
||||||
|
cookies: refreshedCookies,
|
||||||
|
userAgent: ODIDO_USER_AGENT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPssh(
|
||||||
|
mpdUrl: string,
|
||||||
|
cookies: OdidoCookies = {}
|
||||||
|
): Promise<string | null> {
|
||||||
|
const response = await fetch(mpdUrl, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": ODIDO_USER_AGENT,
|
||||||
|
...(Object.keys(cookies).length ? { Cookie: cookieHeader(cookies) } : {}),
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Odido MPD ophalen mislukt (${response.status})`);
|
||||||
|
const xml = await response.text();
|
||||||
|
const widevine = xml.match(
|
||||||
|
/<ContentProtection\b[^>]*schemeIdUri=["']urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed["'][\s\S]*?<\/ContentProtection>/i
|
||||||
|
)?.[0];
|
||||||
|
return widevine?.match(/<(?:[\w-]+:)?pssh\b[^>]*>([^<]+)<\/(?:[\w-]+:)?pssh>/i)?.[1]?.trim() ?? null;
|
||||||
|
}
|
||||||
167
apps/master-api/src/odido/settings.ts
Normal file
167
apps/master-api/src/odido/settings.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { mkdir, writeFile, stat } from "node:fs/promises";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { prisma } from "../database/client";
|
||||||
|
import { decryptSecret, encryptSecret } from "../security/crypto";
|
||||||
|
import { AppError } from "../security/errors";
|
||||||
|
|
||||||
|
const ODIDO_ID = "odido";
|
||||||
|
const DEFAULT_WVD_PATH = "/data/odido/WVD.wvd";
|
||||||
|
|
||||||
|
export type OdidoConfig = {
|
||||||
|
wvdPath: string;
|
||||||
|
lastSyncAt?: string;
|
||||||
|
lastSyncSummary?: Record<string, unknown>;
|
||||||
|
wvdUploadedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseConfig(raw: string | null | undefined): OdidoConfig {
|
||||||
|
let parsed: Partial<OdidoConfig> = {};
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as Partial<OdidoConfig>;
|
||||||
|
} catch {
|
||||||
|
parsed = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
wvdPath: parsed.wvdPath?.trim() || process.env.ODIDO_WVD_PATH || DEFAULT_WVD_PATH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOdidoSettingsPublic() {
|
||||||
|
const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
|
||||||
|
const config = parseConfig(row?.configJson);
|
||||||
|
const hasWvd = existsSync(config.wvdPath);
|
||||||
|
return {
|
||||||
|
configured: !!row?.username && !!row.passwordEnc && hasWvd,
|
||||||
|
enabled: row?.enabled ?? true,
|
||||||
|
username: row?.username ?? "",
|
||||||
|
hasPin: !!row?.passwordEnc,
|
||||||
|
hasWvd,
|
||||||
|
wvdPath: config.wvdPath,
|
||||||
|
wvdUploadedAt: config.wvdUploadedAt ?? null,
|
||||||
|
lastSyncAt: config.lastSyncAt ?? null,
|
||||||
|
lastSyncSummary: config.lastSyncSummary ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertOdidoSettings(
|
||||||
|
sessionSecret: string,
|
||||||
|
input: {
|
||||||
|
username?: string;
|
||||||
|
pin?: string;
|
||||||
|
wvdPath?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const existing = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
|
||||||
|
const config = parseConfig(existing?.configJson);
|
||||||
|
if (input.wvdPath !== undefined) {
|
||||||
|
config.wvdPath = input.wvdPath.trim() || process.env.ODIDO_WVD_PATH || DEFAULT_WVD_PATH;
|
||||||
|
}
|
||||||
|
const username =
|
||||||
|
input.username !== undefined ? input.username.trim() || null : existing?.username ?? null;
|
||||||
|
const passwordEnc =
|
||||||
|
input.pin !== undefined
|
||||||
|
? input.pin.trim()
|
||||||
|
? encryptSecret(input.pin.trim(), sessionSecret)
|
||||||
|
: null
|
||||||
|
: existing?.passwordEnc ?? null;
|
||||||
|
|
||||||
|
await prisma.integrationSetting.upsert({
|
||||||
|
where: { id: ODIDO_ID },
|
||||||
|
create: {
|
||||||
|
id: ODIDO_ID,
|
||||||
|
username,
|
||||||
|
passwordEnc,
|
||||||
|
configJson: JSON.stringify(config),
|
||||||
|
enabled: input.enabled ?? true,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
username,
|
||||||
|
passwordEnc,
|
||||||
|
configJson: JSON.stringify(config),
|
||||||
|
enabled: input.enabled ?? existing?.enabled ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return getOdidoSettingsPublic();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sla WVD.wvd op via admin-upload (base64). Max ~2 MiB. */
|
||||||
|
export async function saveOdidoWvdUpload(contentBase64: string) {
|
||||||
|
const cleaned = contentBase64.replace(/^data:.*?;base64,/, "").trim();
|
||||||
|
if (!cleaned) throw new AppError("INVALID_REQUEST", "Lege WVD-upload", 400);
|
||||||
|
let buf: Buffer;
|
||||||
|
try {
|
||||||
|
buf = Buffer.from(cleaned, "base64");
|
||||||
|
} catch {
|
||||||
|
throw new AppError("INVALID_REQUEST", "Ongeldige base64 WVD-data", 400);
|
||||||
|
}
|
||||||
|
if (buf.length < 64) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "WVD-bestand lijkt te klein", 400);
|
||||||
|
}
|
||||||
|
if (buf.length > 2 * 1024 * 1024) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "WVD-bestand mag max. 2 MB zijn", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
|
||||||
|
const config = parseConfig(existing?.configJson);
|
||||||
|
const target = resolve(config.wvdPath || DEFAULT_WVD_PATH);
|
||||||
|
await mkdir(dirname(target), { recursive: true });
|
||||||
|
await writeFile(target, buf);
|
||||||
|
config.wvdPath = target;
|
||||||
|
config.wvdUploadedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
await prisma.integrationSetting.upsert({
|
||||||
|
where: { id: ODIDO_ID },
|
||||||
|
create: {
|
||||||
|
id: ODIDO_ID,
|
||||||
|
configJson: JSON.stringify(config),
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
configJson: JSON.stringify(config),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const st = await stat(target);
|
||||||
|
return {
|
||||||
|
...(await getOdidoSettingsPublic()),
|
||||||
|
bytes: st.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadOdidoSettings(sessionSecret: string) {
|
||||||
|
const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
|
||||||
|
if (!row?.enabled) throw new AppError("INVALID_REQUEST", "Odido-integratie is uitgeschakeld", 400);
|
||||||
|
if (!row.username || !row.passwordEnc) {
|
||||||
|
throw new AppError("INVALID_REQUEST", "Odido-klantnummer en pincode zijn niet ingesteld", 400);
|
||||||
|
}
|
||||||
|
const config = parseConfig(row.configJson);
|
||||||
|
if (!existsSync(config.wvdPath)) {
|
||||||
|
throw new AppError(
|
||||||
|
"INVALID_REQUEST",
|
||||||
|
"WVD.wvd ontbreekt — upload het bestand onder Scripts → Odido",
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
username: row.username,
|
||||||
|
pin: decryptSecret(row.passwordEnc, sessionSecret),
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveOdidoSyncResult(summary: Record<string, unknown>) {
|
||||||
|
const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
|
||||||
|
if (!row) return;
|
||||||
|
const config = parseConfig(row.configJson);
|
||||||
|
config.lastSyncAt = new Date().toISOString();
|
||||||
|
config.lastSyncSummary = summary;
|
||||||
|
await prisma.integrationSetting.update({
|
||||||
|
where: { id: ODIDO_ID },
|
||||||
|
data: { configJson: JSON.stringify(config) },
|
||||||
|
});
|
||||||
|
}
|
||||||
182
apps/master-api/src/odido/sync.ts
Normal file
182
apps/master-api/src/odido/sync.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { prisma } from "../database/client";
|
||||||
|
import { encryptSecret } from "../security/crypto";
|
||||||
|
import { AppError } from "../security/errors";
|
||||||
|
import { extractOdidoKeys } from "./cdm";
|
||||||
|
import {
|
||||||
|
getPssh,
|
||||||
|
login,
|
||||||
|
playChannel,
|
||||||
|
queryAllChannels,
|
||||||
|
type OdidoChannel,
|
||||||
|
} from "./client";
|
||||||
|
import { loadOdidoSettings, saveOdidoSyncResult } from "./settings";
|
||||||
|
|
||||||
|
export type OdidoSyncSummary = {
|
||||||
|
added: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
disabled: number;
|
||||||
|
durationMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function logoUrl(channel: OdidoChannel): string | null {
|
||||||
|
const fromPictureIcons = channel.picture?.icons?.find((url) => !!url?.trim());
|
||||||
|
if (fromPictureIcons) return fromPictureIcons.trim();
|
||||||
|
if (typeof channel.logo === "object" && channel.logo && Array.isArray((channel.logo as { icons?: string[] }).icons)) {
|
||||||
|
const icon = (channel.logo as { icons?: string[] }).icons?.find((url) => !!url?.trim());
|
||||||
|
if (icon) return icon.trim();
|
||||||
|
}
|
||||||
|
const picture = channel.pictures?.find((item) => item.href || item.url);
|
||||||
|
return picture?.href?.trim() || picture?.url?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelSortOrder(channel: OdidoChannel, apiIndex: number): number {
|
||||||
|
const raw = channel.channelNO ?? channel.channelNo ?? channel.physicalChannels?.[0]?.channelNO;
|
||||||
|
const no = typeof raw === "number" ? raw : Number(String(raw ?? "").trim());
|
||||||
|
if (Number.isFinite(no) && no > 0) return Math.trunc(no) * 10;
|
||||||
|
return 100_000 + apiIndex * 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentId(channel: OdidoChannel): string | null {
|
||||||
|
const physical = channel.physicalChannels?.[0];
|
||||||
|
return channel.contentID ?? channel.contentId ??
|
||||||
|
physical?.contentID ?? physical?.contentId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapLimit<T>(
|
||||||
|
values: T[],
|
||||||
|
limit: number,
|
||||||
|
worker: (value: T) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
let cursor = 0;
|
||||||
|
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, async () => {
|
||||||
|
for (;;) {
|
||||||
|
const index = cursor++;
|
||||||
|
if (index >= values.length) return;
|
||||||
|
await worker(values[index]);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncOdidoList(
|
||||||
|
listId: string,
|
||||||
|
sessionSecret: string
|
||||||
|
): Promise<OdidoSyncSummary> {
|
||||||
|
const started = Date.now();
|
||||||
|
const list = await prisma.liveChannelList.findUnique({
|
||||||
|
where: { id: listId },
|
||||||
|
include: { channels: true },
|
||||||
|
});
|
||||||
|
if (!list) throw new AppError("NOT_FOUND", "Lijst niet gevonden", 404);
|
||||||
|
if (list.provider !== "odido") {
|
||||||
|
throw new AppError("INVALID_REQUEST", "Deze lijst is niet aan Odido gekoppeld", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await loadOdidoSettings(sessionSecret);
|
||||||
|
const cookies = await login(settings.username, settings.pin);
|
||||||
|
const allChannels = await queryAllChannels(cookies);
|
||||||
|
const videos = allChannels
|
||||||
|
.map((channel, apiIndex) => ({ channel, apiIndex }))
|
||||||
|
.filter(({ channel }) => channel.contentType === "VIDEO_CHANNEL");
|
||||||
|
const existing = new Map(
|
||||||
|
list.channels
|
||||||
|
.filter((channel) => channel.externalId)
|
||||||
|
.map((channel) => [channel.externalId!, channel])
|
||||||
|
);
|
||||||
|
const successful = new Set<string>();
|
||||||
|
const counts = { added: 0, updated: 0, skipped: 0, failed: 0 };
|
||||||
|
|
||||||
|
await mapLimit(videos, 4, async ({ channel, apiIndex }) => {
|
||||||
|
const externalId = channel.ID?.trim();
|
||||||
|
const physical = channel.physicalChannels?.[0];
|
||||||
|
const mediaId = physical?.ID?.trim();
|
||||||
|
if (!externalId || !mediaId) {
|
||||||
|
counts.failed += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let play;
|
||||||
|
try {
|
||||||
|
play = await playChannel(cookies, externalId, mediaId);
|
||||||
|
} catch {
|
||||||
|
counts.skipped += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = existing.get(externalId);
|
||||||
|
let kidEnc = previous?.kidEnc ?? null;
|
||||||
|
let keyEnc = previous?.keyEnc ?? null;
|
||||||
|
if (play.licenseUrl && play.licenseToken && (!kidEnc || !keyEnc)) {
|
||||||
|
try {
|
||||||
|
const pssh = await getPssh(play.playUrl, play.cookies);
|
||||||
|
if (!pssh) throw new Error("Widevine PSSH ontbreekt");
|
||||||
|
const keys = await extractOdidoKeys({
|
||||||
|
wvdPath: settings.config.wvdPath,
|
||||||
|
pssh,
|
||||||
|
licUrl: play.licenseUrl,
|
||||||
|
licToken: play.licenseToken,
|
||||||
|
cookies: play.cookies,
|
||||||
|
userAgent: play.userAgent,
|
||||||
|
});
|
||||||
|
const key = keys.map(parseKey).find((item) => item !== null);
|
||||||
|
if (!key) throw new Error("Geen bruikbare ClearKey ontvangen");
|
||||||
|
kidEnc = encryptSecret(key.kid, sessionSecret);
|
||||||
|
keyEnc = encryptSecret(key.key, sessionSecret);
|
||||||
|
} catch {
|
||||||
|
counts.failed += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = channel.name?.trim() || channel.channelName?.trim() ||
|
||||||
|
physical?.mediaName?.trim() || externalId;
|
||||||
|
const data = {
|
||||||
|
name,
|
||||||
|
logoUrl: logoUrl(channel),
|
||||||
|
mpdUrl: `odido://${externalId}/${mediaId}`,
|
||||||
|
mediaId,
|
||||||
|
contentId: play.contentId ?? contentId(channel),
|
||||||
|
kidEnc,
|
||||||
|
keyEnc,
|
||||||
|
sortOrder: channelSortOrder(channel, apiIndex),
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
if (previous) {
|
||||||
|
await prisma.liveChannel.update({ where: { id: previous.id }, data });
|
||||||
|
counts.updated += 1;
|
||||||
|
} else {
|
||||||
|
await prisma.liveChannel.create({
|
||||||
|
data: { listId, externalId, ...data },
|
||||||
|
});
|
||||||
|
counts.added += 1;
|
||||||
|
}
|
||||||
|
successful.add(externalId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A full sync is authoritative: channels outside the current package (including
|
||||||
|
// PlayChannel failures) remain stored for stable IDs, but are disabled.
|
||||||
|
const disableIds = list.channels
|
||||||
|
.filter((channel) => channel.externalId && !successful.has(channel.externalId))
|
||||||
|
.map((channel) => channel.id);
|
||||||
|
const disabled = disableIds.length
|
||||||
|
? (await prisma.liveChannel.updateMany({
|
||||||
|
where: { id: { in: disableIds }, enabled: true },
|
||||||
|
data: { enabled: false },
|
||||||
|
})).count
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const summary: OdidoSyncSummary = {
|
||||||
|
...counts,
|
||||||
|
disabled,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
await saveOdidoSyncResult(summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
@ -92,6 +92,7 @@ export async function listLiveChannelListsAdmin() {
|
||||||
id: l.id,
|
id: l.id,
|
||||||
name: l.name,
|
name: l.name,
|
||||||
slug: l.slug,
|
slug: l.slug,
|
||||||
|
provider: l.provider,
|
||||||
sortOrder: l.sortOrder,
|
sortOrder: l.sortOrder,
|
||||||
enabled: l.enabled,
|
enabled: l.enabled,
|
||||||
channelCount: l._count.channels,
|
channelCount: l._count.channels,
|
||||||
|
|
@ -112,10 +113,14 @@ export async function getLiveChannelListAdmin(listId: string) {
|
||||||
id: list.id,
|
id: list.id,
|
||||||
name: list.name,
|
name: list.name,
|
||||||
slug: list.slug,
|
slug: list.slug,
|
||||||
|
provider: list.provider,
|
||||||
sortOrder: list.sortOrder,
|
sortOrder: list.sortOrder,
|
||||||
enabled: list.enabled,
|
enabled: list.enabled,
|
||||||
channels: list.channels.map((c) => ({
|
channels: list.channels.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
|
externalId: c.externalId,
|
||||||
|
mediaId: c.mediaId,
|
||||||
|
contentId: c.contentId,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
logoUrl: c.logoUrl,
|
logoUrl: c.logoUrl,
|
||||||
mpdUrl: c.mpdUrl,
|
mpdUrl: c.mpdUrl,
|
||||||
|
|
@ -150,19 +155,23 @@ export async function createLiveChannelList(
|
||||||
|
|
||||||
export async function updateLiveChannelList(
|
export async function updateLiveChannelList(
|
||||||
listId: string,
|
listId: string,
|
||||||
body: { name?: string; sortOrder?: number; enabled?: boolean }
|
body: { name?: string; sortOrder?: number; enabled?: boolean; provider?: string | null }
|
||||||
) {
|
) {
|
||||||
const existing = await prisma.liveChannelList.findUnique({ where: { id: listId } });
|
const existing = await prisma.liveChannelList.findUnique({ where: { id: listId } });
|
||||||
if (!existing) throw new AppError("NOT_FOUND", "Lijst niet gevonden", 404);
|
if (!existing) throw new AppError("NOT_FOUND", "Lijst niet gevonden", 404);
|
||||||
const name = body.name?.trim();
|
const name = body.name?.trim();
|
||||||
const slug =
|
const slug =
|
||||||
name && name !== existing.name ? await uniqueSlug(name, listId) : undefined;
|
name && name !== existing.name ? await uniqueSlug(name, listId) : undefined;
|
||||||
|
if (body.provider !== undefined && body.provider !== null && body.provider !== "odido") {
|
||||||
|
throw new AppError("INVALID_REQUEST", "Onbekende live-lijstprovider", 400);
|
||||||
|
}
|
||||||
await prisma.liveChannelList.update({
|
await prisma.liveChannelList.update({
|
||||||
where: { id: listId },
|
where: { id: listId },
|
||||||
data: {
|
data: {
|
||||||
...(name ? { name, slug } : {}),
|
...(name ? { name, slug } : {}),
|
||||||
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
|
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
|
||||||
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
||||||
|
...(body.provider !== undefined ? { provider: body.provider } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return getLiveChannelListAdmin(listId);
|
return getLiveChannelListAdmin(listId);
|
||||||
|
|
@ -416,18 +425,74 @@ export async function getCustomPlayUrl(
|
||||||
viewers: { some: { viewerUserId: viewerId } },
|
viewers: { some: { viewerUserId: viewerId } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: { list: { select: { name: true } } },
|
include: { list: { select: { name: true, provider: true } } },
|
||||||
});
|
});
|
||||||
if (!channel) throw new AppError("FORBIDDEN", "Geen toegang tot deze zender", 403);
|
if (!channel) throw new AppError("FORBIDDEN", "Geen toegang tot deze zender", 403);
|
||||||
|
|
||||||
|
let streamUrl = channel.mpdUrl;
|
||||||
|
let kidEnc = channel.kidEnc;
|
||||||
|
let keyEnc = channel.keyEnc;
|
||||||
|
const isOdido = channel.mpdUrl.startsWith("odido://") ||
|
||||||
|
(channel.list.provider === "odido" && !!channel.externalId && !!channel.mediaId);
|
||||||
|
if (isOdido) {
|
||||||
|
const marker = /^odido:\/\/([^/]+)\/([^/?#]+)/.exec(channel.mpdUrl);
|
||||||
|
const externalId = channel.externalId ?? marker?.[1];
|
||||||
|
const mediaId = channel.mediaId ?? marker?.[2];
|
||||||
|
if (!externalId || !mediaId) {
|
||||||
|
throw new AppError("ODIDO_ERROR", "Odido-zenderreferentie is onvolledig", 500);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { loadOdidoSettings } = await import("../odido/settings");
|
||||||
|
const { login, playChannel, getPssh } = await import("../odido/client");
|
||||||
|
const settings = await loadOdidoSettings(sessionSecret);
|
||||||
|
const cookies = await login(settings.username, settings.pin);
|
||||||
|
const play = await playChannel(cookies, externalId, mediaId);
|
||||||
|
streamUrl = play.playUrl;
|
||||||
|
if (play.licenseUrl && play.licenseToken && (!kidEnc || !keyEnc)) {
|
||||||
|
const pssh = await getPssh(play.playUrl, play.cookies);
|
||||||
|
if (!pssh) throw new Error("Widevine PSSH ontbreekt");
|
||||||
|
const { extractOdidoKeys } = await import("../odido/cdm");
|
||||||
|
const keys = await extractOdidoKeys({
|
||||||
|
wvdPath: settings.config.wvdPath,
|
||||||
|
pssh,
|
||||||
|
licUrl: play.licenseUrl,
|
||||||
|
licToken: play.licenseToken,
|
||||||
|
cookies: play.cookies,
|
||||||
|
userAgent: play.userAgent,
|
||||||
|
});
|
||||||
|
const parsed = keys
|
||||||
|
.map((value) => value.trim().toLowerCase().split(":", 2))
|
||||||
|
.find(([kid, key]) => /^[0-9a-f]{32}$/.test(kid ?? "") &&
|
||||||
|
/^[0-9a-f]{32}$/.test(key ?? ""));
|
||||||
|
if (!parsed) throw new Error("Geen bruikbare ClearKey ontvangen");
|
||||||
|
kidEnc = encryptSecret(parsed[0], sessionSecret);
|
||||||
|
keyEnc = encryptSecret(parsed[1], sessionSecret);
|
||||||
|
await prisma.liveChannel.update({
|
||||||
|
where: { id: channel.id },
|
||||||
|
data: {
|
||||||
|
kidEnc,
|
||||||
|
keyEnc,
|
||||||
|
contentId: play.contentId ?? channel.contentId,
|
||||||
|
externalId,
|
||||||
|
mediaId,
|
||||||
|
mpdUrl: `odido://${externalId}/${mediaId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "onbekende fout";
|
||||||
|
throw new AppError("ODIDO_ERROR", `Odido-zender starten mislukt: ${message}`, 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const drm =
|
const drm =
|
||||||
channel.kidEnc && channel.keyEnc
|
kidEnc && keyEnc
|
||||||
? {
|
? {
|
||||||
type: "clearkey" as const,
|
type: "clearkey" as const,
|
||||||
keys: [
|
keys: [
|
||||||
{
|
{
|
||||||
kid: decryptSecret(channel.kidEnc, sessionSecret),
|
kid: decryptSecret(kidEnc, sessionSecret),
|
||||||
key: decryptSecret(channel.keyEnc, sessionSecret),
|
key: decryptSecret(keyEnc, sessionSecret),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
@ -453,7 +518,7 @@ export async function getCustomPlayUrl(
|
||||||
streamId: sid,
|
streamId: sid,
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
logoUrl: channel.logoUrl,
|
logoUrl: channel.logoUrl,
|
||||||
streamUrl: channel.mpdUrl,
|
streamUrl,
|
||||||
format: "dash" as const,
|
format: "dash" as const,
|
||||||
fallbackStreamUrl: null,
|
fallbackStreamUrl: null,
|
||||||
fallbackFormat: null,
|
fallbackFormat: null,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ RUN mkdir -p /out \
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN apk add --no-cache openssl wget python3 make g++
|
# python3 blijft voor Odido Widevine CDM (pywidevine); make/g++ alleen voor npm native builds
|
||||||
|
RUN apk add --no-cache openssl wget python3 py3-pip make g++
|
||||||
|
|
||||||
COPY --from=builder /out/dist ./dist
|
COPY --from=builder /out/dist ./dist
|
||||||
COPY --from=builder /out/prisma ./prisma
|
COPY --from=builder /out/prisma ./prisma
|
||||||
|
|
@ -48,6 +49,7 @@ COPY --from=node-build /media-node-linux-arm64 /app/install/media-node-linux-arm
|
||||||
COPY deploy/node/install-unraid.sh /app/install/unraid.sh
|
COPY deploy/node/install-unraid.sh /app/install/unraid.sh
|
||||||
COPY deploy/node/install-synology.sh /app/install/synology.sh
|
COPY deploy/node/install-synology.sh /app/install/synology.sh
|
||||||
COPY deploy/node/upgrade-node.sh /app/install/upgrade-node.sh
|
COPY deploy/node/upgrade-node.sh /app/install/upgrade-node.sh
|
||||||
|
COPY apps/master-api/scripts/odido_cdm.py /app/scripts/odido_cdm.py
|
||||||
|
|
||||||
# Remove workspace protocol deps before npm install
|
# Remove workspace protocol deps before npm install
|
||||||
RUN node -e "\
|
RUN node -e "\
|
||||||
|
|
@ -62,7 +64,8 @@ fs.writeFileSync('package.json', JSON.stringify(p, null, 2));"
|
||||||
|
|
||||||
RUN npm install --omit=dev \
|
RUN npm install --omit=dev \
|
||||||
&& npx prisma generate \
|
&& npx prisma generate \
|
||||||
&& apk del python3 make g++
|
&& pip3 install --no-cache-dir --break-system-packages pywidevine requests \
|
||||||
|
&& apk del make g++
|
||||||
|
|
||||||
# IMPORTANT: copy workspace packages AFTER npm install (npm wipes node_modules)
|
# IMPORTANT: copy workspace packages AFTER npm install (npm wipes node_modules)
|
||||||
RUN mkdir -p node_modules/@media-cluster \
|
RUN mkdir -p node_modules/@media-cluster \
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "3100:3000"
|
- "3100:3000"
|
||||||
|
volumes:
|
||||||
|
# Persist Odido WVD.wvd (admin-upload → /data/odido/WVD.wvd)
|
||||||
|
- odido_data:/data/odido
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|
@ -102,6 +105,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
odido_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
media-cluster:
|
media-cluster:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue