From 2d50d8948a2e6071fd1763619ca8885c59886b7a Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 22 Sep 2026 01:35:22 +0200 Subject: [PATCH] Fix Odido sync: sequential per-account play and stop disabling package channels. --- apps/master-api/src/odido/sync.ts | 211 ++++++++++++++++++------------ 1 file changed, 124 insertions(+), 87 deletions(-) diff --git a/apps/master-api/src/odido/sync.ts b/apps/master-api/src/odido/sync.ts index c6314ed..8878435 100644 --- a/apps/master-api/src/odido/sync.ts +++ b/apps/master-api/src/odido/sync.ts @@ -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( worker: (value: T) => Promise ): Promise { let cursor = 0; - 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]); - } - })); + 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(); const perAccount: Array<{ accountId: string; label: string; channels: number }> = []; - const cookiesByAccount = new Map(); - // 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(); - const counts = { - added: 0, - updated: 0, - skipped: 0, - failed: 0, - epgMatched: 0, - epgUnmatched: 0, - }; let epgIdSet = new Set(); let epgByName = new Map(); @@ -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; + let cookies: OdidoCookies; + try { + cookies = await login( + account.username, + account.pin, + tweaks.sessionCacheMinutes * 60_000, + { force: true } + ); + } catch (err) { + console.warn(`[odido] play-login mislukt voor ${account.username}:`, err); + continue; } - const tryOrder = [ - ...entry.accountIds, - ...allAccountIds.filter((id) => !entry.accountIds.includes(id)), - ]; + console.log( + `[odido] sync ${account.label || account.username}: PlayChannel x${owned.length}` + ); - let playOk = false; - for (const accountId of tryOrder) { - const account = accountById.get(accountId); - if (!account) continue; + await mapLimit(owned, playConcurrency, async ([externalId, entry]) => { + if (entry.playOk) return; + const mediaId = entry.channel.physicalChannels?.[0]?.ID?.trim(); + if (!mediaId) return; try { - const cookies = await login( - account.username, - account.pin, - tweaks.sessionCacheMinutes * 60_000, - { force: true } - ); - cookiesByAccount.set(accountId, cookies); 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 */ } + }); + } + + 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 (!playOk) { - counts.skipped += 1; - return; - } + + 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({ - where: { id: { in: disableIds }, enabled: true }, - data: { enabled: false }, - })).count + ? ( + await prisma.liveChannel.updateMany({ + where: { id: { in: disableIds }, enabled: true }, + data: { enabled: false }, + }) + ).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; }