stremio/apps/master-api/src/viewer/epg-match.ts

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