stremio/apps/master-api/src/odido/play.ts

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,
});
}