198 lines
5.8 KiB
TypeScript
198 lines
5.8 KiB
TypeScript
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<boolean> {
|
|
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("<MPD") || text.includes("Period") || text.includes("#EXTM3U");
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function fetchKeysIfNeeded(
|
|
play: OdidoPlayResult,
|
|
wvdPath: string,
|
|
sessionSecret: string,
|
|
existingKid: string | null,
|
|
existingKey: string | null,
|
|
allowFetch: boolean
|
|
): Promise<{ kidEnc: string | null; keyEnc: string | null }> {
|
|
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<ResolveOdidoPlayResult> {
|
|
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<ResolveOdidoPlayResult> {
|
|
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,
|
|
});
|
|
}
|