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.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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
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";
|
} 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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue