stremio/apps/master-api/src/settings/iptv.ts
Jos Vooges | STH cbacf1af41 Polish TV Live Events focus/playback and add per-viewer provider access in admin/API.
Improves Android TV Events navigation and player UX, and gates schedule streams by viewer Live Events ACL plus prisma migration.
2026-09-19 00:39:03 +02:00

268 lines
7.8 KiB
TypeScript

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<string>();
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<string>();
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<string>();
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<IptvSettingsPublic> {
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<string[]> {
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<IptvSettingsPublic> {
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<T extends { id: string; name: string }>(
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<string>();
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];
}