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

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