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;
|
||||
name: string;
|
||||
slug: string;
|
||||
provider?: string | null;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
channelCount: number;
|
||||
|
|
@ -16,6 +17,9 @@ type LiveListSummary = {
|
|||
|
||||
type LiveChannelRow = {
|
||||
id: string;
|
||||
externalId?: string | null;
|
||||
mediaId?: string | null;
|
||||
contentId?: string | null;
|
||||
name: string;
|
||||
logoUrl: string | null;
|
||||
mpdUrl: string;
|
||||
|
|
@ -32,6 +36,7 @@ type LiveListDetail = {
|
|||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
provider?: string | null;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
channels: LiveChannelRow[];
|
||||
|
|
@ -77,6 +82,7 @@ function formFromChannel(c: LiveChannelRow): ChannelForm {
|
|||
/** Korte preview in zender-overzicht (volledige URL via title/tooltip). */
|
||||
function shortUrl(url: string, max = 42): string {
|
||||
const s = url.trim();
|
||||
if (s.startsWith("odido://")) return "Odido (live resolve)";
|
||||
if (s.length <= max) return s;
|
||||
try {
|
||||
const u = new URL(s);
|
||||
|
|
@ -104,6 +110,8 @@ export default function LiveListsPage() {
|
|||
const [editingChannelId, setEditingChannelId] = useState<string | null>(null);
|
||||
const [channelForm, setChannelForm] = useState<ChannelForm>(emptyChannelForm());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [syncBusy, setSyncBusy] = useState(false);
|
||||
const [odidoConfigured, setOdidoConfigured] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -133,6 +141,10 @@ export default function LiveListsPage() {
|
|||
.then((r) => r.json())
|
||||
.then((d) => setLogoOptions(d.logos ?? []))
|
||||
.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]);
|
||||
|
||||
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() {
|
||||
setEditingChannelId("new");
|
||||
setChannelForm(emptyChannelForm());
|
||||
|
|
@ -345,7 +438,7 @@ export default function LiveListsPage() {
|
|||
</div>
|
||||
</div>
|
||||
<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).
|
||||
</p>
|
||||
|
||||
|
|
@ -389,6 +482,7 @@ export default function LiveListsPage() {
|
|||
{l.name}
|
||||
<span className="muted" style={{ display: "block", fontSize: "0.8rem" }}>
|
||||
{l.channelCount} zenders · {l.enabled ? "aan" : "uit"}
|
||||
{l.provider === "odido" ? " · Odido" : ""}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
|
|
@ -432,12 +526,69 @@ export default function LiveListsPage() {
|
|||
<button type="button" className="danger" disabled={busy} onClick={() => void deleteList()}>
|
||||
Verwijderen
|
||||
</button>
|
||||
<button type="button" disabled={busy} onClick={startCreateChannel}>
|
||||
<button type="button" disabled={busy || syncBusy} onClick={startCreateChannel}>
|
||||
Zender toevoegen
|
||||
</button>
|
||||
</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 && (
|
||||
<div className="card" style={{ marginBottom: "1rem" }}>
|
||||
<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: "/streams", label: "Streams" },
|
||||
{ href: "/live-lists", label: "Live-lijsten" },
|
||||
{ href: "/scripts", label: "Scripts" },
|
||||
{ href: "/viewers", label: "Gebruikers" },
|
||||
{ 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())
|
||||
name String
|
||||
slug String @unique
|
||||
provider String? @map("provider")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
|
@ -327,6 +328,9 @@ model LiveChannelList {
|
|||
model LiveChannel {
|
||||
id String @id @default(uuid())
|
||||
listId String @map("list_id")
|
||||
externalId String? @map("external_id")
|
||||
mediaId String? @map("media_id")
|
||||
contentId String? @map("content_id")
|
||||
name String
|
||||
logoUrl String? @map("logo_url")
|
||||
mpdUrl String @map("mpd_url") @db.Text
|
||||
|
|
@ -344,6 +348,7 @@ model LiveChannel {
|
|||
list LiveChannelList @relation(fields: [listId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([listId, sortOrder])
|
||||
@@index([listId, externalId])
|
||||
@@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 };
|
||||
});
|
||||
|
||||
// --- 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 ---
|
||||
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
|
||||
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) => {
|
||||
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");
|
||||
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) => {
|
||||
const { id } = request.params as { id: string };
|
||||
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,
|
||||
name: l.name,
|
||||
slug: l.slug,
|
||||
provider: l.provider,
|
||||
sortOrder: l.sortOrder,
|
||||
enabled: l.enabled,
|
||||
channelCount: l._count.channels,
|
||||
|
|
@ -112,10 +113,14 @@ export async function getLiveChannelListAdmin(listId: string) {
|
|||
id: list.id,
|
||||
name: list.name,
|
||||
slug: list.slug,
|
||||
provider: list.provider,
|
||||
sortOrder: list.sortOrder,
|
||||
enabled: list.enabled,
|
||||
channels: list.channels.map((c) => ({
|
||||
id: c.id,
|
||||
externalId: c.externalId,
|
||||
mediaId: c.mediaId,
|
||||
contentId: c.contentId,
|
||||
name: c.name,
|
||||
logoUrl: c.logoUrl,
|
||||
mpdUrl: c.mpdUrl,
|
||||
|
|
@ -150,19 +155,23 @@ export async function createLiveChannelList(
|
|||
|
||||
export async function updateLiveChannelList(
|
||||
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 } });
|
||||
if (!existing) throw new AppError("NOT_FOUND", "Lijst niet gevonden", 404);
|
||||
const name = body.name?.trim();
|
||||
const slug =
|
||||
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({
|
||||
where: { id: listId },
|
||||
data: {
|
||||
...(name ? { name, slug } : {}),
|
||||
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
|
||||
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
||||
...(body.provider !== undefined ? { provider: body.provider } : {}),
|
||||
},
|
||||
});
|
||||
return getLiveChannelListAdmin(listId);
|
||||
|
|
@ -416,18 +425,74 @@ export async function getCustomPlayUrl(
|
|||
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);
|
||||
|
||||
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 =
|
||||
channel.kidEnc && channel.keyEnc
|
||||
kidEnc && keyEnc
|
||||
? {
|
||||
type: "clearkey" as const,
|
||||
keys: [
|
||||
{
|
||||
kid: decryptSecret(channel.kidEnc, sessionSecret),
|
||||
key: decryptSecret(channel.keyEnc, sessionSecret),
|
||||
kid: decryptSecret(kidEnc, sessionSecret),
|
||||
key: decryptSecret(keyEnc, sessionSecret),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -453,7 +518,7 @@ export async function getCustomPlayUrl(
|
|||
streamId: sid,
|
||||
name: channel.name,
|
||||
logoUrl: channel.logoUrl,
|
||||
streamUrl: channel.mpdUrl,
|
||||
streamUrl,
|
||||
format: "dash" as const,
|
||||
fallbackStreamUrl: null,
|
||||
fallbackFormat: null,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ RUN mkdir -p /out \
|
|||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
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/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-synology.sh /app/install/synology.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
|
||||
RUN node -e "\
|
||||
|
|
@ -62,7 +64,8 @@ fs.writeFileSync('package.json', JSON.stringify(p, null, 2));"
|
|||
|
||||
RUN npm install --omit=dev \
|
||||
&& 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)
|
||||
RUN mkdir -p node_modules/@media-cluster \
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ services:
|
|||
condition: service_healthy
|
||||
ports:
|
||||
- "3100:3000"
|
||||
volumes:
|
||||
# Persist Odido WVD.wvd (admin-upload → /data/odido/WVD.wvd)
|
||||
- odido_data:/data/odido
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 15s
|
||||
|
|
@ -102,6 +105,7 @@ services:
|
|||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
odido_data:
|
||||
|
||||
networks:
|
||||
media-cluster:
|
||||
|
|
|
|||
Loading…
Reference in a new issue