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 { 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 "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" && (
|
||||
<section className="stack-section">
|
||||
<div className="section-head">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
})
|
||||
: Promise.resolve(""),
|
||||
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>();
|
||||
})
|
||||
);
|
||||
|
||||
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>();
|
||||
|
|
|
|||
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,
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]) => ({
|
||||
id,
|
||||
name: `Groep ${id}`,
|
||||
rawName: `Groep ${id}`,
|
||||
channelCount,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "nl"));
|
||||
categories = [...counts.entries()].map(([id, channelCount]) => ({
|
||||
id,
|
||||
name: `Groep ${id}`,
|
||||
rawName: `Groep ${id}`,
|
||||
channelCount,
|
||||
}));
|
||||
}
|
||||
|
||||
// Één eigen verzamelknop
|
||||
categories.unshift({
|
||||
id: "__all__",
|
||||
name: "Alle zenders",
|
||||
rawName: "Alle zenders",
|
||||
channelCount: streams.length,
|
||||
});
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue