269 lines
8.6 KiB
TypeScript
269 lines
8.6 KiB
TypeScript
const BASE_URL = "https://tv.odido.nl";
|
|
export const ODIDO_USER_AGENT =
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
|
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
const DEFAULT_DEVICE_ID = "MzkzNjIwMTU1NjAxMDNydA==";
|
|
const DEFAULT_SESSION_CACHE_MS = 20 * 60_000;
|
|
|
|
export type OdidoCookies = Record<string, string>;
|
|
|
|
export type OdidoChannel = {
|
|
ID?: string;
|
|
name?: string;
|
|
channelName?: string;
|
|
contentType?: string;
|
|
channelNO?: string | number;
|
|
channelNo?: string | number;
|
|
contentID?: string;
|
|
contentId?: string;
|
|
logo?: { icons?: string[] } | string | null;
|
|
picture?: { icons?: string[] } | null;
|
|
pictures?: Array<{ href?: string; url?: string; posterType?: string }>;
|
|
physicalChannels?: Array<{
|
|
ID?: string;
|
|
mediaName?: string;
|
|
channelNO?: string | number;
|
|
contentID?: string;
|
|
contentId?: string;
|
|
}>;
|
|
};
|
|
|
|
export type OdidoPlayResult = {
|
|
playUrl: string;
|
|
licenseUrl: string | null;
|
|
licenseToken: string | null;
|
|
contentId: string | null;
|
|
cookies: OdidoCookies;
|
|
userAgent: string;
|
|
};
|
|
|
|
type Session = { cookies: OdidoCookies; expiresAt: number };
|
|
const sessions = new Map<string, Session>();
|
|
|
|
function cookieHeader(cookies: OdidoCookies): string {
|
|
return Object.entries(cookies)
|
|
.map(([key, value]) => `${key}=${value}`)
|
|
.join("; ");
|
|
}
|
|
|
|
function responseCookies(response: Response): OdidoCookies {
|
|
const headers = response.headers as Headers & { getSetCookie?: () => string[] };
|
|
const values = headers.getSetCookie?.() ?? [response.headers.get("set-cookie") ?? ""];
|
|
const result: OdidoCookies = {};
|
|
for (const value of values) {
|
|
const match = /^([^=;,\s]+)=([^;,]*)/.exec(value);
|
|
if (match) result[match[1]] = match[2];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function isSuccess(body: Record<string, unknown>): boolean {
|
|
const code = body.retCode;
|
|
return code === undefined || code === null || code === "" || code === 0 ||
|
|
code === "0" || code === "000000000";
|
|
}
|
|
|
|
function apiError(operation: string, body: Record<string, unknown>): Error {
|
|
const message = String(body.retMsg ?? body.resultDesc ?? body.retCode ?? "onbekende fout");
|
|
return new Error(`Odido ${operation} mislukt: ${message}`);
|
|
}
|
|
|
|
async function post(
|
|
path: string,
|
|
from: "throughMSAAccess" | "inMSAAccess",
|
|
data: unknown,
|
|
cookies?: OdidoCookies
|
|
): Promise<{ body: Record<string, unknown>; cookies: OdidoCookies }> {
|
|
const response = await fetch(`${BASE_URL}${path}?from=${from}`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "text/plain;charset=UTF-8",
|
|
Origin: BASE_URL,
|
|
Referer: `${BASE_URL}/inloggen/index.html`,
|
|
"User-Agent": ODIDO_USER_AGENT,
|
|
...(cookies?.CSRFSESSION ? { X_CSRFToken: cookies.CSRFSESSION } : {}),
|
|
...(cookies ? { Cookie: cookieHeader(cookies) } : {}),
|
|
},
|
|
body: JSON.stringify(data),
|
|
signal: AbortSignal.timeout(path.includes("QueryAllChannel") ? 60_000 : 30_000),
|
|
});
|
|
const text = await response.text();
|
|
let body: Record<string, unknown>;
|
|
try {
|
|
body = JSON.parse(text) as Record<string, unknown>;
|
|
} catch {
|
|
throw new Error(`Odido gaf geen geldige JSON terug (${response.status})`);
|
|
}
|
|
if (!response.ok) throw apiError(path.split("/").pop() ?? "request", body);
|
|
return { body, cookies: { ...cookies, ...responseCookies(response) } };
|
|
}
|
|
|
|
async function loginWithDevice(
|
|
username: string,
|
|
password: string,
|
|
deviceId: string,
|
|
switched = false
|
|
): Promise<OdidoCookies> {
|
|
const { body, cookies } = await post(
|
|
"/VSP/V3/Authenticate",
|
|
"throughMSAAccess",
|
|
{
|
|
authenticateBasic: {
|
|
userID: username,
|
|
userType: "0",
|
|
needPosterTypes: ["1", "2", "3", "4", "5", "6", "7"],
|
|
timeZone: "Europe/Amsterdam",
|
|
isSupportWebpImgFormat: "0",
|
|
clientPasswd: password,
|
|
lang: "nl",
|
|
},
|
|
authenticateDevice: {
|
|
physicalDeviceID: deviceId,
|
|
terminalID: deviceId,
|
|
deviceModel: "3103_PCClient",
|
|
CADeviceInfos: [{ CADeviceType: "7", CADeviceID: deviceId }],
|
|
},
|
|
authenticateTolerant: {
|
|
areaCode: "",
|
|
templateName: "",
|
|
subnetID: "",
|
|
bossID: "",
|
|
userGroup: "",
|
|
},
|
|
}
|
|
);
|
|
const devices = body.devices as Array<{ physicalDeviceID?: string }> | undefined;
|
|
if (devices?.[0]?.physicalDeviceID && !switched) {
|
|
return loginWithDevice(username, password, devices[0].physicalDeviceID, true);
|
|
}
|
|
if (!isSuccess(body)) throw apiError("inloggen", body);
|
|
for (const required of ["CSESSIONID", "XSESSIONID", "CSRFSESSION", "JSESSIONID"]) {
|
|
if (!cookies[required]) throw new Error(`Odido-login mist cookie ${required}`);
|
|
}
|
|
return cookies;
|
|
}
|
|
|
|
export async function login(
|
|
username: string,
|
|
password: string,
|
|
sessionCacheMs = DEFAULT_SESSION_CACHE_MS,
|
|
opts?: { force?: boolean }
|
|
): Promise<OdidoCookies> {
|
|
if (!opts?.force) {
|
|
const cached = sessions.get(username);
|
|
if (cached && cached.expiresAt > Date.now()) return cached.cookies;
|
|
}
|
|
const cookies = await loginWithDevice(username, password, DEFAULT_DEVICE_ID);
|
|
const ttl = Number.isFinite(sessionCacheMs) && sessionCacheMs > 0
|
|
? sessionCacheMs
|
|
: DEFAULT_SESSION_CACHE_MS;
|
|
sessions.set(username, { cookies, expiresAt: Date.now() + ttl });
|
|
return cookies;
|
|
}
|
|
|
|
export function clearOdidoLoginCache(username?: string) {
|
|
if (username) sessions.delete(username);
|
|
else sessions.clear();
|
|
}
|
|
|
|
export async function heartbeat(cookies: OdidoCookies): Promise<boolean> {
|
|
const { body } = await post("/VSP/V3/OnLineHeartbeat", "inMSAAccess", {}, cookies);
|
|
return isSuccess(body) && body.userValid !== false && body.userValid !== "0";
|
|
}
|
|
|
|
export async function queryAllChannels(cookies: OdidoCookies): Promise<OdidoChannel[]> {
|
|
const { body } = await post(
|
|
"/VSP/V3/QueryAllChannel",
|
|
"inMSAAccess",
|
|
{ isReturnAllMedia: "0" },
|
|
cookies
|
|
);
|
|
if (!isSuccess(body)) throw apiError("zenderlijst ophalen", body);
|
|
const channels = Array.isArray(body.channelDetails)
|
|
? (body.channelDetails as OdidoChannel[])
|
|
: [];
|
|
|
|
// Gidsnummers (channelNO) komen uit DynamicProperties — zelfde bron als de TV-gids.
|
|
try {
|
|
const dyn = await post(
|
|
"/VSP/V3/QueryAllChannelDynamicProperties",
|
|
"inMSAAccess",
|
|
{},
|
|
cookies
|
|
);
|
|
const rows = Array.isArray(dyn.body.channelDynamaicProp)
|
|
? (dyn.body.channelDynamaicProp as Array<{ ID?: string; channelNO?: string | number }>)
|
|
: [];
|
|
const byId = new Map(
|
|
rows
|
|
.filter((row) => row.ID)
|
|
.map((row) => [String(row.ID), row.channelNO] as const)
|
|
);
|
|
for (const channel of channels) {
|
|
const id = channel.ID?.trim();
|
|
if (!id) continue;
|
|
const no = byId.get(id);
|
|
if (no !== undefined && no !== null && no !== "") {
|
|
channel.channelNO = no;
|
|
}
|
|
}
|
|
} catch {
|
|
// Volgorde is nice-to-have; sync mag zonder channelNO doorgaan.
|
|
}
|
|
|
|
return channels;
|
|
}
|
|
|
|
export async function playChannel(
|
|
cookies: OdidoCookies,
|
|
channelId: string,
|
|
mediaId: string
|
|
): Promise<OdidoPlayResult> {
|
|
const { body, cookies: refreshedCookies } = await post(
|
|
"/VSP/V3/PlayChannel",
|
|
"throughMSAAccess",
|
|
{
|
|
channelID: channelId,
|
|
mediaID: mediaId,
|
|
businessType: "BTV",
|
|
isReturnProduct: "1",
|
|
isHTTPS: "1",
|
|
checkLock: { checkType: "0" },
|
|
},
|
|
cookies
|
|
);
|
|
const playUrl = typeof body.playURL === "string" ? body.playURL : "";
|
|
if (!isSuccess(body) || !playUrl) throw apiError("zender starten", body);
|
|
const authorize = body.authorizeResult as
|
|
| { triggers?: Array<Record<string, unknown>> }
|
|
| undefined;
|
|
const trigger = authorize?.triggers?.[0];
|
|
return {
|
|
playUrl,
|
|
licenseUrl: typeof trigger?.licenseURL === "string" ? trigger.licenseURL : null,
|
|
licenseToken: typeof trigger?.customData === "string" ? trigger.customData : null,
|
|
contentId: [body.contentID, body.contentId, trigger?.contentID, trigger?.contentId]
|
|
.find((value): value is string => typeof value === "string" && !!value) ?? null,
|
|
cookies: refreshedCookies,
|
|
userAgent: ODIDO_USER_AGENT,
|
|
};
|
|
}
|
|
|
|
export async function getPssh(
|
|
mpdUrl: string,
|
|
cookies: OdidoCookies = {}
|
|
): Promise<string | null> {
|
|
const response = await fetch(mpdUrl, {
|
|
headers: {
|
|
"User-Agent": ODIDO_USER_AGENT,
|
|
...(Object.keys(cookies).length ? { Cookie: cookieHeader(cookies) } : {}),
|
|
},
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
if (!response.ok) throw new Error(`Odido MPD ophalen mislukt (${response.status})`);
|
|
const xml = await response.text();
|
|
const widevine = xml.match(
|
|
/<ContentProtection\b[^>]*schemeIdUri=["']urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed["'][\s\S]*?<\/ContentProtection>/i
|
|
)?.[0];
|
|
return widevine?.match(/<(?:[\w-]+:)?pssh\b[^>]*>([^<]+)<\/(?:[\w-]+:)?pssh>/i)?.[1]?.trim() ?? null;
|
|
}
|