Support multiple Odido accounts with union sync and round-robin play.
This commit is contained in:
parent
15a678b835
commit
96117a4887
9 changed files with 950 additions and 177 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -14,11 +14,18 @@ type TweakMeta = {
|
|||
|
||||
type OdidoTweaks = Record<string, number | boolean>;
|
||||
|
||||
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<string, unknown> | null;
|
||||
lastMpdRefreshAt: string | null;
|
||||
lastMpdRefreshSummary: Record<string, unknown> | null;
|
||||
accounts: OdidoAccount[];
|
||||
accountCount: number;
|
||||
channelOwnerCount: number;
|
||||
tweaks: OdidoTweaks;
|
||||
tweakDefaults: OdidoTweaks;
|
||||
tweakMeta: TweakMeta[];
|
||||
|
|
@ -47,8 +57,6 @@ function readFileAsBase64(file: File): Promise<string> {
|
|||
export default function ScriptsPage() {
|
||||
useAuth();
|
||||
const [odido, setOdido] = useState<OdidoSettings | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [pin, setPin] = useState("");
|
||||
const [wvdPath, setWvdPath] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [tweaks, setTweaks] = useState<OdidoTweaks>({});
|
||||
|
|
@ -58,13 +66,15 @@ export default function ScriptsPage() {
|
|||
const [err, setErr] = useState<string | null>(null);
|
||||
const wvdInputRef = useRef<HTMLInputElement>(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<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", {
|
||||
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() {
|
|||
</div>
|
||||
</div>
|
||||
<p className="muted page-lead">
|
||||
Provider-scripts voor live-lijsten. Stel hier Odido in, koppel daarna een lijst onder{" "}
|
||||
<Link href="/live-lists">Live-lijsten</Link> en doe daar “Volledig ophalen”.
|
||||
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 <Link href="/live-lists">Live-lijsten</Link>.
|
||||
</p>
|
||||
|
||||
{msg && (
|
||||
|
|
@ -217,33 +294,102 @@ export default function ScriptsPage() {
|
|||
<div className="card" style={{ maxWidth: 720 }}>
|
||||
<h2 style={{ marginTop: 0 }}>Odido</h2>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
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.
|
||||
</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">
|
||||
<label className="field">
|
||||
Klantnummer
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="TV-klantnummer"
|
||||
disabled={busy}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
Pincode
|
||||
<input
|
||||
type="password"
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value)}
|
||||
placeholder={odido?.hasPin ? "•••• (leeg = behouden)" : "4-cijferige pincode"}
|
||||
disabled={busy}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
<div className="field">
|
||||
<span>WVD.wvd (Widevine device)</span>
|
||||
<span>WVD.wvd (Widevine device — gedeeld)</span>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center", marginTop: 6 }}>
|
||||
<input
|
||||
ref={wvdInputRef}
|
||||
|
|
@ -312,14 +458,12 @@ export default function ScriptsPage() {
|
|||
Defaults herstellen
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted" style={{ fontSize: "0.85rem" }}>
|
||||
Wijzigingen gelden na Opslaan. Achtergrond-refresh leest deze waarden live.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
|
||||
gap: "0.75rem 1rem",
|
||||
marginTop: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{meta.map((item) => {
|
||||
|
|
@ -392,10 +536,15 @@ export default function ScriptsPage() {
|
|||
Status:{" "}
|
||||
{odido?.configured ? (
|
||||
<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>nog niet compleet (klantnummer + pincode + WVD)</span>
|
||||
<span>nog niet compleet (minstens 1 account + WVD)</span>
|
||||
)}
|
||||
{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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
30
apps/master-api/src/odido/accounts.test.ts
Normal file
30
apps/master-api/src/odido/accounts.test.ts
Normal 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");
|
||||
164
apps/master-api/src/odido/accounts.ts
Normal file
164
apps/master-api/src/odido/accounts.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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<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(
|
||||
channel: OdidoChannelPlayState,
|
||||
sessionSecret: string,
|
||||
opts?: { cookies?: OdidoCookies; tweaks?: OdidoTweaks; fetchKeys?: boolean }
|
||||
opts?: {
|
||||
cookies?: OdidoCookies;
|
||||
tweaks?: OdidoTweaks;
|
||||
fetchKeys?: boolean;
|
||||
account?: OdidoAccountCreds;
|
||||
}
|
||||
): Promise<ResolveOdidoPlayResult> {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<string, number>;
|
||||
};
|
||||
|
||||
async function mapLimit<T>(
|
||||
|
|
@ -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<string, number> = {};
|
||||
const cookiesByAccount = new Map<string, Awaited<ReturnType<typeof login>>>();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
wvdUploadedAt?: string;
|
||||
|
|
@ -24,10 +37,10 @@ export type OdidoConfig = OdidoTweaks & {
|
|||
};
|
||||
|
||||
function parseConfig(raw: string | null | undefined): OdidoConfig {
|
||||
let parsed: Partial<OdidoConfig> = {};
|
||||
let parsed: Partial<OdidoConfig> & Record<string, unknown> = {};
|
||||
if (raw) {
|
||||
try {
|
||||
parsed = JSON.parse(raw) as Partial<OdidoConfig>;
|
||||
parsed = JSON.parse(raw) as Partial<OdidoConfig> & Record<string, unknown>;
|
||||
} 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<string, unknown> | undefined,
|
||||
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() {
|
||||
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<LoadedOdidoSettings> {
|
||||
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<string, unknown>) {
|
||||
export async function saveOdidoSyncResult(
|
||||
summary: Record<string, unknown>,
|
||||
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<OdidoConfig | null> {
|
|||
if (!row?.enabled) return null;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
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<string, UnionEntry>();
|
||||
const perAccount: Array<{ accountId: string; label: string; channels: number }> = [];
|
||||
const cookiesByAccount = new Map<string, OdidoCookies>();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue