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