stremio/apps/master-api/src/odido/sync.ts
Jos Vooges | STH 4b1f9eb763 Add searchable DRM key database in admin for reused ClearKeys.
Persist kids/keys with channel/event/MPD metadata, expose /drm search UI, and record keys from Odido sync/play for cache-first reuse.
2026-09-22 21:50:50 +02:00

442 lines
13 KiB
TypeScript

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<T>(
values: T[],
limit: number,
worker: (value: T) => Promise<void>
): Promise<void> {
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<OdidoSyncSummary> {
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<string, UnionEntry>();
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<string>();
let epgByName = new Map<string, string>();
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;
}