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; 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(); 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): boolean { const code = body.retCode; return code === undefined || code === null || code === "" || code === 0 || code === "0" || code === "000000000"; } function apiError(operation: string, body: Record): 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; 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; try { body = JSON.parse(text) as Record; } 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 { 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 { 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 { 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 { 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 { 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> } | 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 { 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( /]*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; }