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:
parent
1834d42bd5
commit
4c91b692d2
6 changed files with 134 additions and 41 deletions
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
28
apps/master-api/src/viewer/iptv-categories.test.ts
Normal file
28
apps/master-api/src/viewer/iptv-categories.test.ts
Normal 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");
|
||||
|
|
@ -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". */
|
||||
|
|
|
|||
Loading…
Reference in a new issue