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:
parent
18bcf43c14
commit
0c15d61291
7 changed files with 528 additions and 45 deletions
|
|
@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Nav, useAuth } from "@/components/Nav";
|
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 {
|
function parseTab(v: string | null): SettingsTab {
|
||||||
if (
|
if (
|
||||||
|
|
@ -12,7 +12,8 @@ function parseTab(v: string | null): SettingsTab {
|
||||||
v === "download-station" ||
|
v === "download-station" ||
|
||||||
v === "opensubtitles" ||
|
v === "opensubtitles" ||
|
||||||
v === "shelves" ||
|
v === "shelves" ||
|
||||||
v === "google-play"
|
v === "google-play" ||
|
||||||
|
v === "tv"
|
||||||
) {
|
) {
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +58,7 @@ interface DsConfig {
|
||||||
const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [
|
const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [
|
||||||
{ id: "admin", label: "Admin", short: "Admin" },
|
{ id: "admin", label: "Admin", short: "Admin" },
|
||||||
{ id: "shelves", label: "Planken", short: "Planken" },
|
{ id: "shelves", label: "Planken", short: "Planken" },
|
||||||
|
{ id: "tv", label: "TV", short: "TV" },
|
||||||
{ id: "download-station", label: "Download Station", short: "DS" },
|
{ id: "download-station", label: "Download Station", short: "DS" },
|
||||||
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
|
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
|
||||||
{ id: "google-play", label: "Google Play", short: "Play" },
|
{ id: "google-play", label: "Google Play", short: "Play" },
|
||||||
|
|
@ -141,6 +143,15 @@ export default function SettingsPage() {
|
||||||
const [gpMsg, setGpMsg] = useState<string | null>(null);
|
const [gpMsg, setGpMsg] = useState<string | null>(null);
|
||||||
const [gpErr, setGpErr] = 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(() => {
|
const loadShelves = useCallback(() => {
|
||||||
fetch("/api/v1/admin/library/shelves", { credentials: "include" })
|
fetch("/api/v1/admin/library/shelves", { credentials: "include" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
|
|
@ -204,11 +215,26 @@ export default function SettingsPage() {
|
||||||
.catch(() => undefined);
|
.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(() => {
|
useEffect(() => {
|
||||||
loadShelves();
|
loadShelves();
|
||||||
loadDs();
|
loadDs();
|
||||||
loadOpenSubtitles();
|
loadOpenSubtitles();
|
||||||
loadGooglePlay();
|
loadGooglePlay();
|
||||||
|
loadTvSettings();
|
||||||
fetch("/api/v1/auth/me", { credentials: "include" })
|
fetch("/api/v1/auth/me", { credentials: "include" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setAdminEmail(d.user?.email ?? ""))
|
.then((d) => setAdminEmail(d.user?.email ?? ""))
|
||||||
|
|
@ -221,7 +247,7 @@ export default function SettingsPage() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay]);
|
}, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay, loadTvSettings]);
|
||||||
|
|
||||||
async function saveInstallPageEnabled(enabled: boolean) {
|
async function saveInstallPageEnabled(enabled: boolean) {
|
||||||
setInstallPageBusy(true);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Nav />
|
<Nav />
|
||||||
|
|
@ -710,6 +785,110 @@ export default function SettingsPage() {
|
||||||
</section>
|
</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 "Alle zenders" 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'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 "Alle zenders" 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" && (
|
{tab === "download-station" && (
|
||||||
<section className="stack-section">
|
<section className="stack-section">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ NODE_HEARTBEAT_INTERVAL_SECONDS=30
|
||||||
NODE_OFFLINE_THRESHOLD_SECONDS=90
|
NODE_OFFLINE_THRESHOLD_SECONDS=90
|
||||||
HIDE_UNAVAILABLE_TITLES=true
|
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
|
IPTV_EPG_XML_URL=https://epg.faabserver.nl/epg/odido.xml
|
||||||
|
|
||||||
# Google Play Closed Testing (optioneel — zie GOOGLE_PLAY_ACCESS_SETUP.md)
|
# Google Play Closed Testing (optioneel — zie GOOGLE_PLAY_ACCESS_SETUP.md)
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@ import { nodeConnectionManager } from "./websocket/manager";
|
||||||
import { MetadataService } from "./metadata/service";
|
import { MetadataService } from "./metadata/service";
|
||||||
import { toErrorResponse } from "./security/errors";
|
import { toErrorResponse } from "./security/errors";
|
||||||
import { startIptvEpgWarmup } from "./viewer/iptv-client";
|
import { startIptvEpgWarmup } from "./viewer/iptv-client";
|
||||||
import { setExternalEpgXmlUrl } from "./iptv/xtream";
|
import { applyIptvEpgUrlsToRuntime } from "./settings/iptv";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
|
await applyIptvEpgUrlsToRuntime(config);
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
|
|
|
||||||
|
|
@ -98,15 +98,31 @@ export type XmltvNowNext = {
|
||||||
next: XmltvProgram | null;
|
next: XmltvProgram | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Optionele externe rijke feed (icons/desc), gezet vanuit config. */
|
/** Optionele externe rijke feeds (icons/desc), gezet vanuit admin/config. */
|
||||||
let externalEpgXmlUrl: string | null = null;
|
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) {
|
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 {
|
export function getExternalEpgXmlUrl(): string | null {
|
||||||
return externalEpgXmlUrl;
|
return externalEpgXmlUrls[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CacheEntry<T> = { at: number; data: T };
|
type CacheEntry<T> = { at: number; data: T };
|
||||||
|
|
@ -360,7 +376,7 @@ export class XtreamClient {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rijke gids per XMLTV-channel id: now/next + 24u schedule.
|
* 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>> {
|
async getXmltvGuidesByChannelId(ttlMs = 20 * 60_000): Promise<Map<string, XmltvChannelGuide>> {
|
||||||
const cacheKey = "xmltv:guides";
|
const cacheKey = "xmltv:guides";
|
||||||
|
|
@ -374,27 +390,34 @@ export class XtreamClient {
|
||||||
`${this.baseUrl}/xmltv.php?username=${user}&password=${pass}&type=xml`,
|
`${this.baseUrl}/xmltv.php?username=${user}&password=${pass}&type=xml`,
|
||||||
];
|
];
|
||||||
|
|
||||||
const [externalXml, xuiXml] = await Promise.all([
|
const externalFetches = externalEpgXmlUrls.map((url) =>
|
||||||
externalEpgXmlUrl
|
fetchXmltvText([url])
|
||||||
? fetchXmltvText([externalEpgXmlUrl]).catch((err) => {
|
.then((xml) => parseXmltvGuides(xml))
|
||||||
console.warn("[iptv-epg] external xmltv:", err);
|
.catch((err) => {
|
||||||
return "";
|
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) => {
|
fetchXmltvText(xuiUrls).catch((err) => {
|
||||||
console.warn("[iptv-epg] xui xmltv:", err);
|
console.warn("[iptv-epg] xui xmltv:", err);
|
||||||
return "";
|
return "";
|
||||||
}),
|
}),
|
||||||
|
...externalFetches,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!externalXml && !xuiXml) {
|
let external = new Map<string, XmltvChannelGuide>();
|
||||||
throw new Error("XMLTV niet beschikbaar");
|
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 xui = xuiXml ? parseXmltvGuides(xuiXml) : new Map<string, XmltvChannelGuide>();
|
||||||
const map = mergeXmltvGuides(xui, external);
|
const map = mergeXmltvGuides(xui, external);
|
||||||
|
|
||||||
|
if (map.size === 0) {
|
||||||
|
throw new Error("XMLTV niet beschikbaar");
|
||||||
|
}
|
||||||
|
|
||||||
this.cacheSet(cacheKey, map);
|
this.cacheSet(cacheKey, map);
|
||||||
// Houd thin now/next-cache synchroon
|
// Houd thin now/next-cache synchroon
|
||||||
const thin = new Map<string, XmltvNowNext>();
|
const thin = new Map<string, XmltvNowNext>();
|
||||||
|
|
|
||||||
196
apps/master-api/src/settings/iptv.ts
Normal file
196
apps/master-api/src/settings/iptv.ts
Normal 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];
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
getGooglePlaySettingsPublic,
|
getGooglePlaySettingsPublic,
|
||||||
upsertGooglePlaySettings,
|
upsertGooglePlaySettings,
|
||||||
} from "./google-play";
|
} from "./google-play";
|
||||||
|
import { getIptvSettings, upsertIptvSettings } from "./iptv";
|
||||||
|
import { listIptvCategoriesForAdmin } from "../viewer/iptv-client";
|
||||||
import type { GooglePlayAccessService } from "../google-play/service";
|
import type { GooglePlayAccessService } from "../google-play/service";
|
||||||
|
|
||||||
export function registerSettingsRoutes(
|
export function registerSettingsRoutes(
|
||||||
|
|
@ -65,4 +67,23 @@ export function registerSettingsRoutes(
|
||||||
googlePlay?.invalidateClient();
|
googlePlay?.invalidateClient();
|
||||||
return { settings };
|
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 };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { loadXtreamByLineId, loadXtreamForViewer } from "../iptv/service";
|
||||||
import {
|
import {
|
||||||
decodeEpgText,
|
decodeEpgText,
|
||||||
epgToIso,
|
epgToIso,
|
||||||
setExternalEpgXmlUrl,
|
|
||||||
type XtreamClient,
|
type XtreamClient,
|
||||||
type XtreamEpgListing,
|
type XtreamEpgListing,
|
||||||
type XtreamLiveStream,
|
type XtreamLiveStream,
|
||||||
|
|
@ -13,6 +12,11 @@ import {
|
||||||
type XmltvNowNext,
|
type XmltvNowNext,
|
||||||
type XmltvProgram,
|
type XmltvProgram,
|
||||||
} from "../iptv/xtream";
|
} from "../iptv/xtream";
|
||||||
|
import {
|
||||||
|
applyIptvCategoryOrder,
|
||||||
|
applyIptvEpgUrlsToRuntime,
|
||||||
|
getIptvSettings,
|
||||||
|
} from "../settings/iptv";
|
||||||
|
|
||||||
const AGGREGATE_LABELS = new Set([
|
const AGGREGATE_LABELS = new Set([
|
||||||
"alles",
|
"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);
|
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
|
// 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>>),
|
xt.client.getLiveCategories().catch(() => [] as Awaited<ReturnType<typeof xt.client.getLiveCategories>>),
|
||||||
Promise.race([
|
Promise.race([
|
||||||
xt.client.getLiveStreams().catch(() => [] as XtreamLiveStream[]),
|
xt.client.getLiveStreams().catch(() => [] as XtreamLiveStream[]),
|
||||||
new Promise<XtreamLiveStream[]>((resolve) => setTimeout(() => resolve([]), 6_000)),
|
new Promise<XtreamLiveStream[]>((resolve) => setTimeout(() => resolve([]), 6_000)),
|
||||||
]),
|
]),
|
||||||
|
getIptvSettings(config),
|
||||||
]);
|
]);
|
||||||
const cats = Array.isArray(catsRaw) ? catsRaw : [];
|
const cats = Array.isArray(catsRaw) ? catsRaw : [];
|
||||||
const streams: XtreamLiveStream[] = Array.isArray(streamsRaw) ? streamsRaw : [];
|
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
|
// Alleen pure provider-dump "Alles/All" weg — niet "NL | ALL" landpakketten
|
||||||
.filter((c) => c.id.length > 0 && !isAggregateCategoryName(c.rawName))
|
.filter((c) => c.id.length > 0 && !isAggregateCategoryName(c.rawName));
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, "nl"));
|
|
||||||
|
|
||||||
// Fallback: bouquets uit zenders afleiden als get_live_categories leeg/fout is
|
// Fallback: bouquets uit zenders afleiden als get_live_categories leeg/fout is
|
||||||
if (categories.length === 0 && counts.size > 0) {
|
if (categories.length === 0 && counts.size > 0) {
|
||||||
categories = [...counts.entries()]
|
categories = [...counts.entries()].map(([id, channelCount]) => ({
|
||||||
.map(([id, channelCount]) => ({
|
id,
|
||||||
id,
|
name: `Groep ${id}`,
|
||||||
name: `Groep ${id}`,
|
rawName: `Groep ${id}`,
|
||||||
rawName: `Groep ${id}`,
|
channelCount,
|
||||||
channelCount,
|
}));
|
||||||
}))
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, "nl"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Één eigen verzamelknop
|
categories = applyIptvCategoryOrder(categories, iptvPrefs.categoryOrder);
|
||||||
categories.unshift({
|
|
||||||
id: "__all__",
|
if (iptvPrefs.showAlleZenders) {
|
||||||
name: "Alle zenders",
|
categories.unshift({
|
||||||
rawName: "Alle zenders",
|
id: "__all__",
|
||||||
channelCount: streams.length,
|
name: "Alle zenders",
|
||||||
});
|
rawName: "Alle zenders",
|
||||||
|
channelCount: streams.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { categories };
|
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".
|
* Alleen pure verzamelbakken (Alles / All), géén landpakketten zoals "NL | ALL".
|
||||||
* Match op ruwe naam (zonder landcode te strippen).
|
* 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) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
||||||
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 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 =
|
let streams =
|
||||||
!categoryId || categoryId === "__all__"
|
!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) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
||||||
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 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 streams = await xt.client.getLiveStreams();
|
||||||
const ch = streams.find((s) => s.stream_id === streamId);
|
const ch = streams.find((s) => s.stream_id === streamId);
|
||||||
|
|
@ -675,11 +738,11 @@ async function fetchNowNextTitlesParallel(
|
||||||
*/
|
*/
|
||||||
export function startIptvEpgWarmup(config: Config) {
|
export function startIptvEpgWarmup(config: Config) {
|
||||||
const INTERVAL_MS = 15 * 60_000;
|
const INTERVAL_MS = 15 * 60_000;
|
||||||
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
|
void applyIptvEpgUrlsToRuntime(config);
|
||||||
|
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
try {
|
try {
|
||||||
setExternalEpgXmlUrl(config.IPTV_EPG_XML_URL);
|
await applyIptvEpgUrlsToRuntime(config);
|
||||||
const lines = await prisma.iptvLine.findMany({
|
const lines = await prisma.iptvLine.findMany({
|
||||||
where: {
|
where: {
|
||||||
enableLive: true,
|
enableLive: true,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue