diff --git a/apps/admin-ui/src/app/live-lists/page.tsx b/apps/admin-ui/src/app/live-lists/page.tsx index 7227688..924c557 100644 --- a/apps/admin-ui/src/app/live-lists/page.tsx +++ b/apps/admin-ui/src/app/live-lists/page.tsx @@ -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(null); const [channelForm, setChannelForm] = useState(emptyChannelForm()); const [busy, setBusy] = useState(false); + const [syncBusy, setSyncBusy] = useState(false); + const [odidoConfigured, setOdidoConfigured] = useState(false); const [msg, setMsg] = useState(null); const [err, setErr] = useState(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() {

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

@@ -389,6 +482,7 @@ export default function LiveListsPage() { {l.name} {l.channelCount} zenders · {l.enabled ? "aan" : "uit"} + {l.provider === "odido" ? " · Odido" : ""} @@ -432,12 +526,69 @@ export default function LiveListsPage() { - +
+

Odido-script

+ {detail.provider === "odido" ? ( + <> +

+ Deze lijst is gekoppeld aan Odido. “Volledig ophalen” checkt welke TV-zenders + in je abonnement zitten (geen radio) en vult/werkt de lijst bij. +

+
+ + + + Instellingen + +
+ {!odidoConfigured && ( +

+ Stel eerst klantnummer, pincode en WVD in onder Scripts. +

+ )} + + ) : ( + <> +

+ Koppel Odido om automatisch TV-zenders uit je abonnement in deze lijst te zetten. +

+
+ + + Scripts / Odido-instellingen + +
+ {!odidoConfigured && ( +

+ Nog niet geconfigureerd — vul eerst Scripts → Odido in. +

+ )} + + )} +
+ {editingChannelId && (

diff --git a/apps/admin-ui/src/app/scripts/page.tsx b/apps/admin-ui/src/app/scripts/page.tsx new file mode 100644 index 0000000..42fda9b --- /dev/null +++ b/apps/admin-ui/src/app/scripts/page.tsx @@ -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 | null; +}; + +function readFileAsBase64(file: File): Promise { + 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(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(null); + const [err, setErr] = useState(null); + const wvdInputRef = useRef(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 = { + 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 ( + <> +