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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-07 23:32:13 +02:00
parent 1834d42bd5
commit 4c91b692d2
6 changed files with 134 additions and 41 deletions

View file

@ -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"
}

View file

@ -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",

View file

@ -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";

View file

@ -80,6 +80,45 @@ export type XmltvNowNext = {
type CacheEntry<T> = { 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<T = unknown>(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<string, unknown>;
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<string, unknown>) };
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<{

View file

@ -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");

View file

@ -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<null>((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<unknown[]>((resolve) => setTimeout(() => resolve([]), 6_000)),
]),
]);
const cats = Array.isArray(catsRaw) ? catsRaw : [];
const streams = Array.isArray(streamsRaw) ? streamsRaw : [];
const counts = new Map<string, number>();
@ -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". */