Add admin TV settings for EPG URLs, category order, and Alle zenders.

Store cluster-wide IPTV UI prefs in IntegrationSetting and apply them to client category lists and multi-feed XMLTV merge.
This commit is contained in:
Jos Vooges | STH 2026-09-15 18:33:50 +02:00
parent 18bcf43c14
commit 0c15d61291
7 changed files with 528 additions and 45 deletions

View file

@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Nav, useAuth } from "@/components/Nav";
type SettingsTab = "admin" | "shelves" | "download-station" | "opensubtitles" | "google-play";
type SettingsTab = "admin" | "shelves" | "download-station" | "opensubtitles" | "google-play" | "tv";
function parseTab(v: string | null): SettingsTab {
if (
@ -12,7 +12,8 @@ function parseTab(v: string | null): SettingsTab {
v === "download-station" ||
v === "opensubtitles" ||
v === "shelves" ||
v === "google-play"
v === "google-play" ||
v === "tv"
) {
return v;
}
@ -57,6 +58,7 @@ interface DsConfig {
const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [
{ id: "admin", label: "Admin", short: "Admin" },
{ id: "shelves", label: "Planken", short: "Planken" },
{ id: "tv", label: "TV", short: "TV" },
{ id: "download-station", label: "Download Station", short: "DS" },
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
{ id: "google-play", label: "Google Play", short: "Play" },
@ -141,6 +143,15 @@ export default function SettingsPage() {
const [gpMsg, setGpMsg] = useState<string | null>(null);
const [gpErr, setGpErr] = useState<string | null>(null);
const [tvEpgUrlsText, setTvEpgUrlsText] = useState("");
const [tvShowAlle, setTvShowAlle] = useState(true);
const [tvCategories, setTvCategories] = useState<
Array<{ id: string; name: string; channelCount: number }>
>([]);
const [tvBusy, setTvBusy] = useState(false);
const [tvMsg, setTvMsg] = useState<string | null>(null);
const [tvErr, setTvErr] = useState<string | null>(null);
const loadShelves = useCallback(() => {
fetch("/api/v1/admin/library/shelves", { credentials: "include" })
.then((r) => r.json())
@ -204,11 +215,26 @@ export default function SettingsPage() {
.catch(() => undefined);
}, []);
const loadTvSettings = useCallback(() => {
fetch("/api/v1/admin/settings/iptv", { credentials: "include" })
.then((r) => r.json())
.then((d) => {
const s = d.settings;
if (s) {
setTvEpgUrlsText(Array.isArray(s.epgXmlUrls) ? s.epgXmlUrls.join("\n") : "");
setTvShowAlle(s.showAlleZenders !== false);
}
setTvCategories(Array.isArray(d.categories) ? d.categories : []);
})
.catch(() => undefined);
}, []);
useEffect(() => {
loadShelves();
loadDs();
loadOpenSubtitles();
loadGooglePlay();
loadTvSettings();
fetch("/api/v1/auth/me", { credentials: "include" })
.then((r) => r.json())
.then((d) => setAdminEmail(d.user?.email ?? ""))
@ -221,7 +247,7 @@ export default function SettingsPage() {
}
})
.catch(() => undefined);
}, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay]);
}, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay, loadTvSettings]);
async function saveInstallPageEnabled(enabled: boolean) {
setInstallPageBusy(true);
@ -443,6 +469,55 @@ export default function SettingsPage() {
}
}
function moveTvCategory(index: number, dir: -1 | 1) {
setTvCategories((prev) => {
const next = [...prev];
const j = index + dir;
if (j < 0 || j >= next.length) return prev;
const tmp = next[index];
next[index] = next[j];
next[j] = tmp;
return next;
});
}
async function saveTvSettings() {
setTvBusy(true);
setTvMsg(null);
setTvErr(null);
try {
const epgXmlUrls = tvEpgUrlsText
.split(/[\n,]+/)
.map((u) => u.trim())
.filter(Boolean);
const res = await fetch("/api/v1/admin/settings/iptv", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
epgXmlUrls,
categoryOrder: tvCategories.map((c) => c.id),
showAlleZenders: tvShowAlle,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setTvErr(data.error?.message ?? "Opslaan mislukt");
return;
}
setTvMsg("Opgeslagen — geldt voor alle accounts");
if (data.settings) {
setTvEpgUrlsText(
Array.isArray(data.settings.epgXmlUrls) ? data.settings.epgXmlUrls.join("\n") : ""
);
setTvShowAlle(data.settings.showAlleZenders !== false);
}
if (Array.isArray(data.categories)) setTvCategories(data.categories);
} finally {
setTvBusy(false);
}
}
return (
<>
<Nav />
@ -710,6 +785,110 @@ export default function SettingsPage() {
</section>
)}
{tab === "tv" && (
<section className="stack-section">
<div className="section-head">
<h2 className="section-title">Live TV</h2>
<p className="muted">
Cluster-breed: EPG-bronnen, categorie-volgorde en of &quot;Alle zenders&quot; zichtbaar is.
Geldt voor alle accounts. IPTV-login blijft per kijker (onder{" "}
<Link href="/viewers">Kijkers</Link>).
</p>
</div>
<div className="card" style={{ marginBottom: "1rem" }}>
<h3 className="section-title" style={{ fontSize: "1rem", marginBottom: "0.5rem" }}>
EPG XML-URL&apos;s
</h3>
<p className="muted" style={{ marginBottom: "0.75rem" }}>
Eén URL per regel. Meerdere bronnen worden samengevoegd op channel-id (zoals{" "}
<code>odido/…</code>). Eerst in XUI de EPG aan zenders koppelen, daarna hier de rijke
XML-feed(s) zetten voor images/beschrijvingen.
</p>
<label style={{ display: "block", marginBottom: "0.75rem" }}>
XMLTV-feeds
<textarea
value={tvEpgUrlsText}
onChange={(e) => setTvEpgUrlsText(e.target.value)}
rows={4}
placeholder="https://epg.faabserver.nl/epg/odido.xml"
style={{ width: "100%", fontFamily: "monospace", fontSize: "0.85rem" }}
autoComplete="off"
/>
</label>
<label className="checkbox-row" style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
<input
type="checkbox"
checked={tvShowAlle}
onChange={(e) => setTvShowAlle(e.target.checked)}
/>
<span>Toon categorie &quot;Alle zenders&quot; in de apps</span>
</label>
</div>
<div className="card">
<h3 className="section-title" style={{ fontSize: "1rem", marginBottom: "0.5rem" }}>
Categorie-volgorde
</h3>
<p className="muted" style={{ marginBottom: "0.75rem" }}>
Volgorde van links naar rechts in de TV-apps. Nieuwe XUI-categorieën komen onderaan.
{tvCategories.length === 0
? " Geen actieve IPTV-lijn gevonden — koppel eerst een kijker."
: ""}
</p>
<div style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}>
{tvCategories.map((cat, idx) => (
<div
key={cat.id}
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.45rem 0.6rem",
background: "var(--bg-elevated, #141414)",
borderRadius: 6,
}}
>
<span style={{ width: 28, color: "var(--muted)", fontSize: "0.85rem" }}>
{idx + 1}
</span>
<span style={{ flex: 1, fontWeight: 500 }}>{cat.name}</span>
<span className="muted" style={{ fontSize: "0.8rem" }}>
{cat.channelCount} · {cat.id}
</span>
<button
type="button"
disabled={idx === 0 || tvBusy}
onClick={() => moveTvCategory(idx, -1)}
aria-label="Omhoog"
>
↑
</button>
<button
type="button"
disabled={idx >= tvCategories.length - 1 || tvBusy}
onClick={() => moveTvCategory(idx, 1)}
aria-label="Omlaag"
>
↓
</button>
</div>
))}
</div>
<div className="inline-form" style={{ marginTop: "1rem" }}>
<button type="button" disabled={tvBusy} onClick={() => void saveTvSettings()}>
{tvBusy ? "Bezig…" : "Opslaan"}
</button>
<button type="button" disabled={tvBusy} onClick={() => loadTvSettings()}>
Vernieuwen
</button>
{tvMsg && <span className="ok-text">{tvMsg}</span>}
{tvErr && <span className="danger-text">{tvErr}</span>}
</div>
</div>
</section>
)}
{tab === "download-station" && (
<section className="stack-section">
<div className="section-head">

View file

@ -20,7 +20,8 @@ NODE_HEARTBEAT_INTERVAL_SECONDS=30
NODE_OFFLINE_THRESHOLD_SECONDS=90
HIDE_UNAVAILABLE_TITLES=true
# Rijke IPTV-EPG (icons/desc/24u) — merge op epg_channel_id met XUI
# Rijke IPTV-EPG (fallback als Admin → Instellingen → TV leeg is)
# Voorkeur: URL's beheren in Admin UI (meerdere feeds mogelijk).
IPTV_EPG_XML_URL=https://epg.faabserver.nl/epg/odido.xml
# Google Play Closed Testing (optioneel — zie GOOGLE_PLAY_ACCESS_SETUP.md)

View file

@ -20,11 +20,11 @@ import { nodeConnectionManager } from "./websocket/manager";
import { MetadataService } from "./metadata/service";
import { toErrorResponse } from "./security/errors";
import { startIptvEpgWarmup } from "./viewer/iptv-client";
import { setExternalEpgXmlUrl } from "./iptv/xtream";
import { applyIptvEpgUrlsToRuntime } from "./settings/iptv";
async function main() {
const config = loadConfig();
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
await applyIptvEpgUrlsToRuntime(config);
const app = Fastify({
logger: {

View file

@ -98,15 +98,31 @@ export type XmltvNowNext = {
next: XmltvProgram | null;
};
/** Optionele externe rijke feed (icons/desc), gezet vanuit config. */
let externalEpgXmlUrl: string | null = null;
/** Optionele externe rijke feeds (icons/desc), gezet vanuit admin/config. */
let externalEpgXmlUrls: string[] = [];
export function setExternalEpgXmlUrls(urls: string[] | null | undefined) {
const seen = new Set<string>();
externalEpgXmlUrls = [];
for (const raw of urls ?? []) {
const u = String(raw ?? "").trim();
if (!u || seen.has(u)) continue;
seen.add(u);
externalEpgXmlUrls.push(u);
}
}
/** @deprecated gebruik setExternalEpgXmlUrls */
export function setExternalEpgXmlUrl(url: string | null | undefined) {
externalEpgXmlUrl = url?.trim() || null;
setExternalEpgXmlUrls(url ? [url] : []);
}
export function getExternalEpgXmlUrls(): string[] {
return [...externalEpgXmlUrls];
}
export function getExternalEpgXmlUrl(): string | null {
return externalEpgXmlUrl;
return externalEpgXmlUrls[0] ?? null;
}
type CacheEntry<T> = { at: number; data: T };
@ -360,7 +376,7 @@ export class XtreamClient {
/**
* Rijke gids per XMLTV-channel id: now/next + 24u schedule.
* Extern IPTV_EPG_XML_URL wint op image/desc; XUI vult ontbrekende zenders.
* Externe EPG-URL's (admin) winnen op image/desc; XUI vult ontbrekende zenders.
*/
async getXmltvGuidesByChannelId(ttlMs = 20 * 60_000): Promise<Map<string, XmltvChannelGuide>> {
const cacheKey = "xmltv:guides";
@ -374,27 +390,34 @@ export class XtreamClient {
`${this.baseUrl}/xmltv.php?username=${user}&password=${pass}&type=xml`,
];
const [externalXml, xuiXml] = await Promise.all([
externalEpgXmlUrl
? fetchXmltvText([externalEpgXmlUrl]).catch((err) => {
console.warn("[iptv-epg] external xmltv:", err);
return "";
const externalFetches = externalEpgXmlUrls.map((url) =>
fetchXmltvText([url])
.then((xml) => parseXmltvGuides(xml))
.catch((err) => {
console.warn("[iptv-epg] external xmltv:", url, err);
return new Map<string, XmltvChannelGuide>();
})
: Promise.resolve(""),
);
const [xuiXml, ...externalMaps] = await Promise.all([
fetchXmltvText(xuiUrls).catch((err) => {
console.warn("[iptv-epg] xui xmltv:", err);
return "";
}),
...externalFetches,
]);
if (!externalXml && !xuiXml) {
throw new Error("XMLTV niet beschikbaar");
let external = new Map<string, XmltvChannelGuide>();
for (const m of externalMaps) {
external = mergeXmltvGuides(external, m);
}
const external = externalXml ? parseXmltvGuides(externalXml) : new Map<string, XmltvChannelGuide>();
const xui = xuiXml ? parseXmltvGuides(xuiXml) : new Map<string, XmltvChannelGuide>();
const map = mergeXmltvGuides(xui, external);
if (map.size === 0) {
throw new Error("XMLTV niet beschikbaar");
}
this.cacheSet(cacheKey, map);
// Houd thin now/next-cache synchroon
const thin = new Map<string, XmltvNowNext>();

View file

@ -0,0 +1,196 @@
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;
};
type StoredJson = Partial<{
epgXmlUrls: unknown;
categoryOrder: unknown;
showAlleZenders: 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;
}
function defaultsFromEnv(config: Config): IptvSettingsPublic {
const envUrl = config.IPTV_EPG_XML_URL?.trim();
return {
epgXmlUrls: envUrl ? [envUrl] : [],
categoryOrder: [],
showAlleZenders: true,
};
}
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;
return {
epgXmlUrls: urls,
categoryOrder: normalizeIdList(stored.categoryOrder),
showAlleZenders:
typeof stored.showAlleZenders === "boolean" ? stored.showAlleZenders : true,
};
}
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;
}
): 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,
};
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);
}
}
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);
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];
}

View file

@ -10,6 +10,8 @@ import {
getGooglePlaySettingsPublic,
upsertGooglePlaySettings,
} from "./google-play";
import { getIptvSettings, upsertIptvSettings } from "./iptv";
import { listIptvCategoriesForAdmin } from "../viewer/iptv-client";
import type { GooglePlayAccessService } from "../google-play/service";
export function registerSettingsRoutes(
@ -65,4 +67,23 @@ export function registerSettingsRoutes(
googlePlay?.invalidateClient();
return { settings };
});
app.get("/api/v1/admin/settings/iptv", { preHandler: requireAdmin }, async () => {
const [settings, categories] = await Promise.all([
getIptvSettings(config),
listIptvCategoriesForAdmin(config),
]);
return { settings, categories };
});
app.put("/api/v1/admin/settings/iptv", { preHandler: requireAdmin }, async (request) => {
const body = request.body as {
epgXmlUrls?: string[];
categoryOrder?: string[];
showAlleZenders?: boolean;
};
const settings = await upsertIptvSettings(config, body);
const categories = await listIptvCategoriesForAdmin(config);
return { settings, categories };
});
}

View file

@ -5,7 +5,6 @@ import { loadXtreamByLineId, loadXtreamForViewer } from "../iptv/service";
import {
decodeEpgText,
epgToIso,
setExternalEpgXmlUrl,
type XtreamClient,
type XtreamEpgListing,
type XtreamLiveStream,
@ -13,6 +12,11 @@ import {
type XmltvNowNext,
type XmltvProgram,
} from "../iptv/xtream";
import {
applyIptvCategoryOrder,
applyIptvEpgUrlsToRuntime,
getIptvSettings,
} from "../settings/iptv";
const AGGREGATE_LABELS = new Set([
"alles",
@ -62,12 +66,13 @@ export async function getViewerIptvCategories(viewerId: string, config: Config)
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
// Categorieën + (korte) zenderlijst parallel — categorieën nooit laten falen op trage streams
const [catsRaw, streamsRaw] = await Promise.all([
const [catsRaw, streamsRaw, iptvPrefs] = await Promise.all([
xt.client.getLiveCategories().catch(() => [] as Awaited<ReturnType<typeof xt.client.getLiveCategories>>),
Promise.race([
xt.client.getLiveStreams().catch(() => [] as XtreamLiveStream[]),
new Promise<XtreamLiveStream[]>((resolve) => setTimeout(() => resolve([]), 6_000)),
]),
getIptvSettings(config),
]);
const cats = Array.isArray(catsRaw) ? catsRaw : [];
const streams: XtreamLiveStream[] = Array.isArray(streamsRaw) ? streamsRaw : [];
@ -91,32 +96,90 @@ export async function getViewerIptvCategories(viewerId: string, config: Config)
};
})
// Alleen pure provider-dump "Alles/All" weg — niet "NL | ALL" landpakketten
.filter((c) => c.id.length > 0 && !isAggregateCategoryName(c.rawName))
.sort((a, b) => a.name.localeCompare(b.name, "nl"));
.filter((c) => c.id.length > 0 && !isAggregateCategoryName(c.rawName));
// Fallback: bouquets uit zenders afleiden als get_live_categories leeg/fout is
if (categories.length === 0 && counts.size > 0) {
categories = [...counts.entries()]
.map(([id, channelCount]) => ({
categories = [...counts.entries()].map(([id, channelCount]) => ({
id,
name: `Groep ${id}`,
rawName: `Groep ${id}`,
channelCount,
}))
.sort((a, b) => a.name.localeCompare(b.name, "nl"));
}));
}
// Één eigen verzamelknop
categories = applyIptvCategoryOrder(categories, iptvPrefs.categoryOrder);
if (iptvPrefs.showAlleZenders) {
categories.unshift({
id: "__all__",
name: "Alle zenders",
rawName: "Alle zenders",
channelCount: streams.length,
});
}
return { categories };
}
/** Admin: categorieën uit een actieve IPTV-lijn voor volgorde-UI. */
export async function listIptvCategoriesForAdmin(config: Config) {
const line = await prisma.iptvLine.findFirst({
where: { enableLive: true },
orderBy: { updatedAt: "desc" },
select: { id: true },
});
if (!line) return [] as Array<{ id: string; name: string; channelCount: number }>;
const xt = await loadXtreamByLineId(line.id, config);
if (!xt?.line.enableLive) {
return [] as Array<{ id: string; name: string; channelCount: number }>;
}
const [catsRaw, streamsRaw, iptvPrefs] = await Promise.all([
xt.client.getLiveCategories().catch(() => []),
Promise.race([
xt.client.getLiveStreams().catch(() => [] as XtreamLiveStream[]),
new Promise<XtreamLiveStream[]>((resolve) => setTimeout(() => resolve([]), 8_000)),
]),
getIptvSettings(config),
]);
const cats = Array.isArray(catsRaw) ? catsRaw : [];
const streams = Array.isArray(streamsRaw) ? streamsRaw : [];
const counts = new Map<string, number>();
for (const s of streams) {
const key = normalizeIptvCategoryId(s.category_id);
if (!key) continue;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
let categories = cats
.map((c) => {
const id = normalizeIptvCategoryId(c.category_id);
const rawName = String(c.category_name ?? "");
return {
id,
name: friendlyCategoryName(rawName),
rawName,
channelCount: counts.get(id) ?? 0,
};
})
.filter((c) => c.id.length > 0 && !isAggregateCategoryName(c.rawName));
if (categories.length === 0 && counts.size > 0) {
categories = [...counts.entries()].map(([id, channelCount]) => ({
id,
name: `Groep ${id}`,
rawName: `Groep ${id}`,
channelCount,
}));
}
categories = applyIptvCategoryOrder(categories, iptvPrefs.categoryOrder);
return categories.map(({ id, name, channelCount }) => ({ id, name, channelCount }));
}
/**
* Alleen pure verzamelbakken (Alles / All), géén landpakketten zoals "NL | ALL".
* Match op ruwe naam (zonder landcode te strippen).
@ -151,7 +214,7 @@ export async function getViewerIptvChannels(
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
await applyIptvEpgUrlsToRuntime(config);
let streams =
!categoryId || categoryId === "__all__"
@ -237,7 +300,7 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
if (!xt) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
await applyIptvEpgUrlsToRuntime(config);
const streams = await xt.client.getLiveStreams();
const ch = streams.find((s) => s.stream_id === streamId);
@ -675,11 +738,11 @@ async function fetchNowNextTitlesParallel(
*/
export function startIptvEpgWarmup(config: Config) {
const INTERVAL_MS = 15 * 60_000;
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
void applyIptvEpgUrlsToRuntime(config);
const tick = async () => {
try {
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
await applyIptvEpgUrlsToRuntime(config);
const lines = await prisma.iptvLine.findMany({
where: {
enableLive: true,