Support multiple Odido accounts with union sync and round-robin play.

This commit is contained in:
Jos Vooges | STH 2026-09-22 00:43:14 +02:00
parent 15a678b835
commit 96117a4887
9 changed files with 950 additions and 177 deletions

View file

@ -328,6 +328,11 @@ export default function LiveListsPage() {
d.disabled != null ? `${d.disabled} uit` : null, d.disabled != null ? `${d.disabled} uit` : null,
d.skipped != null ? `${d.skipped} overgeslagen` : null, d.skipped != null ? `${d.skipped} overgeslagen` : null,
d.failed != null ? `${d.failed} mislukt` : 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.epgMatched != null ? `EPG ${d.epgMatched}` : null,
d.epgUnmatched != null && d.epgUnmatched > 0 ? `EPG-miss ${d.epgUnmatched}` : null, d.epgUnmatched != null && d.epgUnmatched > 0 ? `EPG-miss ${d.epgUnmatched}` : null,
d.durationMs != null ? `${Math.round(d.durationMs / 1000)}s` : null, d.durationMs != null ? `${Math.round(d.durationMs / 1000)}s` : null,

View file

@ -14,11 +14,18 @@ type TweakMeta = {
type OdidoTweaks = Record<string, number | boolean>; type OdidoTweaks = Record<string, number | boolean>;
type OdidoAccount = {
id: string;
label: string;
username: string;
hasPin: boolean;
enabled: boolean;
channelCount: number;
};
type OdidoSettings = { type OdidoSettings = {
configured: boolean; configured: boolean;
enabled: boolean; enabled: boolean;
username: string;
hasPin: boolean;
hasWvd: boolean; hasWvd: boolean;
wvdPath: string; wvdPath: string;
wvdUploadedAt: string | null; wvdUploadedAt: string | null;
@ -26,6 +33,9 @@ type OdidoSettings = {
lastSyncSummary: Record<string, unknown> | null; lastSyncSummary: Record<string, unknown> | null;
lastMpdRefreshAt: string | null; lastMpdRefreshAt: string | null;
lastMpdRefreshSummary: Record<string, unknown> | null; lastMpdRefreshSummary: Record<string, unknown> | null;
accounts: OdidoAccount[];
accountCount: number;
channelOwnerCount: number;
tweaks: OdidoTweaks; tweaks: OdidoTweaks;
tweakDefaults: OdidoTweaks; tweakDefaults: OdidoTweaks;
tweakMeta: TweakMeta[]; tweakMeta: TweakMeta[];
@ -47,8 +57,6 @@ function readFileAsBase64(file: File): Promise<string> {
export default function ScriptsPage() { export default function ScriptsPage() {
useAuth(); useAuth();
const [odido, setOdido] = useState<OdidoSettings | null>(null); const [odido, setOdido] = useState<OdidoSettings | null>(null);
const [username, setUsername] = useState("");
const [pin, setPin] = useState("");
const [wvdPath, setWvdPath] = useState(""); const [wvdPath, setWvdPath] = useState("");
const [enabled, setEnabled] = useState(true); const [enabled, setEnabled] = useState(true);
const [tweaks, setTweaks] = useState<OdidoTweaks>({}); const [tweaks, setTweaks] = useState<OdidoTweaks>({});
@ -58,13 +66,15 @@ export default function ScriptsPage() {
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const wvdInputRef = useRef<HTMLInputElement>(null); const wvdInputRef = useRef<HTMLInputElement>(null);
const [newLabel, setNewLabel] = useState("");
const [newUsername, setNewUsername] = useState("");
const [newPin, setNewPin] = useState("");
const applySettings = useCallback((d: OdidoSettings) => { const applySettings = useCallback((d: OdidoSettings) => {
setOdido(d); setOdido(d);
setUsername(d.username ?? "");
setWvdPath(d.wvdPath ?? ""); setWvdPath(d.wvdPath ?? "");
setEnabled(d.enabled !== false); setEnabled(d.enabled !== false);
setTweaks(d.tweaks ?? d.tweakDefaults ?? {}); setTweaks(d.tweaks ?? d.tweakDefaults ?? {});
setPin("");
}, []); }, []);
const load = useCallback(async () => { const load = useCallback(async () => {
@ -84,18 +94,11 @@ export default function ScriptsPage() {
setErr(null); setErr(null);
setMsg(null); setMsg(null);
try { try {
const body: Record<string, unknown> = {
username: username.trim(),
wvdPath: wvdPath.trim(),
enabled,
tweaks,
};
if (pin.trim()) body.pin = pin.trim();
const r = await fetch("/api/v1/admin/scripts/odido", { const r = await fetch("/api/v1/admin/scripts/odido", {
method: "PUT", method: "PUT",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify({ wvdPath: wvdPath.trim(), enabled, tweaks }),
}); });
const d = await r.json(); const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Opslaan mislukt"); 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) { async function uploadWvd(file: File | null) {
if (!file) return; if (!file) return;
setBusy(true); setBusy(true);
@ -186,6 +261,7 @@ export default function ScriptsPage() {
const summary = odido?.lastSyncSummary; const summary = odido?.lastSyncSummary;
const refreshSummary = odido?.lastMpdRefreshSummary; const refreshSummary = odido?.lastMpdRefreshSummary;
const meta = odido?.tweakMeta ?? []; const meta = odido?.tweakMeta ?? [];
const accounts = odido?.accounts ?? [];
return ( return (
<> <>
@ -199,8 +275,9 @@ export default function ScriptsPage() {
</div> </div>
</div> </div>
<p className="muted page-lead"> <p className="muted page-lead">
Provider-scripts voor live-lijsten. Stel hier Odido in, koppel daarna een lijst onder{" "} Provider-scripts voor live-lijsten. Meerdere Odido-accounts: sync bouwt één unieke
<Link href="/live-lists">Live-lijsten</Link> en doe daar “Volledig ophalen”. zenderlijst (geen dubbels); overlap wordt round-robin verdeeld bij play/refresh. Koppel
daarna een lijst onder <Link href="/live-lists">Live-lijsten</Link>.
</p> </p>
{msg && ( {msg && (
@ -217,33 +294,102 @@ export default function ScriptsPage() {
<div className="card" style={{ maxWidth: 720 }}> <div className="card" style={{ maxWidth: 720 }}>
<h2 style={{ marginTop: 0 }}>Odido</h2> <h2 style={{ marginTop: 0 }}>Odido</h2>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
Play gebruikt een gecachte MPD als die jonger is dan de TTL (default 1 uur). Anders Accounts met verschillende abonnementen vullen elkaar aan. Gedeelde zenders worden
(of via achtergrond-refresh) verse PlayChannel. ClearKey blijft in de DB. load-balanced; unieke zenders komen van het account dat ze wél heeft.
</p> </p>
<h3 style={{ marginBottom: "0.5rem" }}>Accounts</h3>
{accounts.length === 0 ? (
<p className="muted">Nog geen accounts — voeg hieronder je eerste klantnummer toe.</p>
) : (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 1rem" }}>
{accounts.map((a) => (
<li
key={a.id}
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
alignItems: "center",
justifyContent: "space-between",
padding: "0.6rem 0",
borderBottom: "1px solid var(--border, #333)",
}}
>
<div>
<strong>{a.label || a.username}</strong>
<span className="muted" style={{ marginLeft: 8, fontSize: "0.85rem" }}>
{a.username}
{a.id !== "legacy" ? ` · ${a.channelCount} zenders` : ""}
{!a.enabled ? " · uit" : ""}
</span>
</div>
<div style={{ display: "flex", gap: 6 }}>
{a.id !== "legacy" && (
<>
<button type="button" disabled={busy} onClick={() => void toggleAccount(a)}>
{a.enabled ? "Uitschakelen" : "Inschakelen"}
</button>
<button type="button" disabled={busy} onClick={() => void removeAccount(a)}>
Verwijderen
</button>
</>
)}
</div>
</li>
))}
</ul>
)}
<form onSubmit={addAccount} className="form-group" style={{ marginBottom: "1.5rem" }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
gap: "0.75rem",
}}
>
<label className="field">
Label (optioneel)
<input
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="bijv. Thuis / Zakelijk"
disabled={busy}
/>
</label>
<label className="field">
Klantnummer
<input
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
placeholder="TV-klantnummer"
disabled={busy}
required
autoComplete="username"
/>
</label>
<label className="field">
Pincode
<input
type="password"
value={newPin}
onChange={(e) => setNewPin(e.target.value)}
placeholder="4-cijferig"
disabled={busy}
required
autoComplete="current-password"
/>
</label>
</div>
<button type="submit" className="btn-primary" disabled={busy} style={{ marginTop: 8 }}>
Account toevoegen
</button>
</form>
<form onSubmit={saveOdido} className="form-group"> <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"> <div className="field">
<span>WVD.wvd (Widevine device)</span> <span>WVD.wvd (Widevine device — gedeeld)</span>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center", marginTop: 6 }}> <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center", marginTop: 6 }}>
<input <input
ref={wvdInputRef} ref={wvdInputRef}
@ -312,14 +458,12 @@ export default function ScriptsPage() {
Defaults herstellen Defaults herstellen
</button> </button>
</div> </div>
<p className="muted" style={{ fontSize: "0.85rem" }}>
Wijzigingen gelden na Opslaan. Achtergrond-refresh leest deze waarden live.
</p>
<div <div
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
gap: "0.75rem 1rem", gap: "0.75rem 1rem",
marginTop: "0.75rem",
}} }}
> >
{meta.map((item) => { {meta.map((item) => {
@ -392,10 +536,15 @@ export default function ScriptsPage() {
Status:{" "} Status:{" "}
{odido?.configured ? ( {odido?.configured ? (
<span className="ok-banner" style={{ display: "inline", padding: "2px 8px" }}> <span className="ok-banner" style={{ display: "inline", padding: "2px 8px" }}>
geconfigureerd geconfigureerd ({odido.accountCount} account
{odido.accountCount === 1 ? "" : "s"}
{odido.channelOwnerCount
? ` · ${odido.channelOwnerCount} zenders na sync`
: ""}
)
</span> </span>
) : ( ) : (
<span>nog niet compleet (klantnummer + pincode + WVD)</span> <span>nog niet compleet (minstens 1 account + WVD)</span>
)} )}
{odido?.lastSyncAt && ( {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.added != null ? `+${String(summary.added)}` : null,
summary.updated != null ? `~${String(summary.updated)}` : 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) .filter(Boolean)
.join(" · ") || "ok"} .join(" · ") || "ok"}
@ -430,9 +583,8 @@ export default function ScriptsPage() {
refreshSummary.refreshed != null refreshSummary.refreshed != null
? `${String(refreshSummary.refreshed)} ok` ? `${String(refreshSummary.refreshed)} ok`
: null, : null,
refreshSummary.failed != null ? `${String(refreshSummary.failed)} fout` : null, refreshSummary.failed != null
refreshSummary.triggeredBy != null ? `${String(refreshSummary.failed)} fout`
? String(refreshSummary.triggeredBy)
: null, : null,
] ]
.filter(Boolean) .filter(Boolean)

View file

@ -1647,6 +1647,51 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return flushOdidoMpdCache(); 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 --- // --- Custom ClearKey/DASH live channel lists ---
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => { app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels"); const { listLiveChannelListsAdmin } = await import("../viewer/live-channels");

View file

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

View file

@ -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<string, string[]>;
const rrCursor = new Map<string, number>();
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<string, unknown>;
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<string, unknown>)) {
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;
}

View file

@ -11,6 +11,11 @@ import {
} from "./client"; } from "./client";
import { loadOdidoSettings } from "./settings"; import { loadOdidoSettings } from "./settings";
import { isMpdCacheFresh, type OdidoTweaks } from "./tweaks"; import { isMpdCacheFresh, type OdidoTweaks } from "./tweaks";
import {
ownersForChannel,
pickAccountIdForChannel,
type OdidoAccountCreds,
} from "./accounts";
export type OdidoChannelPlayState = { export type OdidoChannelPlayState = {
id: string; id: string;
@ -30,6 +35,7 @@ export type ResolveOdidoPlayResult = {
keyEnc: string | null; keyEnc: string | null;
fromCache: boolean; fromCache: boolean;
contentId: string | null; contentId: string | null;
accountId?: string;
}; };
function parseKey(value: string): { kid: string; key: string } | null { function parseKey(value: string): { kid: string; key: string } | null {
@ -92,10 +98,39 @@ async function fetchKeysIfNeeded(
}; };
} }
function accountOrderForChannel(
externalId: string,
accounts: OdidoAccountCreds[],
channelOwners: Record<string, string[]>
): 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( export async function refreshChannelMpd(
channel: OdidoChannelPlayState, channel: OdidoChannelPlayState,
sessionSecret: string, sessionSecret: string,
opts?: { cookies?: OdidoCookies; tweaks?: OdidoTweaks; fetchKeys?: boolean } opts?: {
cookies?: OdidoCookies;
tweaks?: OdidoTweaks;
fetchKeys?: boolean;
account?: OdidoAccountCreds;
}
): Promise<ResolveOdidoPlayResult> { ): Promise<ResolveOdidoPlayResult> {
const settings = await loadOdidoSettings(sessionSecret); const settings = await loadOdidoSettings(sessionSecret);
const tweaks = opts?.tweaks ?? settings.config; const tweaks = opts?.tweaks ?? settings.config;
@ -103,41 +138,56 @@ export async function refreshChannelMpd(
const mediaId = channel.mediaId; const mediaId = channel.mediaId;
if (!externalId || !mediaId) throw new Error("Odido-zenderreferentie is onvolledig"); if (!externalId || !mediaId) throw new Error("Odido-zenderreferentie is onvolledig");
const cookies = const ordered = opts?.account
opts?.cookies ?? ? [opts.account, ...settings.accounts.filter((a) => a.id !== opts.account!.id)]
(await login(settings.username, settings.pin, tweaks.sessionCacheMinutes * 60_000)); : accountOrderForChannel(externalId, settings.accounts, settings.config.channelOwners);
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 now = new Date(); let lastError: Error | null = null;
await prisma.liveChannel.update({ for (const account of ordered) {
where: { id: channel.id }, try {
data: { const cookies =
streamUrlCache: play.playUrl, opts?.cookies && opts.account?.id === account.id
streamUrlCachedAt: now, ? opts.cookies
contentId: play.contentId ?? channel.contentId, : await login(account.username, account.pin, tweaks.sessionCacheMinutes * 60_000);
externalId, const play = await playChannel(cookies, externalId, mediaId);
mediaId, const keys = await fetchKeysIfNeeded(
mpdUrl: `odido://${externalId}/${mediaId}`, play,
...(keys.kidEnc ? { kidEnc: keys.kidEnc } : {}), settings.config.wvdPath,
...(keys.keyEnc ? { keyEnc: keys.keyEnc } : {}), sessionSecret,
}, channel.kidEnc,
}); channel.keyEnc,
opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay
);
return { const now = new Date();
streamUrl: play.playUrl, await prisma.liveChannel.update({
kidEnc: keys.kidEnc, where: { id: channel.id },
keyEnc: keys.keyEnc, data: {
fromCache: false, streamUrlCache: play.playUrl,
contentId: play.contentId ?? channel.contentId, 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( export async function resolveOdidoPlay(

View file

@ -2,10 +2,15 @@ import { prisma } from "../database/client";
import { login } from "./client"; import { login } from "./client";
import { refreshChannelMpd } from "./play"; import { refreshChannelMpd } from "./play";
import { import {
hasOdidoAccountsConfigured,
loadOdidoConfigOnly, loadOdidoConfigOnly,
loadOdidoSettings, loadOdidoSettings,
saveOdidoMpdRefreshResult, saveOdidoMpdRefreshResult,
} from "./settings"; } from "./settings";
import {
ownersForChannel,
pickAccountIdForChannel,
} from "./accounts";
export type OdidoMpdRefreshSummary = { export type OdidoMpdRefreshSummary = {
refreshed: number; refreshed: number;
@ -13,6 +18,7 @@ export type OdidoMpdRefreshSummary = {
skipped: number; skipped: number;
durationMs: number; durationMs: number;
triggeredBy: "schedule" | "manual"; triggeredBy: "schedule" | "manual";
byAccount?: Record<string, number>;
}; };
async function mapLimit<T>( async function mapLimit<T>(
@ -64,11 +70,9 @@ export async function refreshOdidoMpdCache(
}); });
const counts = { refreshed: 0, failed: 0, skipped: 0 }; const counts = { refreshed: 0, failed: 0, skipped: 0 };
const cookies = await login( const byAccount: Record<string, number> = {};
settings.username, const cookiesByAccount = new Map<string, Awaited<ReturnType<typeof login>>>();
settings.pin, const enabledIds = settings.accounts.map((a) => a.id);
tweaks.sessionCacheMinutes * 60_000
);
await mapLimit( await mapLimit(
channels, channels,
@ -79,13 +83,33 @@ export async function refreshOdidoMpdCache(
counts.skipped += 1; counts.skipped += 1;
return; 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 { 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, { await refreshChannelMpd(channel, sessionSecret, {
cookies, cookies,
account,
tweaks, tweaks,
fetchKeys: false, fetchKeys: false,
}); });
counts.refreshed += 1; counts.refreshed += 1;
byAccount[account.label || account.username] =
(byAccount[account.label || account.username] ?? 0) + 1;
} catch { } catch {
counts.failed += 1; counts.failed += 1;
} }
@ -96,6 +120,7 @@ export async function refreshOdidoMpdCache(
...counts, ...counts,
durationMs: Date.now() - started, durationMs: Date.now() - started,
triggeredBy, triggeredBy,
byAccount,
}; };
await saveOdidoMpdRefreshResult(summary); await saveOdidoMpdRefreshResult(summary);
return summary; return summary;
@ -112,16 +137,11 @@ export async function flushOdidoMpdCache(): Promise<{ cleared: number }> {
return { cleared: result.count }; 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 { export function startOdidoMpdRefreshWorker(getSessionSecret: () => string): () => void {
let running = false; let running = false;
const tick = async () => { const tick = async () => {
if (running) return; if (running) return;
const row = await prisma.integrationSetting.findUnique({ where: { id: "odido" } }); if (!(await hasOdidoAccountsConfigured())) return;
if (!row?.enabled || !row.username || !row.passwordEnc) return;
const config = await loadOdidoConfigOnly(); const config = await loadOdidoConfigOnly();
if (!config?.mpdRefreshEnabled) return; if (!config?.mpdRefreshEnabled) return;
const last = config.lastMpdRefreshAt ? Date.parse(config.lastMpdRefreshAt) : 0; const last = config.lastMpdRefreshAt ? Date.parse(config.lastMpdRefreshAt) : 0;

View file

@ -2,20 +2,33 @@ import { existsSync } from "node:fs";
import { mkdir, writeFile, stat } from "node:fs/promises"; import { mkdir, writeFile, stat } from "node:fs/promises";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { prisma } from "../database/client"; import { prisma } from "../database/client";
import { decryptSecret, encryptSecret } from "../security/crypto"; import { encryptSecret } from "../security/crypto";
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
import {
accountToPublic,
createAccountStored,
decryptAccount,
parseAccountsJson,
parseChannelOwners,
type OdidoAccountCreds,
type OdidoAccountStored,
type OdidoChannelOwners,
} from "./accounts";
import { import {
mergeOdidoTweaks, mergeOdidoTweaks,
ODIDO_TWEAK_DEFAULTS, ODIDO_TWEAK_DEFAULTS,
ODIDO_TWEAK_META, ODIDO_TWEAK_META,
type OdidoTweaks, type OdidoTweaks,
} from "./tweaks"; } from "./tweaks";
import { decryptSecret } from "../security/crypto";
const ODIDO_ID = "odido"; const ODIDO_ID = "odido";
const DEFAULT_WVD_PATH = "/data/odido/WVD.wvd"; const DEFAULT_WVD_PATH = "/data/odido/WVD.wvd";
export type OdidoConfig = OdidoTweaks & { export type OdidoConfig = OdidoTweaks & {
wvdPath: string; wvdPath: string;
accounts: OdidoAccountStored[];
channelOwners: OdidoChannelOwners;
lastSyncAt?: string; lastSyncAt?: string;
lastSyncSummary?: Record<string, unknown>; lastSyncSummary?: Record<string, unknown>;
wvdUploadedAt?: string; wvdUploadedAt?: string;
@ -24,10 +37,10 @@ export type OdidoConfig = OdidoTweaks & {
}; };
function parseConfig(raw: string | null | undefined): OdidoConfig { function parseConfig(raw: string | null | undefined): OdidoConfig {
let parsed: Partial<OdidoConfig> = {}; let parsed: Partial<OdidoConfig> & Record<string, unknown> = {};
if (raw) { if (raw) {
try { try {
parsed = JSON.parse(raw) as Partial<OdidoConfig>; parsed = JSON.parse(raw) as Partial<OdidoConfig> & Record<string, unknown>;
} catch { } catch {
parsed = {}; parsed = {};
} }
@ -36,18 +49,93 @@ function parseConfig(raw: string | null | undefined): OdidoConfig {
return { return {
...tweaks, ...tweaks,
wvdPath: parsed.wvdPath?.trim() || process.env.ODIDO_WVD_PATH || DEFAULT_WVD_PATH, wvdPath: parsed.wvdPath?.trim() || process.env.ODIDO_WVD_PATH || DEFAULT_WVD_PATH,
lastSyncAt: parsed.lastSyncAt, accounts: parseAccountsJson(parsed.accounts),
lastSyncSummary: parsed.lastSyncSummary, channelOwners: parseChannelOwners(parsed.channelOwners),
wvdUploadedAt: parsed.wvdUploadedAt, lastSyncAt: parsed.lastSyncAt as string | undefined,
lastMpdRefreshAt: parsed.lastMpdRefreshAt, lastSyncSummary: parsed.lastSyncSummary as Record<string, unknown> | undefined,
lastMpdRefreshSummary: parsed.lastMpdRefreshSummary, wvdUploadedAt: parsed.wvdUploadedAt as string | undefined,
lastMpdRefreshAt: parsed.lastMpdRefreshAt as string | undefined,
lastMpdRefreshSummary: parsed.lastMpdRefreshSummary as Record<string, unknown> | undefined,
}; };
} }
async function ensureAccountsMigrated(
sessionSecret: string,
row: {
username: string | null;
passwordEnc: string | null;
configJson: string | null;
} | null
): Promise<OdidoConfig> {
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() { export async function getOdidoSettingsPublic() {
const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
const config = parseConfig(row?.configJson); const config = parseConfig(row?.configJson);
const hasWvd = existsSync(config.wvdPath); 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 { const {
lastSyncAt, lastSyncAt,
lastSyncSummary, lastSyncSummary,
@ -55,13 +143,17 @@ export async function getOdidoSettingsPublic() {
lastMpdRefreshAt, lastMpdRefreshAt,
lastMpdRefreshSummary, lastMpdRefreshSummary,
wvdPath, wvdPath,
...tweaks channelOwners,
} = config; } = config;
const configured =
(hasUsableAccount(realAccounts) || (!!row?.username && !!row.passwordEnc)) && hasWvd;
return { return {
configured: !!row?.username && !!row.passwordEnc && hasWvd, configured,
enabled: row?.enabled ?? true, enabled: row?.enabled ?? true,
username: row?.username ?? "", username: publicAccounts[0]?.username ?? row?.username ?? "",
hasPin: !!row?.passwordEnc, hasPin: publicAccounts.some((a) => a.hasPin) || !!row?.passwordEnc,
hasWvd, hasWvd,
wvdPath, wvdPath,
wvdUploadedAt: wvdUploadedAt ?? null, wvdUploadedAt: wvdUploadedAt ?? null,
@ -69,7 +161,10 @@ export async function getOdidoSettingsPublic() {
lastSyncSummary: lastSyncSummary ?? null, lastSyncSummary: lastSyncSummary ?? null,
lastMpdRefreshAt: lastMpdRefreshAt ?? null, lastMpdRefreshAt: lastMpdRefreshAt ?? null,
lastMpdRefreshSummary: lastMpdRefreshSummary ?? 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, tweakDefaults: ODIDO_TWEAK_DEFAULTS,
tweakMeta: ODIDO_TWEAK_META, tweakMeta: ODIDO_TWEAK_META,
}; };
@ -86,43 +181,115 @@ export async function upsertOdidoSettings(
} }
) { ) {
const existing = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); 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) { if (input.wvdPath !== undefined) {
config.wvdPath = input.wvdPath.trim() || process.env.ODIDO_WVD_PATH || DEFAULT_WVD_PATH; config.wvdPath = input.wvdPath.trim() || process.env.ODIDO_WVD_PATH || DEFAULT_WVD_PATH;
} }
if (input.tweaks) { if (input.tweaks) {
const merged = mergeOdidoTweaks({ ...config, ...input.tweaks }); Object.assign(config, mergeOdidoTweaks({ ...config, ...input.tweaks }));
Object.assign(config, merged);
} }
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({ if (input.username !== undefined || input.pin !== undefined) {
where: { id: ODIDO_ID }, const username =
create: { input.username !== undefined ? input.username.trim() : config.accounts[0]?.username ?? "";
id: ODIDO_ID, if (username) {
username, if (config.accounts[0]) {
passwordEnc, config.accounts[0] = {
configJson: JSON.stringify(config), ...config.accounts[0],
enabled: input.enabled ?? true, username,
}, label: config.accounts[0].label || username,
update: { pinEnc: input.pin?.trim()
username, ? encryptSecret(input.pin.trim(), sessionSecret)
passwordEnc, : config.accounts[0].pinEnc,
configJson: JSON.stringify(config), };
enabled: input.enabled ?? existing?.enabled ?? true, } 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(); return getOdidoSettingsPublic();
} }
/** Sla WVD.wvd op via admin-upload (base64). Max ~2 MiB. */
export async function saveOdidoWvdUpload(contentBase64: string) { export async function saveOdidoWvdUpload(contentBase64: string) {
const cleaned = contentBase64.replace(/^data:.*?;base64,/, "").trim(); const cleaned = contentBase64.replace(/^data:.*?;base64,/, "").trim();
if (!cleaned) throw new AppError("INVALID_REQUEST", "Lege WVD-upload", 400); 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<LoadedOdidoSettings> {
const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); 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?.enabled) throw new AppError("INVALID_REQUEST", "Odido-integratie is uitgeschakeld", 400);
if (!row.username || !row.passwordEnc) { const config = await ensureAccountsMigrated(sessionSecret, row);
throw new AppError("INVALID_REQUEST", "Odido-klantnummer en pincode zijn niet ingesteld", 400); 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)) { if (!existsSync(config.wvdPath)) {
throw new AppError( throw new AppError(
"INVALID_REQUEST", "INVALID_REQUEST",
@ -180,19 +367,19 @@ export async function loadOdidoSettings(sessionSecret: string) {
400 400
); );
} }
return { return { accounts, config };
username: row.username,
pin: decryptSecret(row.passwordEnc, sessionSecret),
config,
};
} }
export async function saveOdidoSyncResult(summary: Record<string, unknown>) { export async function saveOdidoSyncResult(
summary: Record<string, unknown>,
channelOwners?: OdidoChannelOwners
) {
const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
if (!row) return; if (!row) return;
const config = parseConfig(row.configJson); const config = parseConfig(row.configJson);
config.lastSyncAt = new Date().toISOString(); config.lastSyncAt = new Date().toISOString();
config.lastSyncSummary = summary; config.lastSyncSummary = summary;
if (channelOwners) config.channelOwners = channelOwners;
await prisma.integrationSetting.update({ await prisma.integrationSetting.update({
where: { id: ODIDO_ID }, where: { id: ODIDO_ID },
data: { configJson: JSON.stringify(config) }, data: { configJson: JSON.stringify(config) },
@ -216,3 +403,11 @@ export async function loadOdidoConfigOnly(): Promise<OdidoConfig | null> {
if (!row?.enabled) return null; if (!row?.enabled) return null;
return parseConfig(row.configJson); return parseConfig(row.configJson);
} }
export async function hasOdidoAccountsConfigured(): Promise<boolean> {
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;
}

View file

@ -6,9 +6,11 @@ import { extractOdidoKeys } from "./cdm";
import { import {
getPssh, getPssh,
login, login,
ODIDO_USER_AGENT,
playChannel, playChannel,
queryAllChannels, queryAllChannels,
type OdidoChannel, type OdidoChannel,
type OdidoCookies,
} from "./client"; } from "./client";
import { loadOdidoSettings, saveOdidoSyncResult } from "./settings"; import { loadOdidoSettings, saveOdidoSyncResult } from "./settings";
import { import {
@ -16,6 +18,7 @@ import {
matchEpgChannelId, matchEpgChannelId,
} from "../viewer/epg-match"; } from "../viewer/epg-match";
import { loadXmltvChannelIndex } from "../viewer/live-channels"; import { loadXmltvChannelIndex } from "../viewer/live-channels";
import type { OdidoChannelOwners } from "./accounts";
export type OdidoSyncSummary = { export type OdidoSyncSummary = {
added: number; added: number;
@ -25,6 +28,10 @@ export type OdidoSyncSummary = {
disabled: number; disabled: number;
epgMatched: number; epgMatched: number;
epgUnmatched: number; epgUnmatched: number;
accounts: number;
uniqueChannels: number;
overlapChannels: number;
perAccount: Array<{ accountId: string; label: string; channels: number }>;
durationMs: number; durationMs: number;
}; };
@ -73,6 +80,19 @@ async function mapLimit<T>(
})); }));
} }
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( export async function syncOdidoList(
listId: string, listId: string,
sessionSecret: string, sessionSecret: string,
@ -90,15 +110,77 @@ export async function syncOdidoList(
const settings = await loadOdidoSettings(sessionSecret); const settings = await loadOdidoSettings(sessionSecret);
const tweaks = settings.config; const tweaks = settings.config;
const cookies = await login( const accounts = settings.accounts;
settings.username,
settings.pin, const union = new Map<string, UnionEntry>();
tweaks.sessionCacheMinutes * 60_000 const perAccount: Array<{ accountId: string; label: string; channels: number }> = [];
); const cookiesByAccount = new Map<string, OdidoCookies>();
const allChannels = await queryAllChannels(cookies);
const videos = allChannels // 1) Per account: login + zenderlijst → unie (geen dubbele LiveChannel-rijen)
.map((channel, apiIndex) => ({ channel, apiIndex })) for (const account of accounts) {
.filter(({ channel }) => channel.contentType === "VIDEO_CHANNEL"); 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( const existing = new Map(
list.channels list.channels
.filter((channel) => channel.externalId) .filter((channel) => channel.externalId)
@ -129,19 +211,38 @@ export async function syncOdidoList(
} }
} }
await mapLimit(videos, tweaks.syncConcurrency, async ({ channel, apiIndex }) => { const channelOwners: OdidoChannelOwners = {};
const externalId = channel.ID?.trim(); const entries = [...union.entries()];
const physical = channel.physicalChannels?.[0];
// 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(); const mediaId = physical?.ID?.trim();
if (!externalId || !mediaId) { if (!mediaId) {
counts.failed += 1; counts.failed += 1;
return; return;
} }
let play; let playOk = false;
try { for (const accountId of entry.accountIds) {
play = await playChannel(cookies, externalId, mediaId); const cookies = cookiesByAccount.get(accountId);
} catch { 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; counts.skipped += 1;
return; return;
} }
@ -151,20 +252,22 @@ export async function syncOdidoList(
let keyEnc = previous?.keyEnc ?? null; let keyEnc = previous?.keyEnc ?? null;
if ( if (
tweaks.syncFetchKeysIfMissing && tweaks.syncFetchKeysIfMissing &&
play.licenseUrl && entry.licenseUrl &&
play.licenseToken && entry.licenseToken &&
entry.playUrl &&
entry.cookies &&
(!kidEnc || !keyEnc) (!kidEnc || !keyEnc)
) { ) {
try { 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"); if (!pssh) throw new Error("Widevine PSSH ontbreekt");
const keys = await extractOdidoKeys({ const keys = await extractOdidoKeys({
wvdPath: settings.config.wvdPath, wvdPath: settings.config.wvdPath,
pssh, pssh,
licUrl: play.licenseUrl, licUrl: entry.licenseUrl,
licToken: play.licenseToken, licToken: entry.licenseToken,
cookies: play.cookies, cookies: entry.cookies,
userAgent: play.userAgent, userAgent: ODIDO_USER_AGENT,
}); });
const key = keys.map(parseKey).find((item) => item !== null); const key = keys.map(parseKey).find((item) => item !== null);
if (!key) throw new Error("Geen bruikbare ClearKey ontvangen"); 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; physical?.mediaName?.trim() || externalId;
const resolvedContentId = play.contentId ?? contentId(channel); const resolvedContentId = entry.playContentId ?? contentId(entry.channel);
const now = new Date(); const now = new Date();
let epgChannelId = previous?.epgChannelId ?? null; let epgChannelId = previous?.epgChannelId ?? null;
@ -206,17 +309,17 @@ export async function syncOdidoList(
const data = { const data = {
name, name,
logoUrl: logoUrl(channel), logoUrl: logoUrl(entry.channel),
mpdUrl: `odido://${externalId}/${mediaId}`, mpdUrl: `odido://${externalId}/${mediaId}`,
mediaId, mediaId,
contentId: resolvedContentId, contentId: resolvedContentId,
epgChannelId, epgChannelId,
kidEnc, kidEnc,
keyEnc, keyEnc,
sortOrder: channelSortOrder(channel, apiIndex), sortOrder: channelSortOrder(entry.channel, entry.apiIndex),
enabled: true, enabled: true,
...(tweaks.syncRefreshMpdCache ...(tweaks.syncRefreshMpdCache && entry.playUrl
? { streamUrlCache: play.playUrl, streamUrlCachedAt: now } ? { streamUrlCache: entry.playUrl, streamUrlCachedAt: now }
: {}), : {}),
}; };
if (previous) { if (previous) {
@ -241,11 +344,20 @@ export async function syncOdidoList(
})).count })).count
: 0; : 0;
let overlapChannels = 0;
for (const ids of Object.values(channelOwners)) {
if (ids.length > 1) overlapChannels += 1;
}
const summary: OdidoSyncSummary = { const summary: OdidoSyncSummary = {
...counts, ...counts,
disabled, disabled,
accounts: accounts.length,
uniqueChannels: successful.size,
overlapChannels,
perAccount,
durationMs: Date.now() - started, durationMs: Date.now() - started,
}; };
await saveOdidoSyncResult(summary); await saveOdidoSyncResult(summary, channelOwners);
return summary; return summary;
} }