import { prisma } from "../database/client"; import type { Config } from "../config"; import { AppError } from "../security/errors"; import { setExternalEpgXmlUrls } from "../iptv/xtream"; const IPTV_ID = "iptv"; export type IptvSettingsPublic = { epgXmlUrls: string[]; categoryOrder: string[]; showAlleZenders: boolean; /** FAAB (of andere) live-events JSON feed; leeg = default FAAB-URL. */ liveEventsUrl: string; /** * Providers die cluster-breed uit staan. * Nieuwe providers die hier niet in staan blijven aan (default ON). */ liveEventsDisabledProviders: string[]; }; type StoredJson = Partial<{ epgXmlUrls: unknown; categoryOrder: unknown; showAlleZenders: unknown; liveEventsUrl: unknown; liveEventsDisabledProviders: unknown; }>; type CacheEntry = { at: number; settings: IptvSettingsPublic }; let settingsCache: CacheEntry | null = null; const SETTINGS_TTL_MS = 30_000; function parseConfigJson(raw: string | null | undefined): StoredJson { if (!raw?.trim()) return {}; try { return JSON.parse(raw) as StoredJson; } catch { return {}; } } function normalizeCategoryId(raw: unknown): string { if (raw == null) return ""; const s = String(raw).trim(); if (!s) return ""; if (/^\d+(\.0+)?$/.test(s)) return String(parseInt(s, 10)); return s; } function normalizeUrlList(raw: unknown, fallback: string[] = []): string[] { const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\n,]+/) : fallback; const out: string[] = []; const seen = new Set(); for (const item of list) { const u = String(item ?? "").trim(); if (!u) continue; try { const parsed = new URL(u); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") continue; } catch { continue; } if (seen.has(u)) continue; seen.add(u); out.push(u); } return out; } function normalizeIdList(raw: unknown): string[] { if (!Array.isArray(raw)) return []; const out: string[] = []; const seen = new Set(); for (const item of raw) { const id = normalizeCategoryId(item); if (!id || id === "__all__" || seen.has(id)) continue; seen.add(id); out.push(id); } return out; } /** Provider-ids: trim + lowercase, uniek, gesorteerd. */ export function normalizeProviderIdList(raw: unknown): string[] { if (!Array.isArray(raw)) return []; const out: string[] = []; const seen = new Set(); for (const item of raw) { const id = String(item ?? "") .trim() .toLowerCase(); if (!id || seen.has(id)) continue; seen.add(id); out.push(id); } return out.sort((a, b) => a.localeCompare(b, "nl")); } const DEFAULT_LIVE_EVENTS_URL = "http://epg.faabserver.nl/api/export/live-events?horizon_days=3"; function normalizeSingleUrl(raw: unknown, fallback = ""): string { const s = String(raw ?? "").trim(); if (!s) return fallback; try { const parsed = new URL(s); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return fallback; return s; } catch { return fallback; } } function defaultsFromEnv(config: Config): IptvSettingsPublic { const envUrl = config.IPTV_EPG_XML_URL?.trim(); return { epgXmlUrls: envUrl ? [envUrl] : [], categoryOrder: [], showAlleZenders: true, liveEventsUrl: DEFAULT_LIVE_EVENTS_URL, liveEventsDisabledProviders: [], }; } function fromStored(stored: StoredJson, config: Config): IptvSettingsPublic { const base = defaultsFromEnv(config); const hasStoredUrls = Object.prototype.hasOwnProperty.call(stored, "epgXmlUrls"); const urls = hasStoredUrls ? normalizeUrlList(stored.epgXmlUrls) : base.epgXmlUrls; const hasLiveEventsUrl = Object.prototype.hasOwnProperty.call(stored, "liveEventsUrl"); return { epgXmlUrls: urls, categoryOrder: normalizeIdList(stored.categoryOrder), showAlleZenders: typeof stored.showAlleZenders === "boolean" ? stored.showAlleZenders : true, liveEventsUrl: hasLiveEventsUrl ? normalizeSingleUrl(stored.liveEventsUrl, "") : base.liveEventsUrl, liveEventsDisabledProviders: normalizeProviderIdList( stored.liveEventsDisabledProviders ), }; } export async function getIptvSettings(config: Config): Promise { if (settingsCache && Date.now() - settingsCache.at < SETTINGS_TTL_MS) { return settingsCache.settings; } const row = await prisma.integrationSetting.findUnique({ where: { id: IPTV_ID } }); const settings = fromStored(parseConfigJson(row?.configJson), config); settingsCache = { at: Date.now(), settings }; return settings; } export function invalidateIptvSettingsCache() { settingsCache = null; } /** Zet runtime EPG-feeds (DB + env-fallback) voor XMLTV-merge. */ export async function applyIptvEpgUrlsToRuntime(config: Config): Promise { const settings = await getIptvSettings(config); setExternalEpgXmlUrls(settings.epgXmlUrls); return settings.epgXmlUrls; } export async function upsertIptvSettings( config: Config, body: { epgXmlUrls?: unknown; categoryOrder?: unknown; showAlleZenders?: unknown; liveEventsUrl?: unknown; liveEventsDisabledProviders?: unknown; } ): Promise { const current = await getIptvSettings(config); const next: IptvSettingsPublic = { epgXmlUrls: body.epgXmlUrls !== undefined ? normalizeUrlList(body.epgXmlUrls) : current.epgXmlUrls, categoryOrder: body.categoryOrder !== undefined ? normalizeIdList(body.categoryOrder) : current.categoryOrder, showAlleZenders: typeof body.showAlleZenders === "boolean" ? body.showAlleZenders : current.showAlleZenders, liveEventsUrl: body.liveEventsUrl !== undefined ? normalizeSingleUrl(body.liveEventsUrl, "") : current.liveEventsUrl, liveEventsDisabledProviders: body.liveEventsDisabledProviders !== undefined ? normalizeProviderIdList(body.liveEventsDisabledProviders) : current.liveEventsDisabledProviders, }; if (body.epgXmlUrls !== undefined) { const rawCount = Array.isArray(body.epgXmlUrls) ? body.epgXmlUrls.filter((u) => String(u ?? "").trim()).length : typeof body.epgXmlUrls === "string" ? body.epgXmlUrls.split(/[\n,]+/).filter((u) => u.trim()).length : 0; if (rawCount > 0 && next.epgXmlUrls.length === 0) { throw new AppError("VALIDATION", "Geen geldige EPG-URL's (http/https)", 400); } } if (body.liveEventsUrl !== undefined) { const raw = String(body.liveEventsUrl ?? "").trim(); if (raw && !next.liveEventsUrl) { throw new AppError("VALIDATION", "Ongeldige live-events URL (http/https)", 400); } } await prisma.integrationSetting.upsert({ where: { id: IPTV_ID }, create: { id: IPTV_ID, enabled: true, configJson: JSON.stringify(next), }, update: { enabled: true, configJson: JSON.stringify(next), }, }); invalidateIptvSettingsCache(); setExternalEpgXmlUrls(next.epgXmlUrls); try { const { invalidateScheduleEventsCache } = await import("../viewer/schedule-events"); invalidateScheduleEventsCache(); } catch { /* ignore */ } return next; } /** Pas admin-volgorde toe; onbekende ids achteraan (alfabetisch). */ export function applyIptvCategoryOrder( categories: T[], order: string[] ): T[] { if (!order.length) { return [...categories].sort((a, b) => a.name.localeCompare(b.name, "nl")); } const byId = new Map(categories.map((c) => [c.id, c])); const ordered: T[] = []; const used = new Set(); for (const id of order) { const hit = byId.get(id); if (!hit || used.has(id)) continue; ordered.push(hit); used.add(id); } const rest = categories .filter((c) => !used.has(c.id)) .sort((a, b) => a.name.localeCompare(b.name, "nl")); return [...ordered, ...rest]; }