Persist kids/keys with channel/event/MPD metadata, expose /drm search UI, and record keys from Odido sync/play for cache-first reuse.
442 lines
13 KiB
TypeScript
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;
|
|
}
|