From 4c91b692d2b1b811f19b25ccda68bb82e75d3988 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 7 Sep 2026 23:32:13 +0200 Subject: [PATCH] Fix IPTV country ALL bouquets being treated as dump folders. Keep NL|ALL-style packs visible, only drop pure Alles/All dumps, and coerce XUI object-map category responses. Co-authored-by: Cursor --- apps/ios/VMC/UI/LiveTvScreen.swift | 3 +- apps/master-api/package.json | 2 +- apps/master-api/src/admin/routes.ts | 1 + apps/master-api/src/iptv/xtream.ts | 63 +++++++++++---- .../src/viewer/iptv-categories.test.ts | 28 +++++++ apps/master-api/src/viewer/iptv-client.ts | 78 ++++++++++++------- 6 files changed, 134 insertions(+), 41 deletions(-) create mode 100644 apps/master-api/src/viewer/iptv-categories.test.ts diff --git a/apps/ios/VMC/UI/LiveTvScreen.swift b/apps/ios/VMC/UI/LiveTvScreen.swift index b508d96..553b2c7 100644 --- a/apps/ios/VMC/UI/LiveTvScreen.swift +++ b/apps/ios/VMC/UI/LiveTvScreen.swift @@ -153,10 +153,11 @@ struct LiveTvScreen: View { error = nil do { let raw = try await app.api.loadIptvCategories() - // Alleen provider-dubbel "Alles/All" weg; behoud onze "__all__" (Alle zenders) + // Alleen pure dump "Alles/All" weg; landcodes/NL blijven (server filtert al) categories = raw.filter { cat in if cat.id == "__all__" { return true } let n = cat.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if n.count <= 3 { return true } // "NL", "BE", … return n != "alles" && n != "all" && n != "alle" } diff --git a/apps/master-api/package.json b/apps/master-api/package.json index 1dbc111..4bdf642 100644 --- a/apps/master-api/package.json +++ b/apps/master-api/package.json @@ -6,7 +6,7 @@ "dev": "tsx watch src/app.ts", "build": "tsc", "start": "node dist/app.js", - "test": "tsx src/metadata/filename-parser.test.ts && tsx src/google-play/service.test.ts", + "test": "tsx src/metadata/filename-parser.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts", "db:generate": "prisma generate", "db:migrate": "prisma migrate deploy", "db:migrate:dev": "prisma migrate dev", diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index 3f9d8ca..4b0d026 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -907,6 +907,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) liveCategories: live.length, vodCategories: vod.length, seriesCategories: series.length, + liveCategoryNames: live.slice(0, 40).map((c) => String(c.category_name ?? "")), }; } catch (err) { const message = err instanceof Error ? err.message : "Test mislukt"; diff --git a/apps/master-api/src/iptv/xtream.ts b/apps/master-api/src/iptv/xtream.ts index 1d6320d..998c89a 100644 --- a/apps/master-api/src/iptv/xtream.ts +++ b/apps/master-api/src/iptv/xtream.ts @@ -80,6 +80,45 @@ export type XmltvNowNext = { type CacheEntry = { at: number; data: T }; +const LIST_ACTIONS = new Set([ + "get_live_categories", + "get_live_streams", + "get_vod_categories", + "get_vod_streams", + "get_series_categories", + "get_series", +]); + +/** Xtream/XUI geeft soms een array, soms een object-map, soms { data: [...] }. */ +export function coerceXtreamList(data: unknown): T[] { + if (data == null || data === false) return []; + if (Array.isArray(data)) return data as T[]; + if (typeof data !== "object") return []; + + const obj = data as Record; + for (const key of ["categories", "streams", "series", "data", "results", "items"]) { + if (Array.isArray(obj[key])) return obj[key] as T[]; + } + + const entries = Object.entries(obj); + if (entries.length === 0) return []; + if (!entries.every(([, v]) => v != null && typeof v === "object")) return []; + + return entries.map(([key, value]) => { + const row = { ...(value as Record) }; + if (row.category_id == null && row.categoryId == null && /^\d+$/.test(key)) { + row.category_id = key; + } + if (row.category_name == null && typeof row.name === "string") { + row.category_name = row.name; + } + if (row.stream_id == null && row.streamId != null) { + row.stream_id = row.streamId; + } + return row as T; + }); +} + function normalizeBaseUrl(url: string): string { let u = url.trim().replace(/\/+$/, ""); u = u.replace(/\/player_api\.php$/i, ""); @@ -158,21 +197,19 @@ export class XtreamClient { if (!res.ok) { throw new Error(`Xtream API HTTP ${res.status}`); } - const data = (await res.json()) as T; - // Nooit een niet-array cachen voor list-actions (Xtream geeft soms auth/error-object) - if ( - (params.action === "get_live_categories" || - params.action === "get_live_streams" || - params.action === "get_vod_categories" || - params.action === "get_vod_streams" || - params.action === "get_series_categories" || - params.action === "get_series") && - !Array.isArray(data) - ) { - throw new Error(`Xtream API gaf geen lijst terug voor ${params.action}`); + const data = (await res.json()) as unknown; + // List-actions: coerce object-maps / wrappers naar arrays (XUI-varianten) + if (typeof params.action === "string" && LIST_ACTIONS.has(params.action)) { + const list = coerceXtreamList(data); + if (!Array.isArray(data) && list.length === 0 && data != null && data !== false) { + // Echt foutobject (auth error e.d.), niet cachen + throw new Error(`Xtream API gaf geen lijst terug voor ${params.action}`); + } + this.cacheSet(cacheKey, list); + return list as T; } this.cacheSet(cacheKey, data); - return data; + return data as T; } async authenticate(): Promise<{ diff --git a/apps/master-api/src/viewer/iptv-categories.test.ts b/apps/master-api/src/viewer/iptv-categories.test.ts new file mode 100644 index 0000000..c14620c --- /dev/null +++ b/apps/master-api/src/viewer/iptv-categories.test.ts @@ -0,0 +1,28 @@ +import { coerceXtreamList } from "../iptv/xtream"; +import { friendlyCategoryName, isAggregateCategoryName } from "./iptv-client"; + +function assert(cond: unknown, msg: string): asserts cond { + if (!cond) throw new Error(msg); +} + +assert(friendlyCategoryName("NL | ALL") === "NL", "NL | ALL → NL"); +assert(friendlyCategoryName("BE | Alles") === "BE", "BE | Alles → BE"); +assert(friendlyCategoryName("★ NL | ALL CHANNELS") === "NL", "star NL ALL CHANNELS → NL"); +assert(friendlyCategoryName("NL | Sport") === "Sport", "NL | Sport → Sport"); +assert(friendlyCategoryName("UK | Entertainment") === "Entertainment", "UK entertain"); + +assert(isAggregateCategoryName("ALLES") === true, "ALLES dump"); +assert(isAggregateCategoryName("★ ALL") === true, "star ALL dump"); +assert(isAggregateCategoryName("Alle zenders") === true, "alle zenders dump"); +assert(isAggregateCategoryName("NL | ALL") === false, "NL | ALL keep"); +assert(isAggregateCategoryName("NL | Sport") === false, "NL Sport keep"); +assert(isAggregateCategoryName("All Sports") === false, "All Sports keep"); + +const fromMap = coerceXtreamList({ + "10": { category_name: "NL | Sport" }, + "11": { category_name: "ALLES" }, +}); +assert(fromMap.length === 2, "coerce map length"); +assert(String((fromMap[0] as { category_id?: string }).category_id) === "10", "coerce id from key"); + +console.log("iptv-categories tests ok"); diff --git a/apps/master-api/src/viewer/iptv-client.ts b/apps/master-api/src/viewer/iptv-client.ts index 2c3db21..7c1b02b 100644 --- a/apps/master-api/src/viewer/iptv-client.ts +++ b/apps/master-api/src/viewer/iptv-client.ts @@ -11,11 +11,34 @@ import { type XmltvNowNext, } from "../iptv/xtream"; +const AGGREGATE_LABELS = new Set([ + "alles", + "all", + "alle", + "alle zenders", + "all channels", + "all channel", + "all categories", + "all cats", + "everything", +]); + +function normCatLabel(s: string): string { + return s.toLowerCase().replace(/\s+/g, " ").trim(); +} + /** Maak Xtream-categorienamen leesbaarder voor TV-gebruikers. */ export function friendlyCategoryName(raw: string): string { - let s = raw.trim(); - s = s.replace(/^[A-Z]{2,3}\s*[|·:]\s*/i, ""); - s = s.replace(/^[\s★☆●▪•\-–—]+/, ""); + let s = raw.trim().replace(/^[\s★☆●▪•\-–—]+/, ""); + const m = s.match(/^([A-Z]{2,3})\s*[|·:]\s*(.+)$/i); + if (m) { + const rest = m[2].trim(); + // "NL | ALL" is een landpakket — niet reduceren tot "ALL" (dat verdween daarna als verzamelbak) + if (AGGREGATE_LABELS.has(normCatLabel(rest))) { + return m[1].toUpperCase(); + } + s = rest; + } return s.trim() || raw.trim() || "Overig"; } @@ -82,14 +105,15 @@ export async function getViewerIptvCategories(viewerId: string, config: Config) const xt = await loadXtreamForToken(tokenId, config); if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403); - // Categorieën zijn leidend — nooit blokkeren op de (soms trage/incomplete) bulk-zenderlijst - const catsRaw = await xt.client.getLiveCategories().catch(() => null); - const cats = Array.isArray(catsRaw) ? catsRaw : []; - - const streamsRaw = await Promise.race([ - xt.client.getLiveStreams().catch(() => null), - new Promise((resolve) => setTimeout(() => resolve(null), 6_000)), + // Categorieën + (korte) zenderlijst parallel — categorieën nooit laten falen op trage streams + const [catsRaw, streamsRaw] = await Promise.all([ + xt.client.getLiveCategories().catch(() => []), + Promise.race([ + xt.client.getLiveStreams().catch(() => []), + new Promise((resolve) => setTimeout(() => resolve([]), 6_000)), + ]), ]); + const cats = Array.isArray(catsRaw) ? catsRaw : []; const streams = Array.isArray(streamsRaw) ? streamsRaw : []; const counts = new Map(); @@ -99,7 +123,7 @@ export async function getViewerIptvCategories(viewerId: string, config: Config) counts.set(key, (counts.get(key) ?? 0) + 1); } - const categories = cats + let categories = cats .map((c) => { const id = normalizeIptvCategoryId(c.category_id); const rawName = String(c.category_name ?? ""); @@ -110,10 +134,22 @@ export async function getViewerIptvCategories(viewerId: string, config: Config) channelCount: counts.get(id) ?? 0, }; }) - // Toon ALLE echte planken; alleen pure verzamelbakken van de provider weg + // 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")); + // 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")); + } + // Één eigen verzamelknop categories.unshift({ id: "__all__", @@ -126,22 +162,12 @@ export async function getViewerIptvCategories(viewerId: string, config: Config) } /** - * Alleen pure verzamelbakken (Alles / All), géén "All Sports", "Alle films", enz. - * Exacte match ná friendly-name (landcode-prefix al gestript). + * Alleen pure verzamelbakken (Alles / All), géén landpakketten zoals "NL | ALL". + * Match op ruwe naam (zonder landcode te strippen). */ export function isAggregateCategoryName(raw: string): boolean { - const n = friendlyCategoryName(raw).toLowerCase().replace(/\s+/g, " ").trim(); - return ( - n === "alles" || - n === "all" || - n === "alle" || - n === "alle zenders" || - n === "all channels" || - n === "all channel" || - n === "all categories" || - n === "all cats" || - n === "everything" - ); + const n = normCatLabel(String(raw ?? "").replace(/^[\s★☆●▪•\-–—]+/, "")); + return AGGREGATE_LABELS.has(n); } /** Xtream geeft category_id soms als number, "12" of "12.0". */