Add Odido live provider with admin WVD upload and auto-migrate on deploy.

This commit is contained in:
Jos Vooges | STH 2026-09-21 21:10:41 +02:00
parent 8bfa8935b0
commit 03e5c9c661
14 changed files with 1284 additions and 11 deletions

View file

@ -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 }}>

View 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>
</>
);
}

View file

@ -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" },
]; ];

View file

@ -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");

View file

@ -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")
} }

View 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)

View file

@ -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");

View 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));
});
}

View 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;
}

View 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) },
});
}

View 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;
}

View file

@ -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,

View file

@ -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 \

View file

@ -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: