import { prisma } from "../database/client"; import { encryptSecret } from "../security/crypto"; import { extractOdidoKeys } from "./cdm"; import { getPssh, login, ODIDO_USER_AGENT, playChannel, type OdidoCookies, type OdidoPlayResult, } from "./client"; import { loadOdidoSettings } from "./settings"; import { isMpdCacheFresh, type OdidoTweaks } from "./tweaks"; export type OdidoChannelPlayState = { id: string; externalId: string | null; mediaId: string | null; contentId: string | null; mpdUrl: string; streamUrlCache: string | null; streamUrlCachedAt: Date | null; kidEnc: string | null; keyEnc: string | null; }; export type ResolveOdidoPlayResult = { streamUrl: string; kidEnc: string | null; keyEnc: string | null; fromCache: boolean; contentId: string | 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 probeMpd(url: string, timeoutMs: number, cookies?: OdidoCookies): Promise { try { const response = await fetch(url, { method: "GET", headers: { "User-Agent": ODIDO_USER_AGENT, ...(cookies && Object.keys(cookies).length ? { Cookie: Object.entries(cookies) .map(([k, v]) => `${k}=${v}`) .join("; "), } : {}), }, signal: AbortSignal.timeout(timeoutMs), }); if (!response.ok) return false; const text = await response.text(); return text.includes(" { if (existingKid && existingKey) return { kidEnc: existingKid, keyEnc: existingKey }; if (!allowFetch || !play.licenseUrl || !play.licenseToken) { return { kidEnc: existingKid, keyEnc: existingKey }; } const pssh = await getPssh(play.playUrl, play.cookies); if (!pssh) throw new Error("Widevine PSSH ontbreekt"); const keys = await extractOdidoKeys({ wvdPath, pssh, licUrl: play.licenseUrl, licToken: play.licenseToken, cookies: play.cookies, userAgent: play.userAgent, }); const key = keys.map(parseKey).find((item) => item !== null); if (!key) throw new Error("Geen bruikbare ClearKey ontvangen"); return { kidEnc: encryptSecret(key.kid, sessionSecret), keyEnc: encryptSecret(key.key, sessionSecret), }; } export async function refreshChannelMpd( channel: OdidoChannelPlayState, sessionSecret: string, opts?: { cookies?: OdidoCookies; tweaks?: OdidoTweaks; fetchKeys?: boolean } ): Promise { const settings = await loadOdidoSettings(sessionSecret); const tweaks = opts?.tweaks ?? settings.config; const externalId = channel.externalId; const mediaId = channel.mediaId; if (!externalId || !mediaId) throw new Error("Odido-zenderreferentie is onvolledig"); const cookies = opts?.cookies ?? (await login(settings.username, settings.pin, tweaks.sessionCacheMinutes * 60_000)); const play = await playChannel(cookies, externalId, mediaId); const keys = await fetchKeysIfNeeded( play, settings.config.wvdPath, sessionSecret, channel.kidEnc, channel.keyEnc, opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay ); const now = new Date(); await prisma.liveChannel.update({ where: { id: channel.id }, data: { streamUrlCache: play.playUrl, streamUrlCachedAt: now, contentId: play.contentId ?? channel.contentId, externalId, mediaId, mpdUrl: `odido://${externalId}/${mediaId}`, ...(keys.kidEnc ? { kidEnc: keys.kidEnc } : {}), ...(keys.keyEnc ? { keyEnc: keys.keyEnc } : {}), }, }); return { streamUrl: play.playUrl, kidEnc: keys.kidEnc, keyEnc: keys.keyEnc, fromCache: false, contentId: play.contentId ?? channel.contentId, }; } export async function resolveOdidoPlay( channel: OdidoChannelPlayState, sessionSecret: string ): Promise { const settings = await loadOdidoSettings(sessionSecret); const tweaks = settings.config; const haveCache = !!channel.streamUrlCache && /^https?:\/\//i.test(channel.streamUrlCache); const fresh = isMpdCacheFresh(channel.streamUrlCachedAt, tweaks.mpdTtlSeconds); const canUseCache = tweaks.mpdPreferCacheOnPlay && !tweaks.mpdForceFreshOnPlay && haveCache && fresh; if (canUseCache) { if (tweaks.mpdProbeCachedBeforeServe) { const ok = await probeMpd(channel.streamUrlCache!, tweaks.mpdProbeTimeoutMs); if (ok) { return { streamUrl: channel.streamUrlCache!, kidEnc: channel.kidEnc, keyEnc: channel.keyEnc, fromCache: true, contentId: channel.contentId, }; } if (!tweaks.mpdFallbackFreshOnProbeFail) { throw new Error("Gecachte MPD niet bereikbaar"); } } else { return { streamUrl: channel.streamUrlCache!, kidEnc: channel.kidEnc, keyEnc: channel.keyEnc, fromCache: true, contentId: channel.contentId, }; } } if (!tweaks.mpdRefreshOnPlayIfStale && haveCache && !tweaks.mpdForceFreshOnPlay) { return { streamUrl: channel.streamUrlCache!, kidEnc: channel.kidEnc, keyEnc: channel.keyEnc, fromCache: true, contentId: channel.contentId, }; } return refreshChannelMpd(channel, sessionSecret, { tweaks, fetchKeys: tweaks.refetchKeysIfMissingOnPlay, }); }