diff --git a/apps/admin-ui/src/app/live-lists/page.tsx b/apps/admin-ui/src/app/live-lists/page.tsx index bd8e8e0..14a8f8f 100644 --- a/apps/admin-ui/src/app/live-lists/page.tsx +++ b/apps/admin-ui/src/app/live-lists/page.tsx @@ -328,6 +328,11 @@ export default function LiveListsPage() { d.disabled != null ? `${d.disabled} uit` : null, d.skipped != null ? `${d.skipped} overgeslagen` : null, d.failed != null ? `${d.failed} mislukt` : null, + d.uniqueChannels != null ? `${d.uniqueChannels} uniek` : null, + d.overlapChannels != null && d.overlapChannels > 0 + ? `${d.overlapChannels} overlap` + : null, + d.accounts != null ? `${d.accounts} acc` : null, d.epgMatched != null ? `EPG ${d.epgMatched}` : null, d.epgUnmatched != null && d.epgUnmatched > 0 ? `EPG-miss ${d.epgUnmatched}` : null, d.durationMs != null ? `${Math.round(d.durationMs / 1000)}s` : null, diff --git a/apps/admin-ui/src/app/scripts/page.tsx b/apps/admin-ui/src/app/scripts/page.tsx index deeb3b1..c13e955 100644 --- a/apps/admin-ui/src/app/scripts/page.tsx +++ b/apps/admin-ui/src/app/scripts/page.tsx @@ -14,11 +14,18 @@ type TweakMeta = { type OdidoTweaks = Record; +type OdidoAccount = { + id: string; + label: string; + username: string; + hasPin: boolean; + enabled: boolean; + channelCount: number; +}; + type OdidoSettings = { configured: boolean; enabled: boolean; - username: string; - hasPin: boolean; hasWvd: boolean; wvdPath: string; wvdUploadedAt: string | null; @@ -26,6 +33,9 @@ type OdidoSettings = { lastSyncSummary: Record | null; lastMpdRefreshAt: string | null; lastMpdRefreshSummary: Record | null; + accounts: OdidoAccount[]; + accountCount: number; + channelOwnerCount: number; tweaks: OdidoTweaks; tweakDefaults: OdidoTweaks; tweakMeta: TweakMeta[]; @@ -47,8 +57,6 @@ function readFileAsBase64(file: File): Promise { 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 [tweaks, setTweaks] = useState({}); @@ -58,13 +66,15 @@ export default function ScriptsPage() { const [err, setErr] = useState(null); const wvdInputRef = useRef(null); + const [newLabel, setNewLabel] = useState(""); + const [newUsername, setNewUsername] = useState(""); + const [newPin, setNewPin] = useState(""); + const applySettings = useCallback((d: OdidoSettings) => { setOdido(d); - setUsername(d.username ?? ""); setWvdPath(d.wvdPath ?? ""); setEnabled(d.enabled !== false); setTweaks(d.tweaks ?? d.tweakDefaults ?? {}); - setPin(""); }, []); const load = useCallback(async () => { @@ -84,18 +94,11 @@ export default function ScriptsPage() { setErr(null); setMsg(null); try { - const body: Record = { - username: username.trim(), - wvdPath: wvdPath.trim(), - enabled, - tweaks, - }; - 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), + body: JSON.stringify({ wvdPath: wvdPath.trim(), enabled, tweaks }), }); const d = await r.json(); if (!r.ok) throw new Error(d.error?.message ?? "Opslaan mislukt"); @@ -108,6 +111,78 @@ export default function ScriptsPage() { } } + async function addAccount(e: FormEvent) { + e.preventDefault(); + setBusy(true); + setErr(null); + setMsg(null); + try { + const r = await fetch("/api/v1/admin/scripts/odido/accounts", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + label: newLabel.trim() || undefined, + username: newUsername.trim(), + pin: newPin.trim(), + enabled: true, + }), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error?.message ?? "Account toevoegen mislukt"); + applySettings(d); + setNewLabel(""); + setNewUsername(""); + setNewPin(""); + setMsg("Odido-account toegevoegd"); + } catch (e) { + setErr(String((e as Error).message ?? e)); + } finally { + setBusy(false); + } + } + + async function toggleAccount(account: OdidoAccount) { + setBusy(true); + setErr(null); + try { + const r = await fetch(`/api/v1/admin/scripts/odido/accounts/${account.id}`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !account.enabled }), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error?.message ?? "Account bijwerken mislukt"); + applySettings(d); + } catch (e) { + setErr(String((e as Error).message ?? e)); + } finally { + setBusy(false); + } + } + + async function removeAccount(account: OdidoAccount) { + if (!confirm(`Account “${account.label || account.username}” verwijderen?`)) return; + setBusy(true); + setErr(null); + setMsg(null); + try { + const r = await fetch(`/api/v1/admin/scripts/odido/accounts/${account.id}`, { + method: "DELETE", + credentials: "include", + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error?.message ?? "Verwijderen mislukt"); + applySettings(d); + setMsg("Account verwijderd"); + } catch (e) { + setErr(String((e as Error).message ?? e)); + } finally { + setBusy(false); + } + } + async function uploadWvd(file: File | null) { if (!file) return; setBusy(true); @@ -186,6 +261,7 @@ export default function ScriptsPage() { const summary = odido?.lastSyncSummary; const refreshSummary = odido?.lastMpdRefreshSummary; const meta = odido?.tweakMeta ?? []; + const accounts = odido?.accounts ?? []; return ( <> @@ -199,8 +275,9 @@ export default function ScriptsPage() {

- Provider-scripts voor live-lijsten. Stel hier Odido in, koppel daarna een lijst onder{" "} - Live-lijsten en doe daar “Volledig ophalen”. + Provider-scripts voor live-lijsten. Meerdere Odido-accounts: sync bouwt één unieke + zenderlijst (geen dubbels); overlap wordt round-robin verdeeld bij play/refresh. Koppel + daarna een lijst onder Live-lijsten.

{msg && ( @@ -217,33 +294,102 @@ export default function ScriptsPage() {

Odido

- Play gebruikt een gecachte MPD als die jonger is dan de TTL (default 1 uur). Anders - (of via achtergrond-refresh) verse PlayChannel. ClearKey blijft in de DB. + Accounts met verschillende abonnementen vullen elkaar aan. Gedeelde zenders worden + load-balanced; unieke zenders komen van het account dat ze wél heeft.

+ +

Accounts

+ {accounts.length === 0 ? ( +

Nog geen accounts — voeg hieronder je eerste klantnummer toe.

+ ) : ( +
    + {accounts.map((a) => ( +
  • +
    + {a.label || a.username} + + {a.username} + {a.id !== "legacy" ? ` · ${a.channelCount} zenders` : ""} + {!a.enabled ? " · uit" : ""} + +
    +
    + {a.id !== "legacy" && ( + <> + + + + )} +
    +
  • + ))} +
+ )} + +
+
+ + + +
+ +
+
- -
- WVD.wvd (Widevine device) + WVD.wvd (Widevine device — gedeeld)
-

- Wijzigingen gelden na Opslaan. Achtergrond-refresh leest deze waarden live. -

{meta.map((item) => { @@ -392,10 +536,15 @@ export default function ScriptsPage() { Status:{" "} {odido?.configured ? ( - geconfigureerd + geconfigureerd ({odido.accountCount} account + {odido.accountCount === 1 ? "" : "s"} + {odido.channelOwnerCount + ? ` · ${odido.channelOwnerCount} zenders na sync` + : ""} + ) ) : ( - nog niet compleet (klantnummer + pincode + WVD) + nog niet compleet (minstens 1 account + WVD) )} {odido?.lastSyncAt && ( <> @@ -406,10 +555,14 @@ export default function ScriptsPage() { {" "} ( {[ + summary.uniqueChannels != null + ? `${String(summary.uniqueChannels)} uniek` + : null, + summary.overlapChannels != null + ? `${String(summary.overlapChannels)} overlap` + : null, 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(" · ") || "ok"} @@ -430,9 +583,8 @@ export default function ScriptsPage() { refreshSummary.refreshed != null ? `${String(refreshSummary.refreshed)} ok` : null, - refreshSummary.failed != null ? `${String(refreshSummary.failed)} fout` : null, - refreshSummary.triggeredBy != null - ? String(refreshSummary.triggeredBy) + refreshSummary.failed != null + ? `${String(refreshSummary.failed)} fout` : null, ] .filter(Boolean) diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index 1868211..94f0038 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -1647,6 +1647,51 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) return flushOdidoMpdCache(); }); + app.post("/api/v1/admin/scripts/odido/accounts", { preHandler: requireAdmin }, async (request) => { + const body = request.body as { + username?: string; + pin?: string; + label?: string; + enabled?: boolean; + }; + if (!body.username?.trim() || !body.pin?.trim()) { + throw new AppError("INVALID_REQUEST", "username en pin verplicht", 400); + } + const { addOdidoAccount } = await import("../odido/settings"); + return addOdidoAccount(config.SESSION_SECRET, { + username: body.username, + pin: body.pin, + label: body.label, + enabled: body.enabled, + }); + }); + + app.patch( + "/api/v1/admin/scripts/odido/accounts/:accountId", + { preHandler: requireAdmin }, + async (request) => { + const { accountId } = request.params as { accountId: string }; + const body = request.body as { + username?: string; + pin?: string; + label?: string; + enabled?: boolean; + }; + const { updateOdidoAccount } = await import("../odido/settings"); + return updateOdidoAccount(config.SESSION_SECRET, accountId, body); + } + ); + + app.delete( + "/api/v1/admin/scripts/odido/accounts/:accountId", + { preHandler: requireAdmin }, + async (request) => { + const { accountId } = request.params as { accountId: string }; + const { removeOdidoAccount } = await import("../odido/settings"); + return removeOdidoAccount(config.SESSION_SECRET, accountId); + } + ); + // --- Custom ClearKey/DASH live channel lists --- app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => { const { listLiveChannelListsAdmin } = await import("../viewer/live-channels"); diff --git a/apps/master-api/src/odido/accounts.test.ts b/apps/master-api/src/odido/accounts.test.ts new file mode 100644 index 0000000..62680c0 --- /dev/null +++ b/apps/master-api/src/odido/accounts.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { + pickAccountIdForChannel, + parseChannelOwners, +} from "./accounts"; + +const owners = parseChannelOwners({ + "111": ["a", "b"], + "222": ["b"], +}); +assert.deepEqual(owners["111"], ["a", "b"]); + +const picks = new Set(); +for (let i = 0; i < 4; i++) { + picks.add(pickAccountIdForChannel("111", owners["111"], ["a", "b"])!); +} +assert.equal(picks.size, 2, "round-robin moet beide accounts raken"); + +assert.equal( + pickAccountIdForChannel("999", [], ["a", "b"]), + "a", + "zonder owners: fallback naar enabled pool" +); +assert.equal( + pickAccountIdForChannel("222", owners["222"], ["a", "b"]), + "b", + "alleen owner b" +); + +console.log("odido accounts lb tests ok"); diff --git a/apps/master-api/src/odido/accounts.ts b/apps/master-api/src/odido/accounts.ts new file mode 100644 index 0000000..30b9de5 --- /dev/null +++ b/apps/master-api/src/odido/accounts.ts @@ -0,0 +1,164 @@ +import { randomUUID } from "node:crypto"; +import { decryptSecret, encryptSecret } from "../security/crypto"; + +export type OdidoAccountStored = { + id: string; + label: string; + username: string; + pinEnc: string; + enabled: boolean; +}; + +export type OdidoAccountPublic = { + id: string; + label: string; + username: string; + hasPin: boolean; + enabled: boolean; + /** Aantal zenders waar dit account toegang toe heeft (na laatste sync). */ + channelCount: number; +}; + +export type OdidoAccountCreds = { + id: string; + label: string; + username: string; + pin: string; + enabled: boolean; +}; + +/** externalId → accountIds die deze zender kunnen spelen. */ +export type OdidoChannelOwners = Record; + +const rrCursor = new Map(); + +export function accountToPublic( + account: OdidoAccountStored, + channelOwners: OdidoChannelOwners +): OdidoAccountPublic { + let channelCount = 0; + for (const owners of Object.values(channelOwners)) { + if (owners.includes(account.id)) channelCount += 1; + } + return { + id: account.id, + label: account.label || account.username, + username: account.username, + hasPin: !!account.pinEnc, + enabled: account.enabled !== false, + channelCount, + }; +} + +export function decryptAccount( + account: OdidoAccountStored, + sessionSecret: string +): OdidoAccountCreds { + return { + id: account.id, + label: account.label || account.username, + username: account.username, + pin: decryptSecret(account.pinEnc, sessionSecret), + enabled: account.enabled !== false, + }; +} + +export function createAccountStored( + sessionSecret: string, + input: { username: string; pin: string; label?: string; enabled?: boolean; id?: string } +): OdidoAccountStored { + const username = input.username.trim(); + const pin = input.pin.trim(); + if (!username) throw new Error("Klantnummer verplicht"); + if (!pin) throw new Error("Pincode verplicht"); + return { + id: input.id?.trim() || randomUUID(), + label: (input.label ?? "").trim() || username, + username, + pinEnc: encryptSecret(pin, sessionSecret), + enabled: input.enabled !== false, + }; +} + +export function parseAccountsJson(raw: unknown): OdidoAccountStored[] { + if (!Array.isArray(raw)) return []; + const out: OdidoAccountStored[] = []; + for (const item of raw) { + if (!item || typeof item !== "object") continue; + const row = item as Record; + const id = String(row.id ?? "").trim(); + const username = String(row.username ?? "").trim(); + const pinEnc = String(row.pinEnc ?? "").trim(); + if (!id || !username || !pinEnc) continue; + out.push({ + id, + label: String(row.label ?? "").trim() || username, + username, + pinEnc, + enabled: row.enabled !== false, + }); + } + return out; +} + +export function parseChannelOwners(raw: unknown): OdidoChannelOwners { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const out: OdidoChannelOwners = {}; + for (const [channelId, value] of Object.entries(raw as Record)) { + if (!channelId.trim()) continue; + const ids = Array.isArray(value) + ? value.map((v) => String(v).trim()).filter(Boolean) + : []; + if (ids.length) out[channelId] = [...new Set(ids)]; + } + return out; +} + +/** + * Kies account voor een zender (round-robin over owners). + * Fallback: alle enabled accounts als owners onbekend zijn. + */ +export function pickAccountIdForChannel( + externalId: string, + ownerIds: string[], + enabledAccountIds: string[] +): string | null { + const pool = (ownerIds.length ? ownerIds : enabledAccountIds).filter((id) => + enabledAccountIds.includes(id) + ); + if (!pool.length) return null; + const key = externalId || "__global__"; + const cursor = rrCursor.get(key) ?? 0; + const chosen = pool[cursor % pool.length]; + rrCursor.set(key, cursor + 1); + return chosen; +} + +/** Voor batch-refresh: verdeel zenders round-robin over accounts. */ +export function pickAccountIdRoundRobin( + enabledAccountIds: string[], + salt: string +): string | null { + if (!enabledAccountIds.length) return null; + const key = `rr:${salt}`; + const cursor = rrCursor.get(key) ?? 0; + const chosen = enabledAccountIds[cursor % enabledAccountIds.length]; + rrCursor.set(key, cursor + 1); + return chosen; +} + +export function ownersForChannel( + channelOwners: OdidoChannelOwners, + externalId: string +): string[] { + return channelOwners[externalId] ?? []; +} + +export function mergeChannelOwners( + previous: OdidoChannelOwners, + next: OdidoChannelOwners +): OdidoChannelOwners { + // Sync is authoritative: replace with next map (full package union). + void previous; + return next; +} diff --git a/apps/master-api/src/odido/play.ts b/apps/master-api/src/odido/play.ts index 3407392..854f771 100644 --- a/apps/master-api/src/odido/play.ts +++ b/apps/master-api/src/odido/play.ts @@ -11,6 +11,11 @@ import { } from "./client"; import { loadOdidoSettings } from "./settings"; import { isMpdCacheFresh, type OdidoTweaks } from "./tweaks"; +import { + ownersForChannel, + pickAccountIdForChannel, + type OdidoAccountCreds, +} from "./accounts"; export type OdidoChannelPlayState = { id: string; @@ -30,6 +35,7 @@ export type ResolveOdidoPlayResult = { keyEnc: string | null; fromCache: boolean; contentId: string | null; + accountId?: string; }; function parseKey(value: string): { kid: string; key: string } | null { @@ -92,10 +98,39 @@ async function fetchKeysIfNeeded( }; } +function accountOrderForChannel( + externalId: string, + accounts: OdidoAccountCreds[], + channelOwners: Record +): OdidoAccountCreds[] { + const enabledIds = accounts.map((a) => a.id); + const owners = ownersForChannel(channelOwners, externalId); + const primaryId = pickAccountIdForChannel(externalId, owners, enabledIds); + const byId = new Map(accounts.map((a) => [a.id, a])); + const ordered: OdidoAccountCreds[] = []; + if (primaryId && byId.has(primaryId)) ordered.push(byId.get(primaryId)!); + for (const id of owners.length ? owners : enabledIds) { + if (id === primaryId) continue; + const acc = byId.get(id); + if (acc) ordered.push(acc); + } + // Als owners leeg/outdated: alle accounts proberen + if (!ordered.length) return accounts; + for (const acc of accounts) { + if (!ordered.some((a) => a.id === acc.id)) ordered.push(acc); + } + return ordered; +} + export async function refreshChannelMpd( channel: OdidoChannelPlayState, sessionSecret: string, - opts?: { cookies?: OdidoCookies; tweaks?: OdidoTweaks; fetchKeys?: boolean } + opts?: { + cookies?: OdidoCookies; + tweaks?: OdidoTweaks; + fetchKeys?: boolean; + account?: OdidoAccountCreds; + } ): Promise { const settings = await loadOdidoSettings(sessionSecret); const tweaks = opts?.tweaks ?? settings.config; @@ -103,41 +138,56 @@ export async function refreshChannelMpd( const mediaId = channel.mediaId; if (!externalId || !mediaId) throw new Error("Odido-zenderreferentie is onvolledig"); - const cookies = - opts?.cookies ?? - (await login(settings.username, settings.pin, tweaks.sessionCacheMinutes * 60_000)); - const play = await playChannel(cookies, externalId, mediaId); - const keys = await fetchKeysIfNeeded( - play, - settings.config.wvdPath, - sessionSecret, - channel.kidEnc, - channel.keyEnc, - opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay - ); + const ordered = opts?.account + ? [opts.account, ...settings.accounts.filter((a) => a.id !== opts.account!.id)] + : accountOrderForChannel(externalId, settings.accounts, settings.config.channelOwners); - const now = new Date(); - await prisma.liveChannel.update({ - where: { id: channel.id }, - data: { - streamUrlCache: play.playUrl, - streamUrlCachedAt: now, - contentId: play.contentId ?? channel.contentId, - externalId, - mediaId, - mpdUrl: `odido://${externalId}/${mediaId}`, - ...(keys.kidEnc ? { kidEnc: keys.kidEnc } : {}), - ...(keys.keyEnc ? { keyEnc: keys.keyEnc } : {}), - }, - }); + let lastError: Error | null = null; + for (const account of ordered) { + try { + const cookies = + opts?.cookies && opts.account?.id === account.id + ? opts.cookies + : await login(account.username, account.pin, tweaks.sessionCacheMinutes * 60_000); + const play = await playChannel(cookies, externalId, mediaId); + const keys = await fetchKeysIfNeeded( + play, + settings.config.wvdPath, + sessionSecret, + channel.kidEnc, + channel.keyEnc, + opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay + ); - return { - streamUrl: play.playUrl, - kidEnc: keys.kidEnc, - keyEnc: keys.keyEnc, - fromCache: false, - contentId: play.contentId ?? channel.contentId, - }; + const now = new Date(); + await prisma.liveChannel.update({ + where: { id: channel.id }, + data: { + streamUrlCache: play.playUrl, + streamUrlCachedAt: now, + contentId: play.contentId ?? channel.contentId, + externalId, + mediaId, + mpdUrl: `odido://${externalId}/${mediaId}`, + ...(keys.kidEnc ? { kidEnc: keys.kidEnc } : {}), + ...(keys.keyEnc ? { keyEnc: keys.keyEnc } : {}), + }, + }); + + return { + streamUrl: play.playUrl, + kidEnc: keys.kidEnc, + keyEnc: keys.keyEnc, + fromCache: false, + contentId: play.contentId ?? channel.contentId, + accountId: account.id, + }; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + } + } + + throw lastError ?? new Error("Odido PlayChannel mislukt op alle accounts"); } export async function resolveOdidoPlay( diff --git a/apps/master-api/src/odido/refresh.ts b/apps/master-api/src/odido/refresh.ts index 265f03d..d7d4876 100644 --- a/apps/master-api/src/odido/refresh.ts +++ b/apps/master-api/src/odido/refresh.ts @@ -2,10 +2,15 @@ import { prisma } from "../database/client"; import { login } from "./client"; import { refreshChannelMpd } from "./play"; import { + hasOdidoAccountsConfigured, loadOdidoConfigOnly, loadOdidoSettings, saveOdidoMpdRefreshResult, } from "./settings"; +import { + ownersForChannel, + pickAccountIdForChannel, +} from "./accounts"; export type OdidoMpdRefreshSummary = { refreshed: number; @@ -13,6 +18,7 @@ export type OdidoMpdRefreshSummary = { skipped: number; durationMs: number; triggeredBy: "schedule" | "manual"; + byAccount?: Record; }; async function mapLimit( @@ -64,11 +70,9 @@ export async function refreshOdidoMpdCache( }); const counts = { refreshed: 0, failed: 0, skipped: 0 }; - const cookies = await login( - settings.username, - settings.pin, - tweaks.sessionCacheMinutes * 60_000 - ); + const byAccount: Record = {}; + const cookiesByAccount = new Map>>(); + const enabledIds = settings.accounts.map((a) => a.id); await mapLimit( channels, @@ -79,13 +83,33 @@ export async function refreshOdidoMpdCache( counts.skipped += 1; return; } + const owners = ownersForChannel(settings.config.channelOwners, channel.externalId); + const accountId = pickAccountIdForChannel(channel.externalId, owners, enabledIds); + const account = + settings.accounts.find((a) => a.id === accountId) ?? settings.accounts[0]; + if (!account) { + counts.skipped += 1; + return; + } try { + let cookies = cookiesByAccount.get(account.id); + if (!cookies) { + cookies = await login( + account.username, + account.pin, + tweaks.sessionCacheMinutes * 60_000 + ); + cookiesByAccount.set(account.id, cookies); + } await refreshChannelMpd(channel, sessionSecret, { cookies, + account, tweaks, fetchKeys: false, }); counts.refreshed += 1; + byAccount[account.label || account.username] = + (byAccount[account.label || account.username] ?? 0) + 1; } catch { counts.failed += 1; } @@ -96,6 +120,7 @@ export async function refreshOdidoMpdCache( ...counts, durationMs: Date.now() - started, triggeredBy, + byAccount, }; await saveOdidoMpdRefreshResult(summary); return summary; @@ -112,16 +137,11 @@ export async function flushOdidoMpdCache(): Promise<{ cleared: number }> { return { cleared: result.count }; } -/** - * Poller: elke minuut kijken of mpdRefreshIntervalSeconds verstreken is. - * Interval/enable wordt live uit IntegrationSetting gelezen. - */ export function startOdidoMpdRefreshWorker(getSessionSecret: () => string): () => void { let running = false; const tick = async () => { if (running) return; - const row = await prisma.integrationSetting.findUnique({ where: { id: "odido" } }); - if (!row?.enabled || !row.username || !row.passwordEnc) return; + if (!(await hasOdidoAccountsConfigured())) return; const config = await loadOdidoConfigOnly(); if (!config?.mpdRefreshEnabled) return; const last = config.lastMpdRefreshAt ? Date.parse(config.lastMpdRefreshAt) : 0; diff --git a/apps/master-api/src/odido/settings.ts b/apps/master-api/src/odido/settings.ts index 439c1c4..b506fb3 100644 --- a/apps/master-api/src/odido/settings.ts +++ b/apps/master-api/src/odido/settings.ts @@ -2,20 +2,33 @@ 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 { encryptSecret } from "../security/crypto"; import { AppError } from "../security/errors"; +import { + accountToPublic, + createAccountStored, + decryptAccount, + parseAccountsJson, + parseChannelOwners, + type OdidoAccountCreds, + type OdidoAccountStored, + type OdidoChannelOwners, +} from "./accounts"; import { mergeOdidoTweaks, ODIDO_TWEAK_DEFAULTS, ODIDO_TWEAK_META, type OdidoTweaks, } from "./tweaks"; +import { decryptSecret } from "../security/crypto"; const ODIDO_ID = "odido"; const DEFAULT_WVD_PATH = "/data/odido/WVD.wvd"; export type OdidoConfig = OdidoTweaks & { wvdPath: string; + accounts: OdidoAccountStored[]; + channelOwners: OdidoChannelOwners; lastSyncAt?: string; lastSyncSummary?: Record; wvdUploadedAt?: string; @@ -24,10 +37,10 @@ export type OdidoConfig = OdidoTweaks & { }; function parseConfig(raw: string | null | undefined): OdidoConfig { - let parsed: Partial = {}; + let parsed: Partial & Record = {}; if (raw) { try { - parsed = JSON.parse(raw) as Partial; + parsed = JSON.parse(raw) as Partial & Record; } catch { parsed = {}; } @@ -36,18 +49,93 @@ function parseConfig(raw: string | null | undefined): OdidoConfig { return { ...tweaks, wvdPath: parsed.wvdPath?.trim() || process.env.ODIDO_WVD_PATH || DEFAULT_WVD_PATH, - lastSyncAt: parsed.lastSyncAt, - lastSyncSummary: parsed.lastSyncSummary, - wvdUploadedAt: parsed.wvdUploadedAt, - lastMpdRefreshAt: parsed.lastMpdRefreshAt, - lastMpdRefreshSummary: parsed.lastMpdRefreshSummary, + accounts: parseAccountsJson(parsed.accounts), + channelOwners: parseChannelOwners(parsed.channelOwners), + lastSyncAt: parsed.lastSyncAt as string | undefined, + lastSyncSummary: parsed.lastSyncSummary as Record | undefined, + wvdUploadedAt: parsed.wvdUploadedAt as string | undefined, + lastMpdRefreshAt: parsed.lastMpdRefreshAt as string | undefined, + lastMpdRefreshSummary: parsed.lastMpdRefreshSummary as Record | undefined, }; } +async function ensureAccountsMigrated( + sessionSecret: string, + row: { + username: string | null; + passwordEnc: string | null; + configJson: string | null; + } | null +): Promise { + const config = parseConfig(row?.configJson); + if (config.accounts.length > 0) return config; + if (row?.username && row.passwordEnc) { + try { + const pin = decryptSecret(row.passwordEnc, sessionSecret); + config.accounts = [ + createAccountStored(sessionSecret, { + username: row.username, + pin, + label: row.username, + enabled: true, + }), + ]; + await prisma.integrationSetting.update({ + where: { id: ODIDO_ID }, + data: { configJson: JSON.stringify(config) }, + }); + } catch { + /* legacy pin onleesbaar */ + } + } + return config; +} + +function hasUsableAccount(accounts: OdidoAccountStored[]): boolean { + return accounts.some((a) => a.enabled !== false && !!a.username && !!a.pinEnc); +} + +async function persistAccounts(config: OdidoConfig, enabled: boolean) { + const primary = config.accounts.find((a) => a.enabled) ?? config.accounts[0]; + await prisma.integrationSetting.upsert({ + where: { id: ODIDO_ID }, + create: { + id: ODIDO_ID, + username: primary?.username ?? null, + passwordEnc: primary?.pinEnc ?? null, + configJson: JSON.stringify(config), + enabled, + }, + update: { + username: primary?.username ?? null, + passwordEnc: primary?.pinEnc ?? null, + configJson: JSON.stringify(config), + enabled, + }, + }); +} + export async function getOdidoSettingsPublic() { const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); const config = parseConfig(row?.configJson); const hasWvd = existsSync(config.wvdPath); + const realAccounts = config.accounts; + const publicAccounts = + realAccounts.length > 0 + ? realAccounts.map((a) => accountToPublic(a, config.channelOwners)) + : row?.username + ? [ + { + id: "legacy", + label: row.username, + username: row.username, + hasPin: !!row.passwordEnc, + enabled: true, + channelCount: 0, + }, + ] + : []; + const { lastSyncAt, lastSyncSummary, @@ -55,13 +143,17 @@ export async function getOdidoSettingsPublic() { lastMpdRefreshAt, lastMpdRefreshSummary, wvdPath, - ...tweaks + channelOwners, } = config; + + const configured = + (hasUsableAccount(realAccounts) || (!!row?.username && !!row.passwordEnc)) && hasWvd; + return { - configured: !!row?.username && !!row.passwordEnc && hasWvd, + configured, enabled: row?.enabled ?? true, - username: row?.username ?? "", - hasPin: !!row?.passwordEnc, + username: publicAccounts[0]?.username ?? row?.username ?? "", + hasPin: publicAccounts.some((a) => a.hasPin) || !!row?.passwordEnc, hasWvd, wvdPath, wvdUploadedAt: wvdUploadedAt ?? null, @@ -69,7 +161,10 @@ export async function getOdidoSettingsPublic() { lastSyncSummary: lastSyncSummary ?? null, lastMpdRefreshAt: lastMpdRefreshAt ?? null, lastMpdRefreshSummary: lastMpdRefreshSummary ?? null, - tweaks, + accounts: publicAccounts, + accountCount: publicAccounts.filter((a) => a.enabled).length, + channelOwnerCount: Object.keys(channelOwners).length, + tweaks: mergeOdidoTweaks(config), tweakDefaults: ODIDO_TWEAK_DEFAULTS, tweakMeta: ODIDO_TWEAK_META, }; @@ -86,43 +181,115 @@ export async function upsertOdidoSettings( } ) { const existing = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); - const config = parseConfig(existing?.configJson); + const config = await ensureAccountsMigrated(sessionSecret, existing); + if (input.wvdPath !== undefined) { config.wvdPath = input.wvdPath.trim() || process.env.ODIDO_WVD_PATH || DEFAULT_WVD_PATH; } if (input.tweaks) { - const merged = mergeOdidoTweaks({ ...config, ...input.tweaks }); - Object.assign(config, merged); + Object.assign(config, mergeOdidoTweaks({ ...config, ...input.tweaks })); } - 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, - }, - }); + if (input.username !== undefined || input.pin !== undefined) { + const username = + input.username !== undefined ? input.username.trim() : config.accounts[0]?.username ?? ""; + if (username) { + if (config.accounts[0]) { + config.accounts[0] = { + ...config.accounts[0], + username, + label: config.accounts[0].label || username, + pinEnc: input.pin?.trim() + ? encryptSecret(input.pin.trim(), sessionSecret) + : config.accounts[0].pinEnc, + }; + } else if (input.pin?.trim()) { + config.accounts.push( + createAccountStored(sessionSecret, { + username, + pin: input.pin.trim(), + label: username, + }) + ); + } + } + } + + await persistAccounts(config, input.enabled ?? existing?.enabled ?? true); + return getOdidoSettingsPublic(); +} + +export async function addOdidoAccount( + sessionSecret: string, + input: { username: string; pin: string; label?: string; enabled?: boolean } +) { + const existing = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); + const config = await ensureAccountsMigrated(sessionSecret, existing); + const username = input.username.trim(); + if (config.accounts.some((a) => a.username === username)) { + throw new AppError("INVALID_REQUEST", "Dit klantnummer staat al in de lijst", 400); + } + let account: OdidoAccountStored; + try { + account = createAccountStored(sessionSecret, input); + } catch (e) { + throw new AppError("INVALID_REQUEST", (e as Error).message, 400); + } + config.accounts.push(account); + await persistAccounts(config, existing?.enabled ?? true); + return getOdidoSettingsPublic(); +} + +export async function updateOdidoAccount( + sessionSecret: string, + accountId: string, + input: { username?: string; pin?: string; label?: string; enabled?: boolean } +) { + const existing = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); + const config = await ensureAccountsMigrated(sessionSecret, existing); + const idx = config.accounts.findIndex((a) => a.id === accountId); + if (idx < 0) throw new AppError("NOT_FOUND", "Account niet gevonden", 404); + const current = config.accounts[idx]; + const username = + input.username !== undefined ? input.username.trim() : current.username; + if (!username) throw new AppError("INVALID_REQUEST", "Klantnummer verplicht", 400); + if (config.accounts.some((a, i) => i !== idx && a.username === username)) { + throw new AppError("INVALID_REQUEST", "Dit klantnummer staat al in de lijst", 400); + } + config.accounts[idx] = { + ...current, + username, + label: + input.label !== undefined + ? input.label.trim() || username + : current.label || username, + enabled: input.enabled !== undefined ? input.enabled : current.enabled, + pinEnc: input.pin?.trim() + ? encryptSecret(input.pin.trim(), sessionSecret) + : current.pinEnc, + }; + await persistAccounts(config, existing?.enabled ?? true); + return getOdidoSettingsPublic(); +} + +export async function removeOdidoAccount(sessionSecret: string, accountId: string) { + const existing = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); + const config = await ensureAccountsMigrated(sessionSecret, existing); + const next = config.accounts.filter((a) => a.id !== accountId); + if (next.length === config.accounts.length) { + throw new AppError("NOT_FOUND", "Account niet gevonden", 404); + } + config.accounts = next; + const owners: OdidoChannelOwners = {}; + for (const [ch, ids] of Object.entries(config.channelOwners)) { + const filtered = ids.filter((id) => id !== accountId); + if (filtered.length) owners[ch] = filtered; + } + config.channelOwners = owners; + await persistAccounts(config, 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); @@ -166,13 +333,33 @@ export async function saveOdidoWvdUpload(contentBase64: string) { }; } -export async function loadOdidoSettings(sessionSecret: string) { +export type LoadedOdidoSettings = { + accounts: OdidoAccountCreds[]; + config: OdidoConfig; +}; + +export async function loadOdidoSettings(sessionSecret: string): Promise { 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 = await ensureAccountsMigrated(sessionSecret, row); + const accounts = config.accounts + .filter((a) => a.enabled !== false) + .map((a) => { + try { + return decryptAccount(a, sessionSecret); + } catch { + return null; + } + }) + .filter((a): a is OdidoAccountCreds => !!a); + + if (!accounts.length) { + throw new AppError( + "INVALID_REQUEST", + "Geen actief Odido-account — voeg er een toe onder Scripts → Odido", + 400 + ); } - const config = parseConfig(row.configJson); if (!existsSync(config.wvdPath)) { throw new AppError( "INVALID_REQUEST", @@ -180,19 +367,19 @@ export async function loadOdidoSettings(sessionSecret: string) { 400 ); } - return { - username: row.username, - pin: decryptSecret(row.passwordEnc, sessionSecret), - config, - }; + return { accounts, config }; } -export async function saveOdidoSyncResult(summary: Record) { +export async function saveOdidoSyncResult( + summary: Record, + channelOwners?: OdidoChannelOwners +) { 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; + if (channelOwners) config.channelOwners = channelOwners; await prisma.integrationSetting.update({ where: { id: ODIDO_ID }, data: { configJson: JSON.stringify(config) }, @@ -216,3 +403,11 @@ export async function loadOdidoConfigOnly(): Promise { if (!row?.enabled) return null; return parseConfig(row.configJson); } + +export async function hasOdidoAccountsConfigured(): Promise { + const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); + if (!row?.enabled) return false; + const config = parseConfig(row.configJson); + if (hasUsableAccount(config.accounts)) return true; + return !!row.username && !!row.passwordEnc; +} diff --git a/apps/master-api/src/odido/sync.ts b/apps/master-api/src/odido/sync.ts index 7ef77e6..764a557 100644 --- a/apps/master-api/src/odido/sync.ts +++ b/apps/master-api/src/odido/sync.ts @@ -6,9 +6,11 @@ import { extractOdidoKeys } from "./cdm"; import { getPssh, login, + ODIDO_USER_AGENT, playChannel, queryAllChannels, type OdidoChannel, + type OdidoCookies, } from "./client"; import { loadOdidoSettings, saveOdidoSyncResult } from "./settings"; import { @@ -16,6 +18,7 @@ import { matchEpgChannelId, } from "../viewer/epg-match"; import { loadXmltvChannelIndex } from "../viewer/live-channels"; +import type { OdidoChannelOwners } from "./accounts"; export type OdidoSyncSummary = { added: number; @@ -25,6 +28,10 @@ export type OdidoSyncSummary = { disabled: number; epgMatched: number; epgUnmatched: number; + accounts: number; + uniqueChannels: number; + overlapChannels: number; + perAccount: Array<{ accountId: string; label: string; channels: number }>; durationMs: number; }; @@ -73,6 +80,19 @@ async function mapLimit( })); } +type UnionEntry = { + channel: OdidoChannel; + apiIndex: number; + accountIds: string[]; + /** accountId die PlayChannel succesvol deed (voor keys/cache) */ + playAccountId?: string; + cookies?: OdidoCookies; + playUrl?: string; + licenseUrl?: string | null; + licenseToken?: string | null; + playContentId?: string | null; +}; + export async function syncOdidoList( listId: string, sessionSecret: string, @@ -90,15 +110,77 @@ export async function syncOdidoList( const settings = await loadOdidoSettings(sessionSecret); const tweaks = settings.config; - const cookies = await login( - settings.username, - settings.pin, - tweaks.sessionCacheMinutes * 60_000 - ); - const allChannels = await queryAllChannels(cookies); - const videos = allChannels - .map((channel, apiIndex) => ({ channel, apiIndex })) - .filter(({ channel }) => channel.contentType === "VIDEO_CHANNEL"); + const accounts = settings.accounts; + + const union = new Map(); + const perAccount: Array<{ accountId: string; label: string; channels: number }> = []; + const cookiesByAccount = new Map(); + + // 1) Per account: login + zenderlijst → unie (geen dubbele LiveChannel-rijen) + for (const account of accounts) { + let cookies: OdidoCookies; + try { + cookies = await login( + account.username, + account.pin, + tweaks.sessionCacheMinutes * 60_000 + ); + cookiesByAccount.set(account.id, cookies); + } catch (err) { + console.warn(`[odido] login mislukt voor ${account.username}:`, err); + perAccount.push({ accountId: account.id, label: account.label, channels: 0 }); + continue; + } + + let channels: OdidoChannel[] = []; + try { + channels = await queryAllChannels(cookies); + } catch (err) { + console.warn(`[odido] QueryAllChannel mislukt voor ${account.username}:`, err); + perAccount.push({ accountId: account.id, label: account.label, channels: 0 }); + continue; + } + + const videos = channels.filter((c) => c.contentType === "VIDEO_CHANNEL"); + let count = 0; + for (let apiIndex = 0; apiIndex < videos.length; apiIndex++) { + const channel = videos[apiIndex]; + const externalId = channel.ID?.trim(); + const mediaId = channel.physicalChannels?.[0]?.ID?.trim(); + if (!externalId || !mediaId) continue; + count += 1; + const existing = union.get(externalId); + if (existing) { + if (!existing.accountIds.includes(account.id)) { + existing.accountIds.push(account.id); + } + // Behoud rijkste metadata (logo/channelNO) + if (!logoUrl(existing.channel) && logoUrl(channel)) existing.channel = channel; + if ( + (existing.channel.channelNO == null || existing.channel.channelNO === "") && + channel.channelNO != null + ) { + existing.channel = { ...existing.channel, channelNO: channel.channelNO }; + } + } else { + union.set(externalId, { + channel, + apiIndex, + accountIds: [account.id], + }); + } + } + perAccount.push({ accountId: account.id, label: account.label, channels: count }); + } + + if (!union.size) { + throw new AppError( + "ODIDO_ERROR", + "Geen TV-zenders gevonden op de geconfigureerde Odido-accounts", + 502 + ); + } + const existing = new Map( list.channels .filter((channel) => channel.externalId) @@ -129,19 +211,38 @@ export async function syncOdidoList( } } - await mapLimit(videos, tweaks.syncConcurrency, async ({ channel, apiIndex }) => { - const externalId = channel.ID?.trim(); - const physical = channel.physicalChannels?.[0]; + const channelOwners: OdidoChannelOwners = {}; + const entries = [...union.entries()]; + + // 2) PlayChannel per unieke zender — eerste account dat lukt (keys/cache) + await mapLimit(entries, tweaks.syncConcurrency, async ([externalId, entry]) => { + channelOwners[externalId] = [...entry.accountIds]; + const physical = entry.channel.physicalChannels?.[0]; const mediaId = physical?.ID?.trim(); - if (!externalId || !mediaId) { + if (!mediaId) { counts.failed += 1; return; } - let play; - try { - play = await playChannel(cookies, externalId, mediaId); - } catch { + let playOk = false; + for (const accountId of entry.accountIds) { + const cookies = cookiesByAccount.get(accountId); + if (!cookies) continue; + try { + const play = await playChannel(cookies, externalId, mediaId); + entry.playAccountId = accountId; + entry.cookies = play.cookies; + entry.playUrl = play.playUrl; + entry.licenseUrl = play.licenseUrl; + entry.licenseToken = play.licenseToken; + entry.playContentId = play.contentId; + playOk = true; + break; + } catch { + /* probeer volgend account */ + } + } + if (!playOk) { counts.skipped += 1; return; } @@ -151,20 +252,22 @@ export async function syncOdidoList( let keyEnc = previous?.keyEnc ?? null; if ( tweaks.syncFetchKeysIfMissing && - play.licenseUrl && - play.licenseToken && + entry.licenseUrl && + entry.licenseToken && + entry.playUrl && + entry.cookies && (!kidEnc || !keyEnc) ) { try { - const pssh = await getPssh(play.playUrl, play.cookies); + const pssh = await getPssh(entry.playUrl, entry.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, + licUrl: entry.licenseUrl, + licToken: entry.licenseToken, + cookies: entry.cookies, + userAgent: ODIDO_USER_AGENT, }); const key = keys.map(parseKey).find((item) => item !== null); if (!key) throw new Error("Geen bruikbare ClearKey ontvangen"); @@ -176,9 +279,9 @@ export async function syncOdidoList( } } - const name = channel.name?.trim() || channel.channelName?.trim() || + const name = entry.channel.name?.trim() || entry.channel.channelName?.trim() || physical?.mediaName?.trim() || externalId; - const resolvedContentId = play.contentId ?? contentId(channel); + const resolvedContentId = entry.playContentId ?? contentId(entry.channel); const now = new Date(); let epgChannelId = previous?.epgChannelId ?? null; @@ -206,17 +309,17 @@ export async function syncOdidoList( const data = { name, - logoUrl: logoUrl(channel), + logoUrl: logoUrl(entry.channel), mpdUrl: `odido://${externalId}/${mediaId}`, mediaId, contentId: resolvedContentId, epgChannelId, kidEnc, keyEnc, - sortOrder: channelSortOrder(channel, apiIndex), + sortOrder: channelSortOrder(entry.channel, entry.apiIndex), enabled: true, - ...(tweaks.syncRefreshMpdCache - ? { streamUrlCache: play.playUrl, streamUrlCachedAt: now } + ...(tweaks.syncRefreshMpdCache && entry.playUrl + ? { streamUrlCache: entry.playUrl, streamUrlCachedAt: now } : {}), }; if (previous) { @@ -241,11 +344,20 @@ export async function syncOdidoList( })).count : 0; + let overlapChannels = 0; + for (const ids of Object.values(channelOwners)) { + if (ids.length > 1) overlapChannels += 1; + } + const summary: OdidoSyncSummary = { ...counts, disabled, + accounts: accounts.length, + uniqueChannels: successful.size, + overlapChannels, + perAccount, durationMs: Date.now() - started, }; - await saveOdidoSyncResult(summary); + await saveOdidoSyncResult(summary, channelOwners); return summary; }