118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
/**
|
|
* Match live-lijst zenders → XMLTV channel id over alle geconfigureerde EPG-bronnen.
|
|
* Preferentie: exacte id (odido/<externalId>) → 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<EpgChannelOption>,
|
|
preferIdPrefix?: string
|
|
): Map<string, string> {
|
|
const byName = new Map<string, string>();
|
|
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<string>();
|
|
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/<id> 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<string>,
|
|
byName: Map<string, string>
|
|
): 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;
|
|
}
|