Fix Odido sync: sequential per-account play and stop disabling package channels.

This commit is contained in:
Jos Vooges | STH 2026-09-22 01:35:22 +02:00
parent 62435927f5
commit 2d50d8948a

View file

@ -26,6 +26,8 @@ export type OdidoSyncSummary = {
skipped: number;
failed: number;
disabled: number;
reenabled: number;
playOk: number;
epgMatched: number;
epgUnmatched: number;
accounts: number;
@ -38,7 +40,11 @@ export type OdidoSyncSummary = {
function logoUrl(channel: OdidoChannel): string | null {
const fromPictureIcons = channel.picture?.icons?.find((url) => !!url?.trim());
if (fromPictureIcons) return fromPictureIcons.trim();
if (typeof channel.logo === "object" && channel.logo && Array.isArray((channel.logo as { icons?: string[] }).icons)) {
if (
typeof channel.logo === "object" &&
channel.logo &&
Array.isArray((channel.logo as { icons?: string[] }).icons)
) {
const icon = (channel.logo as { icons?: string[] }).icons?.find((url) => !!url?.trim());
if (icon) return icon.trim();
}
@ -55,8 +61,13 @@ function channelSortOrder(channel: OdidoChannel, apiIndex: number): number {
function contentId(channel: OdidoChannel): string | null {
const physical = channel.physicalChannels?.[0];
return channel.contentID ?? channel.contentId ??
physical?.contentID ?? physical?.contentId ?? null;
return (
channel.contentID ??
channel.contentId ??
physical?.contentID ??
physical?.contentId ??
null
);
}
function parseKey(value: string): { kid: string; key: string } | null {
@ -71,28 +82,37 @@ async function mapLimit<T>(
worker: (value: T) => Promise<void>
): Promise<void> {
let cursor = 0;
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, async () => {
await Promise.all(
Array.from({ length: Math.min(limit, values.length) }, async () => {
for (;;) {
const index = cursor++;
if (index >= values.length) return;
await worker(values[index]);
}
}));
})
);
}
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;
playOk: boolean;
};
/**
* Sync (sequentieel per account):
* 1) login + QueryAllChannel → unie
* 2) per account één login + PlayChannel voor diens zenders
* 3) alles in unie → enabled=true (play-fout zet niet meer uit)
* 4) alleen zenders buiten alle pakketten → enabled=false
*/
export async function syncOdidoList(
listId: string,
sessionSecret: string,
@ -114,9 +134,7 @@ export async function syncOdidoList(
const union = new Map<string, UnionEntry>();
const perAccount: Array<{ accountId: string; label: string; channels: number }> = [];
const cookiesByAccount = new Map<string, OdidoCookies>();
// 1) Per account sequentieel: login → QueryAllChannel → unie
for (const account of accounts) {
let cookies: OdidoCookies;
try {
@ -126,8 +144,7 @@ export async function syncOdidoList(
tweaks.sessionCacheMinutes * 60_000,
{ force: true }
);
cookiesByAccount.set(account.id, cookies);
console.log(`[odido] sync account ${account.label || account.username}: login ok`);
console.log(`[odido] sync ${account.label || account.username}: login ok`);
} catch (err) {
console.warn(`[odido] login mislukt voor ${account.username}:`, err);
perAccount.push({ accountId: account.id, label: account.label, channels: 0 });
@ -151,29 +168,26 @@ export async function syncOdidoList(
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);
}
if (!logoUrl(existing.channel) && logoUrl(channel)) existing.channel = channel;
const prev = union.get(externalId);
if (prev) {
if (!prev.accountIds.includes(account.id)) prev.accountIds.push(account.id);
if (!logoUrl(prev.channel) && logoUrl(channel)) prev.channel = channel;
if (
(existing.channel.channelNO == null || existing.channel.channelNO === "") &&
(prev.channel.channelNO == null || prev.channel.channelNO === "") &&
channel.channelNO != null
) {
existing.channel = { ...existing.channel, channelNO: channel.channelNO };
prev.channel = { ...prev.channel, channelNO: channel.channelNO };
}
} else {
union.set(externalId, {
channel,
apiIndex,
accountIds: [account.id],
playOk: false,
});
}
}
console.log(
`[odido] sync account ${account.label || account.username}: ${count} VIDEO_CHANNEL`
);
console.log(`[odido] sync ${account.label || account.username}: ${count} VIDEO_CHANNEL`);
perAccount.push({ accountId: account.id, label: account.label, channels: count });
}
@ -190,15 +204,6 @@ export async function syncOdidoList(
.filter((channel) => channel.externalId)
.map((channel) => [channel.externalId!, channel])
);
const successful = new Set<string>();
const counts = {
added: 0,
updated: 0,
skipped: 0,
failed: 0,
epgMatched: 0,
epgUnmatched: 0,
};
let epgIdSet = new Set<string>();
let epgByName = new Map<string, string>();
@ -215,67 +220,79 @@ export async function syncOdidoList(
}
}
const channelOwners: OdidoChannelOwners = {};
const entries = [...union.entries()];
const accountById = new Map(accounts.map((a) => [a.id, a]));
const allAccountIds = accounts.map((a) => a.id);
const playConcurrency = Math.min(Math.max(1, tweaks.syncConcurrency), 3);
for (const account of accounts) {
const owned = [...union.entries()].filter(
([, entry]) => entry.accountIds.includes(account.id) && !entry.playOk
);
if (!owned.length) continue;
// 2) PlayChannel per unieke zender.
// Belangrijk: verse login per poging — na multi-account QueryAllChannel zijn
// eerdere cookies vaak ongeldig; probeer owners eerst, daarna overige accounts.
await mapLimit(entries, tweaks.syncConcurrency, async ([externalId, entry]) => {
channelOwners[externalId] = [...entry.accountIds];
const physical = entry.channel.physicalChannels?.[0];
const mediaId = physical?.ID?.trim();
if (!mediaId) {
counts.failed += 1;
return;
}
const tryOrder = [
...entry.accountIds,
...allAccountIds.filter((id) => !entry.accountIds.includes(id)),
];
let playOk = false;
for (const accountId of tryOrder) {
const account = accountById.get(accountId);
if (!account) continue;
let cookies: OdidoCookies;
try {
const cookies = await login(
cookies = await login(
account.username,
account.pin,
tweaks.sessionCacheMinutes * 60_000,
{ force: true }
);
cookiesByAccount.set(accountId, cookies);
} catch (err) {
console.warn(`[odido] play-login mislukt voor ${account.username}:`, err);
continue;
}
console.log(
`[odido] sync ${account.label || account.username}: PlayChannel x${owned.length}`
);
await mapLimit(owned, playConcurrency, async ([externalId, entry]) => {
if (entry.playOk) return;
const mediaId = entry.channel.physicalChannels?.[0]?.ID?.trim();
if (!mediaId) return;
try {
const play = await playChannel(cookies, externalId, mediaId);
entry.playAccountId = accountId;
entry.playOk = true;
entry.playAccountId = account.id;
entry.cookies = play.cookies;
entry.playUrl = play.playUrl;
entry.licenseUrl = play.licenseUrl;
entry.licenseToken = play.licenseToken;
entry.playContentId = play.contentId;
if (!entry.accountIds.includes(accountId)) {
entry.accountIds.push(accountId);
channelOwners[externalId] = [...entry.accountIds];
}
playOk = true;
break;
} catch {
/* probeer volgend account */
/* later play-on-demand */
}
});
}
if (!playOk) {
counts.skipped += 1;
return;
const channelOwners: OdidoChannelOwners = {};
const counts = {
added: 0,
updated: 0,
skipped: 0,
failed: 0,
playOk: 0,
epgMatched: 0,
epgUnmatched: 0,
};
for (const [externalId, entry] of union) {
channelOwners[externalId] = [...entry.accountIds];
const physical = entry.channel.physicalChannels?.[0];
const mediaId = physical?.ID?.trim();
if (!mediaId) {
counts.failed += 1;
continue;
}
if (entry.playOk) counts.playOk += 1;
else counts.skipped += 1;
const previous = existing.get(externalId);
let kidEnc = previous?.kidEnc ?? null;
let keyEnc = previous?.keyEnc ?? null;
if (
tweaks.syncFetchKeysIfMissing &&
entry.playOk &&
entry.licenseUrl &&
entry.licenseToken &&
entry.playUrl &&
@ -299,12 +316,14 @@ export async function syncOdidoList(
keyEnc = encryptSecret(key.key, sessionSecret);
} catch {
counts.failed += 1;
return;
}
}
const name = entry.channel.name?.trim() || entry.channel.channelName?.trim() ||
physical?.mediaName?.trim() || externalId;
const name =
entry.channel.name?.trim() ||
entry.channel.channelName?.trim() ||
physical?.mediaName?.trim() ||
externalId;
const resolvedContentId = entry.playContentId ?? contentId(entry.channel);
const now = new Date();
@ -346,6 +365,7 @@ export async function syncOdidoList(
? { streamUrlCache: entry.playUrl, streamUrlCachedAt: now }
: {}),
};
if (previous) {
await prisma.liveChannel.update({ where: { id: previous.id }, data });
counts.updated += 1;
@ -355,19 +375,32 @@ export async function syncOdidoList(
});
counts.added += 1;
}
successful.add(externalId);
});
}
const inPackage = new Set(union.keys());
const disableIds = list.channels
.filter((channel) => channel.externalId && !successful.has(channel.externalId))
.filter((channel) => channel.externalId && !inPackage.has(channel.externalId))
.map((channel) => channel.id);
const disabled = disableIds.length
? (await prisma.liveChannel.updateMany({
? (
await prisma.liveChannel.updateMany({
where: { id: { in: disableIds }, enabled: true },
data: { enabled: false },
})).count
})
).count
: 0;
const reenabled = (
await prisma.liveChannel.updateMany({
where: {
listId,
enabled: false,
externalId: { in: [...inPackage] },
},
data: { enabled: true },
})
).count;
let overlapChannels = 0;
for (const ids of Object.values(channelOwners)) {
if (ids.length > 1) overlapChannels += 1;
@ -376,12 +409,16 @@ export async function syncOdidoList(
const summary: OdidoSyncSummary = {
...counts,
disabled,
reenabled,
accounts: accounts.length,
uniqueChannels: successful.size,
uniqueChannels: union.size,
overlapChannels,
perAccount,
durationMs: Date.now() - started,
};
console.log(
`[odido] sync klaar: ${union.size} uniek, playOk=${counts.playOk}, noPlay=${counts.skipped}, disabled=${disabled}, reenabled=${reenabled}`
);
await saveOdidoSyncResult(summary, channelOwners);
return summary;
}