import { prisma } from "../database/client"; import { encryptSecret } from "../security/crypto"; import { AppError } from "../security/errors"; import type { Config } from "../config"; import { extractOdidoKeys } from "./cdm"; import { getPssh, login, ODIDO_USER_AGENT, playChannel, queryAllChannels, type OdidoChannel, type OdidoCookies, } from "./client"; import { loadOdidoSettings, saveOdidoSyncResult } from "./settings"; import { buildEpgNameIndex, matchEpgChannelId, } from "../viewer/epg-match"; import { loadXmltvChannelIndex } from "../viewer/live-channels"; import type { OdidoChannelOwners } from "./accounts"; export type OdidoSyncSummary = { added: number; updated: number; skipped: number; failed: number; disabled: number; reenabled: number; playOk: number; epgMatched: number; epgUnmatched: number; accounts: number; uniqueChannels: number; overlapChannels: number; perAccount: Array<{ accountId: string; label: string; channels: number }>; durationMs: number; }; 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) ) { const icon = (channel.logo as { icons?: string[] }).icons?.find((url) => !!url?.trim()); if (icon) return icon.trim(); } const picture = channel.pictures?.find((item) => item.href || item.url); return picture?.href?.trim() || picture?.url?.trim() || null; } function channelSortOrder(channel: OdidoChannel, apiIndex: number): number { const raw = channel.channelNO ?? channel.channelNo ?? channel.physicalChannels?.[0]?.channelNO; const no = typeof raw === "number" ? raw : Number(String(raw ?? "").trim()); if (Number.isFinite(no) && no > 0) return Math.trunc(no) * 10; return 100_000 + apiIndex * 10; } function contentId(channel: OdidoChannel): string | null { const physical = channel.physicalChannels?.[0]; return ( channel.contentID ?? channel.contentId ?? physical?.contentID ?? physical?.contentId ?? null ); } function parseKey(value: string): { kid: string; key: string } | null { const [kid, key] = value.trim().toLowerCase().split(":", 2); if (!/^[0-9a-f]{32}$/.test(kid ?? "") || !/^[0-9a-f]{32}$/.test(key ?? "")) return null; return { kid, key }; } async function mapLimit( values: T[], limit: number, 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]); } }) ); } type UnionEntry = { channel: OdidoChannel; apiIndex: number; accountIds: string[]; 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, config?: Config ): Promise { const started = Date.now(); const list = await prisma.liveChannelList.findUnique({ where: { id: listId }, include: { channels: true }, }); if (!list) throw new AppError("NOT_FOUND", "Lijst niet gevonden", 404); if (list.provider !== "odido") { throw new AppError("INVALID_REQUEST", "Deze lijst is niet aan Odido gekoppeld", 400); } const settings = await loadOdidoSettings(sessionSecret); const tweaks = settings.config; const accounts = settings.accounts; const union = new Map(); const perAccount: Array<{ accountId: string; label: string; channels: number }> = []; for (const account of accounts) { let cookies: OdidoCookies; try { cookies = await login( account.username, account.pin, tweaks.sessionCacheMinutes * 60_000, { force: true } ); 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 }); 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 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 ( (prev.channel.channelNO == null || prev.channel.channelNO === "") && channel.channelNO != null ) { prev.channel = { ...prev.channel, channelNO: channel.channelNO }; } } else { union.set(externalId, { channel, apiIndex, accountIds: [account.id], playOk: false, }); } } console.log(`[odido] sync ${account.label || account.username}: ${count} VIDEO_CHANNEL`); 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) .map((channel) => [channel.externalId!, channel]) ); let epgIdSet = new Set(); let epgByName = new Map(); if (config && tweaks.syncAutoMatchEpg) { try { const index = await loadXmltvChannelIndex(config); epgIdSet = new Set(index.keys()); epgByName = buildEpgNameIndex( [...index.entries()].map(([id, name]) => ({ id, name })), "odido/" ); } catch (err) { console.warn("[odido] EPG-index laden mislukt:", err); } } 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; 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; } 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.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; } catch { /* 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 (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 && entry.cookies && (!kidEnc || !keyEnc) ) { try { 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: 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"); kidEnc = encryptSecret(key.kid, sessionSecret); keyEnc = encryptSecret(key.key, sessionSecret); try { const { upsertDrmKey } = await import("../drm/keys"); const chName = entry.channel.name?.trim() || entry.channel.channelName?.trim() || physical?.mediaName?.trim() || externalId; await upsertDrmKey(prisma, sessionSecret, { kid: key.kid, key: key.key, mpdUrl: entry.playUrl, channelName: chName, source: "odido", externalId, }); } catch { /* ignore drm db errors */ } } catch { counts.failed += 1; } } 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(); let epgChannelId = previous?.epgChannelId ?? null; const mayOverwrite = tweaks.syncOverwriteEpgMatch; if (epgIdSet.size > 0 && (mayOverwrite || !epgChannelId)) { const matched = matchEpgChannelId( { name, externalId, contentId: resolvedContentId, provider: "odido", }, epgIdSet, epgByName ); if (matched) { epgChannelId = matched; counts.epgMatched += 1; } else if (!epgChannelId) { counts.epgUnmatched += 1; } } else if (!epgChannelId) { counts.epgUnmatched += 1; } const data = { name, logoUrl: logoUrl(entry.channel), mpdUrl: `odido://${externalId}/${mediaId}`, mediaId, contentId: resolvedContentId, epgChannelId, kidEnc, keyEnc, sortOrder: channelSortOrder(entry.channel, entry.apiIndex), enabled: true, ...(tweaks.syncRefreshMpdCache && entry.playUrl ? { streamUrlCache: entry.playUrl, streamUrlCachedAt: now } : {}), }; if (previous) { await prisma.liveChannel.update({ where: { id: previous.id }, data }); counts.updated += 1; } else { await prisma.liveChannel.create({ data: { listId, externalId, ...data }, }); counts.added += 1; } } const inPackage = new Set(union.keys()); const disableIds = list.channels .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 : 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; } const summary: OdidoSyncSummary = { ...counts, disabled, reenabled, accounts: accounts.length, 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; }