Auto-match Odido channels to XMLTV EPG across all configured sources.

This commit is contained in:
Jos Vooges | STH 2026-09-21 21:37:20 +02:00
parent 57972ca5f0
commit 15a678b835
7 changed files with 284 additions and 33 deletions

View file

@ -328,6 +328,8 @@ export default function LiveListsPage() {
d.disabled != null ? `${d.disabled} uit` : null, d.disabled != null ? `${d.disabled} uit` : null,
d.skipped != null ? `${d.skipped} overgeslagen` : null, d.skipped != null ? `${d.skipped} overgeslagen` : null,
d.failed != null ? `${d.failed} mislukt` : null, d.failed != null ? `${d.failed} mislukt` : null,
d.epgMatched != null ? `EPG ${d.epgMatched}` : null,
d.epgUnmatched != null && d.epgUnmatched > 0 ? `EPG-miss ${d.epgUnmatched}` : null,
d.durationMs != null ? `${Math.round(d.durationMs / 1000)}s` : null, d.durationMs != null ? `${Math.round(d.durationMs / 1000)}s` : null,
].filter(Boolean); ].filter(Boolean);
setMsg(`Odido-sync klaar: ${parts.join(" · ") || "ok"}`); setMsg(`Odido-sync klaar: ${parts.join(" · ") || "ok"}`);

View file

@ -1713,7 +1713,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
async (request) => { async (request) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const { syncOdidoList } = await import("../odido/sync"); const { syncOdidoList } = await import("../odido/sync");
return syncOdidoList(id, config.SESSION_SECRET); return syncOdidoList(id, config.SESSION_SECRET, config);
} }
); );

View file

@ -1,6 +1,7 @@
import { prisma } from "../database/client"; import { prisma } from "../database/client";
import { encryptSecret } from "../security/crypto"; import { encryptSecret } from "../security/crypto";
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
import type { Config } from "../config";
import { extractOdidoKeys } from "./cdm"; import { extractOdidoKeys } from "./cdm";
import { import {
getPssh, getPssh,
@ -10,6 +11,11 @@ import {
type OdidoChannel, type OdidoChannel,
} from "./client"; } from "./client";
import { loadOdidoSettings, saveOdidoSyncResult } from "./settings"; import { loadOdidoSettings, saveOdidoSyncResult } from "./settings";
import {
buildEpgNameIndex,
matchEpgChannelId,
} from "../viewer/epg-match";
import { loadXmltvChannelIndex } from "../viewer/live-channels";
export type OdidoSyncSummary = { export type OdidoSyncSummary = {
added: number; added: number;
@ -17,6 +23,8 @@ export type OdidoSyncSummary = {
skipped: number; skipped: number;
failed: number; failed: number;
disabled: number; disabled: number;
epgMatched: number;
epgUnmatched: number;
durationMs: number; durationMs: number;
}; };
@ -67,7 +75,8 @@ async function mapLimit<T>(
export async function syncOdidoList( export async function syncOdidoList(
listId: string, listId: string,
sessionSecret: string sessionSecret: string,
config?: Config
): Promise<OdidoSyncSummary> { ): Promise<OdidoSyncSummary> {
const started = Date.now(); const started = Date.now();
const list = await prisma.liveChannelList.findUnique({ const list = await prisma.liveChannelList.findUnique({
@ -96,7 +105,29 @@ export async function syncOdidoList(
.map((channel) => [channel.externalId!, channel]) .map((channel) => [channel.externalId!, channel])
); );
const successful = new Set<string>(); const successful = new Set<string>();
const counts = { added: 0, updated: 0, skipped: 0, failed: 0 }; const counts = {
added: 0,
updated: 0,
skipped: 0,
failed: 0,
epgMatched: 0,
epgUnmatched: 0,
};
let epgIdSet = new Set<string>();
let epgByName = new Map<string, string>();
if (config && tweaks.syncAutoMatchEpg) {
try {
const index = await loadXmltvChannelIndex(config);
epgIdSet = new Set(index.keys());
epgByName = buildEpgNameIndex(
[...index.entries()].map(([id, name]) => ({ id, name })),
"odido/"
);
} catch (err) {
console.warn("[odido] EPG-index laden mislukt:", err);
}
}
await mapLimit(videos, tweaks.syncConcurrency, async ({ channel, apiIndex }) => { await mapLimit(videos, tweaks.syncConcurrency, async ({ channel, apiIndex }) => {
const externalId = channel.ID?.trim(); const externalId = channel.ID?.trim();
@ -147,13 +178,39 @@ export async function syncOdidoList(
const name = channel.name?.trim() || channel.channelName?.trim() || const name = channel.name?.trim() || channel.channelName?.trim() ||
physical?.mediaName?.trim() || externalId; physical?.mediaName?.trim() || externalId;
const resolvedContentId = play.contentId ?? contentId(channel);
const now = new Date(); const now = new Date();
let epgChannelId = previous?.epgChannelId ?? null;
const mayOverwrite = tweaks.syncOverwriteEpgMatch;
if (epgIdSet.size > 0 && (mayOverwrite || !epgChannelId)) {
const matched = matchEpgChannelId(
{
name,
externalId,
contentId: resolvedContentId,
provider: "odido",
},
epgIdSet,
epgByName
);
if (matched) {
epgChannelId = matched;
counts.epgMatched += 1;
} else if (!epgChannelId) {
counts.epgUnmatched += 1;
}
} else if (!epgChannelId) {
counts.epgUnmatched += 1;
}
const data = { const data = {
name, name,
logoUrl: logoUrl(channel), logoUrl: logoUrl(channel),
mpdUrl: `odido://${externalId}/${mediaId}`, mpdUrl: `odido://${externalId}/${mediaId}`,
mediaId, mediaId,
contentId: play.contentId ?? contentId(channel), contentId: resolvedContentId,
epgChannelId,
kidEnc, kidEnc,
keyEnc, keyEnc,
sortOrder: channelSortOrder(channel, apiIndex), sortOrder: channelSortOrder(channel, apiIndex),

View file

@ -32,6 +32,10 @@ export type OdidoTweaks = {
syncFetchKeysIfMissing: boolean; syncFetchKeysIfMissing: boolean;
/** Tijdens sync de MPD-cache vullen. */ /** Tijdens sync de MPD-cache vullen. */
syncRefreshMpdCache: boolean; syncRefreshMpdCache: boolean;
/** Tijdens sync automatisch EPG-id koppelen (alle XMLTV-bronnen). */
syncAutoMatchEpg: boolean;
/** Bestaande handmatige EPG-koppeling overschrijven bij sync. */
syncOverwriteEpgMatch: boolean;
/** Bij play ontbrekende keys alsnog via CDM. */ /** Bij play ontbrekende keys alsnog via CDM. */
refetchKeysIfMissingOnPlay: boolean; refetchKeysIfMissingOnPlay: boolean;
heartbeatEnabled: boolean; heartbeatEnabled: boolean;
@ -57,6 +61,8 @@ export const ODIDO_TWEAK_DEFAULTS: OdidoTweaks = {
syncConcurrency: 4, syncConcurrency: 4,
syncFetchKeysIfMissing: true, syncFetchKeysIfMissing: true,
syncRefreshMpdCache: true, syncRefreshMpdCache: true,
syncAutoMatchEpg: true,
syncOverwriteEpgMatch: false,
refetchKeysIfMissingOnPlay: true, refetchKeysIfMissingOnPlay: true,
heartbeatEnabled: false, heartbeatEnabled: false,
heartbeatIntervalSeconds: 300, heartbeatIntervalSeconds: 300,
@ -97,6 +103,8 @@ export function mergeOdidoTweaks(raw: Partial<OdidoTweaks> | null | undefined):
syncConcurrency: clampInt(r.syncConcurrency, 1, 16, d.syncConcurrency), syncConcurrency: clampInt(r.syncConcurrency, 1, 16, d.syncConcurrency),
syncFetchKeysIfMissing: asBool(r.syncFetchKeysIfMissing, d.syncFetchKeysIfMissing), syncFetchKeysIfMissing: asBool(r.syncFetchKeysIfMissing, d.syncFetchKeysIfMissing),
syncRefreshMpdCache: asBool(r.syncRefreshMpdCache, d.syncRefreshMpdCache), syncRefreshMpdCache: asBool(r.syncRefreshMpdCache, d.syncRefreshMpdCache),
syncAutoMatchEpg: asBool(r.syncAutoMatchEpg, d.syncAutoMatchEpg),
syncOverwriteEpgMatch: asBool(r.syncOverwriteEpgMatch, d.syncOverwriteEpgMatch),
refetchKeysIfMissingOnPlay: asBool(r.refetchKeysIfMissingOnPlay, d.refetchKeysIfMissingOnPlay), refetchKeysIfMissingOnPlay: asBool(r.refetchKeysIfMissingOnPlay, d.refetchKeysIfMissingOnPlay),
heartbeatEnabled: asBool(r.heartbeatEnabled, d.heartbeatEnabled), heartbeatEnabled: asBool(r.heartbeatEnabled, d.heartbeatEnabled),
heartbeatIntervalSeconds: clampInt(r.heartbeatIntervalSeconds, 30, 3600, d.heartbeatIntervalSeconds), heartbeatIntervalSeconds: clampInt(r.heartbeatIntervalSeconds, 30, 3600, d.heartbeatIntervalSeconds),
@ -139,6 +147,8 @@ export const ODIDO_TWEAK_META: Array<{
{ key: "syncConcurrency", label: "Sync parallel", hint: "Gelijktijdige zenders bij volledig ophalen", kind: "number" }, { key: "syncConcurrency", label: "Sync parallel", hint: "Gelijktijdige zenders bij volledig ophalen", kind: "number" },
{ key: "syncFetchKeysIfMissing", label: "Sync: keys ophalen", hint: "CDM tijdens sync als keys ontbreken", kind: "boolean" }, { key: "syncFetchKeysIfMissing", label: "Sync: keys ophalen", hint: "CDM tijdens sync als keys ontbreken", kind: "boolean" },
{ key: "syncRefreshMpdCache", label: "Sync: MPD-cache vullen", hint: "Sla playURL op tijdens sync", kind: "boolean" }, { key: "syncRefreshMpdCache", label: "Sync: MPD-cache vullen", hint: "Sla playURL op tijdens sync", kind: "boolean" },
{ key: "syncAutoMatchEpg", label: "Sync: EPG auto-match", hint: "Koppel XMLTV-id via alle EPG-bronnen", kind: "boolean" },
{ key: "syncOverwriteEpgMatch", label: "Sync: EPG overschrijven", hint: "Ook bestaande handmatige EPG-koppeling vervangen", kind: "boolean" },
{ key: "refetchKeysIfMissingOnPlay", label: "Play: keys als ontbrekend", hint: "CDM bij play zonder opgeslagen keys", kind: "boolean" }, { key: "refetchKeysIfMissingOnPlay", label: "Play: keys als ontbrekend", hint: "CDM bij play zonder opgeslagen keys", kind: "boolean" },
{ key: "heartbeatEnabled", label: "Odido heartbeat", hint: "Sessie warm houden via OnLineHeartbeat", kind: "boolean" }, { key: "heartbeatEnabled", label: "Odido heartbeat", hint: "Sessie warm houden via OnLineHeartbeat", kind: "boolean" },
{ key: "heartbeatIntervalSeconds", label: "Heartbeat-interval", hint: "Alleen als heartbeat aan staat", kind: "number", unit: "sec" }, { key: "heartbeatIntervalSeconds", label: "Heartbeat-interval", hint: "Alleen als heartbeat aan staat", kind: "number", unit: "sec" },

View file

@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import {
buildEpgNameIndex,
matchEpgChannelId,
normalizeEpgLabel,
} from "./epg-match";
assert.equal(normalizeEpgLabel("1. NPO 1"), "npo1");
assert.equal(normalizeEpgLabel("NPO 1 HD"), "npo1");
assert.equal(normalizeEpgLabel("BBC One"), "bbcone");
const options = [
{ id: "odido/141430714", name: "1. NPO 1" },
{ id: "odido/141429798", name: "150. ESPN" },
{ id: "NPO1.nl", name: "NPO 1" },
{ id: "other/espn", name: "ESPN HD" },
];
const byName = buildEpgNameIndex(options);
const idSet = new Set(options.map((o) => o.id));
assert.equal(
matchEpgChannelId({ name: "NPO 1", externalId: "141430714", provider: "odido" }, idSet, byName),
"odido/141430714"
);
assert.equal(
matchEpgChannelId({ name: "ESPN", externalId: "141429798", provider: "odido" }, idSet, byName),
"odido/141429798"
);
assert.equal(
matchEpgChannelId({ name: "NPO 1", externalId: null, provider: null }, idSet, byName),
"odido/141430714"
);
console.log("epg-match tests ok");

View file

@ -0,0 +1,118 @@
/**
* 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;
}

View file

@ -604,15 +604,72 @@ export async function getCustomGuideChannels(
}; };
} }
/** Admin EPG-picker: XMLTV channel id + display name. */ /** Admin EPG-picker: XMLTV channel id + display name (alle geconfigureerde bronnen). */
export async function listEpgChannelOptions(config: Config) { export async function listEpgChannelOptions(config: Config) {
await applyIptvEpgUrlsToRuntime(config);
const index = await loadXmltvChannelIndex(config); const index = await loadXmltvChannelIndex(config);
return [...index.entries()] return [...index.entries()]
.map(([id, name]) => ({ id, name })) .map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name, "nl")); .sort((a, b) => a.name.localeCompare(b.name, "nl"));
} }
/**
* Volledige EPG channel-index over alle XMLTV-bronnen (iptv epgXmlUrls + XUI indien aanwezig).
* Exporteerbaar voor Odido/auto-match.
*/
export async function loadXmltvChannelIndex(config: Config): Promise<Map<string, string>> {
await applyIptvEpgUrlsToRuntime(config);
const settings = await getIptvSettings(config);
const urls = [...settings.epgXmlUrls];
const index = new Map<string, string>();
const mergeParsed = (parsed: Map<string, string>) => {
for (const [id, name] of parsed) {
if (!index.has(id)) index.set(id, name);
const lower = id.toLowerCase();
if (lower !== id && !index.has(lower)) index.set(lower, name);
}
};
for (const url of urls) {
try {
const xml = await fetchXmltvText([url]);
mergeParsed(parseXmltvChannelIndex(xml));
} catch {
/* skip broken source — andere bronnen blijven gelden */
}
}
// XUI xmltv.php meenemen als er een live-lijn is (extra bron naast externe feeds)
try {
const line = await prisma.iptvLine.findFirst({
where: { enableLive: true },
orderBy: { updatedAt: "desc" },
select: { id: true },
});
if (line) {
const xt = await loadXtreamByLineId(line.id, config);
if (xt) {
const guides = await xt.client.getXmltvGuidesByChannelId().catch(() => null);
if (guides) {
for (const id of guides.keys()) {
if (!index.has(id)) index.set(id, id);
}
}
}
}
} catch {
/* XUI optioneel */
}
if (index.size === 0) {
const guides = await loadXmltvGuidesForEpg(config).catch(() => new Map());
for (const id of guides.keys()) {
index.set(id, id);
}
}
return index;
}
const TV_LOGOS_GH = const TV_LOGOS_GH =
"https://api.github.com/repos/tv-logo/tv-logos/contents/countries/netherlands"; "https://api.github.com/repos/tv-logo/tv-logos/contents/countries/netherlands";
const TV_LOGOS_CDN = const TV_LOGOS_CDN =
@ -721,33 +778,6 @@ async function loadXmltvGuidesForEpg(
return merged; return merged;
} }
async function loadXmltvChannelIndex(config: Config): Promise<Map<string, string>> {
const settings = await getIptvSettings(config);
const urls = [...settings.epgXmlUrls];
const index = new Map<string, string>();
for (const url of urls) {
try {
const xml = await fetchXmltvText([url]);
const parsed = parseXmltvChannelIndex(xml);
for (const [id, name] of parsed) {
if (!index.has(id)) index.set(id, name);
}
} catch {
/* skip */
}
}
// Fallback: keys from guides without nice names
if (index.size === 0) {
const guides = await loadXmltvGuidesForEpg(config).catch(() => new Map());
for (const id of guides.keys()) {
index.set(id, id);
}
}
return index;
}
/** Lightweight check used by IPTV status. */ /** Lightweight check used by IPTV status. */
export async function countCustomLiveForViewer(viewerId: string): Promise<number> { export async function countCustomLiveForViewer(viewerId: string): Promise<number> {
return prisma.liveChannel.count({ return prisma.liveChannel.count({