/** * Match live-lijst zenders → XMLTV channel id over alle geconfigureerde EPG-bronnen. * Preferentie: exacte id (odido/) → contentId → genormaliseerde display-name. */ export type EpgChannelOption = { id: string; name: string }; export function normalizeEpgLabel(raw: string | null | undefined): string { let s = String(raw ?? "") .toLowerCase() .normalize("NFD") .replace(/\p{M}/gu, ""); // "1. NPO 1" / "150. ESPN" s = s.replace(/^\d+\.\s*/, ""); s = s.replace(/\b(hd|fhd|uhd|4k|hevc|hdr|sd)\b/g, " "); s = s.replace(/&/g, " and "); s = s.replace(/[^a-z0-9+]+/g, ""); return s; } /** Bouw lookup: genormaliseerde naam → voorkeurs-id (stabiele eerste winnaar). */ export function buildEpgNameIndex( options: Iterable, preferIdPrefix?: string ): Map { const byName = new Map(); const prefer = preferIdPrefix?.toLowerCase(); const preferBetter = (existing: string, next: string) => { if (!prefer) return false; const e = existing.toLowerCase().startsWith(prefer); const n = next.toLowerCase().startsWith(prefer); return n && !e; }; for (const { id, name } of options) { const keys = new Set(); const nName = normalizeEpgLabel(name); const nId = normalizeEpgLabel(id); if (nName) keys.add(nName); if (nId) keys.add(nId); const bare = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : ""; if (bare) { const nBare = normalizeEpgLabel(bare); if (nBare) keys.add(nBare); } for (const key of keys) { const prev = byName.get(key); if (!prev || preferBetter(prev, id)) byName.set(key, id); } } return byName; } export type MatchEpgInput = { name: string; externalId?: string | null; contentId?: string | null; /** provider hint: bij "odido" eerst odido/ proberen */ provider?: string | null; }; /** * Zoek beste XMLTV-id. `idSet` bevat alle bekende ids (case-sensitive + lower aliases ok). * `byName` uit buildEpgNameIndex. */ export function matchEpgChannelId( input: MatchEpgInput, idSet: Set, byName: Map ): string | null { const candidates: string[] = []; const externalId = input.externalId?.trim(); const contentId = input.contentId?.trim(); const provider = (input.provider ?? "").trim().toLowerCase(); if (externalId) { if (provider === "odido" || !provider) { candidates.push(`odido/${externalId}`); } candidates.push(externalId); } if (contentId) { if (provider === "odido" || !provider) { candidates.push(`odido/${contentId}`); } candidates.push(contentId); } for (const c of candidates) { if (idSet.has(c)) return c; const lower = c.toLowerCase(); if (lower !== c && idSet.has(lower)) return lower; // case-insensitive scan for (const id of idSet) { if (id.toLowerCase() === lower) return id; } } const nameKey = normalizeEpgLabel(input.name); if (nameKey && byName.has(nameKey)) return byName.get(nameKey)!; // Lichte fuzzy: name bevat of zit in EPG-label (alleen bij unieke hit) if (nameKey && nameKey.length >= 4) { const hits: string[] = []; for (const [key, id] of byName) { if (key === nameKey) return id; if (key.includes(nameKey) || nameKey.includes(key)) hits.push(id); } const unique = [...new Set(hits)]; if (unique.length === 1) return unique[0]; // Bij Odido: prefer odido/-ids if (provider === "odido") { const odidoHits = unique.filter((id) => id.toLowerCase().startsWith("odido/")); if (odidoHits.length === 1) return odidoHits[0]; } } return null; }