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

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

View file

@ -328,6 +328,11 @@ export default function LiveListsPage() {
d.disabled != null ? `${d.disabled} uit` : null,
d.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,

View file

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

View file

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

View file

@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import {
pickAccountIdForChannel,
parseChannelOwners,
} from "./accounts";
const owners = parseChannelOwners({
"111": ["a", "b"],
"222": ["b"],
});
assert.deepEqual(owners["111"], ["a", "b"]);
const picks = new Set<string>();
for (let i = 0; i < 4; i++) {
picks.add(pickAccountIdForChannel("111", owners["111"], ["a", "b"])!);
}
assert.equal(picks.size, 2, "round-robin moet beide accounts raken");
assert.equal(
pickAccountIdForChannel("999", [], ["a", "b"]),
"a",
"zonder owners: fallback naar enabled pool"
);
assert.equal(
pickAccountIdForChannel("222", owners["222"], ["a", "b"]),
"b",
"alleen owner b"
);
console.log("odido accounts lb tests ok");

View file

@ -0,0 +1,164 @@
import { randomUUID } from "node:crypto";
import { decryptSecret, encryptSecret } from "../security/crypto";
export type OdidoAccountStored = {
id: string;
label: string;
username: string;
pinEnc: string;
enabled: boolean;
};
export type OdidoAccountPublic = {
id: string;
label: string;
username: string;
hasPin: boolean;
enabled: boolean;
/** Aantal zenders waar dit account toegang toe heeft (na laatste sync). */
channelCount: number;
};
export type OdidoAccountCreds = {
id: string;
label: string;
username: string;
pin: string;
enabled: boolean;
};
/** externalId → accountIds die deze zender kunnen spelen. */
export type OdidoChannelOwners = Record<string, string[]>;
const rrCursor = new Map<string, number>();
export function accountToPublic(
account: OdidoAccountStored,
channelOwners: OdidoChannelOwners
): OdidoAccountPublic {
let channelCount = 0;
for (const owners of Object.values(channelOwners)) {
if (owners.includes(account.id)) channelCount += 1;
}
return {
id: account.id,
label: account.label || account.username,
username: account.username,
hasPin: !!account.pinEnc,
enabled: account.enabled !== false,
channelCount,
};
}
export function decryptAccount(
account: OdidoAccountStored,
sessionSecret: string
): OdidoAccountCreds {
return {
id: account.id,
label: account.label || account.username,
username: account.username,
pin: decryptSecret(account.pinEnc, sessionSecret),
enabled: account.enabled !== false,
};
}
export function createAccountStored(
sessionSecret: string,
input: { username: string; pin: string; label?: string; enabled?: boolean; id?: string }
): OdidoAccountStored {
const username = input.username.trim();
const pin = input.pin.trim();
if (!username) throw new Error("Klantnummer verplicht");
if (!pin) throw new Error("Pincode verplicht");
return {
id: input.id?.trim() || randomUUID(),
label: (input.label ?? "").trim() || username,
username,
pinEnc: encryptSecret(pin, sessionSecret),
enabled: input.enabled !== false,
};
}
export function parseAccountsJson(raw: unknown): OdidoAccountStored[] {
if (!Array.isArray(raw)) return [];
const out: OdidoAccountStored[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const row = item as Record<string, unknown>;
const id = String(row.id ?? "").trim();
const username = String(row.username ?? "").trim();
const pinEnc = String(row.pinEnc ?? "").trim();
if (!id || !username || !pinEnc) continue;
out.push({
id,
label: String(row.label ?? "").trim() || username,
username,
pinEnc,
enabled: row.enabled !== false,
});
}
return out;
}
export function parseChannelOwners(raw: unknown): OdidoChannelOwners {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const out: OdidoChannelOwners = {};
for (const [channelId, value] of Object.entries(raw as Record<string, unknown>)) {
if (!channelId.trim()) continue;
const ids = Array.isArray(value)
? value.map((v) => String(v).trim()).filter(Boolean)
: [];
if (ids.length) out[channelId] = [...new Set(ids)];
}
return out;
}
/**
* Kies account voor een zender (round-robin over owners).
* Fallback: alle enabled accounts als owners onbekend zijn.
*/
export function pickAccountIdForChannel(
externalId: string,
ownerIds: string[],
enabledAccountIds: string[]
): string | null {
const pool = (ownerIds.length ? ownerIds : enabledAccountIds).filter((id) =>
enabledAccountIds.includes(id)
);
if (!pool.length) return null;
const key = externalId || "__global__";
const cursor = rrCursor.get(key) ?? 0;
const chosen = pool[cursor % pool.length];
rrCursor.set(key, cursor + 1);
return chosen;
}
/** Voor batch-refresh: verdeel zenders round-robin over accounts. */
export function pickAccountIdRoundRobin(
enabledAccountIds: string[],
salt: string
): string | null {
if (!enabledAccountIds.length) return null;
const key = `rr:${salt}`;
const cursor = rrCursor.get(key) ?? 0;
const chosen = enabledAccountIds[cursor % enabledAccountIds.length];
rrCursor.set(key, cursor + 1);
return chosen;
}
export function ownersForChannel(
channelOwners: OdidoChannelOwners,
externalId: string
): string[] {
return channelOwners[externalId] ?? [];
}
export function mergeChannelOwners(
previous: OdidoChannelOwners,
next: OdidoChannelOwners
): OdidoChannelOwners {
// Sync is authoritative: replace with next map (full package union).
void previous;
return next;
}

View file

@ -11,6 +11,11 @@ import {
} from "./client";
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(

View file

@ -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;

View file

@ -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;
}

View file

@ -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;
}