Improves Android TV Events navigation and player UX, and gates schedule streams by viewer Live Events ACL plus prisma migration.
268 lines
7.8 KiB
TypeScript
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];
|
|
}
|