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
|
error = nil
|
||||||
do {
|
do {
|
||||||
let raw = try await app.api.loadIptvCategories()
|
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
|
categories = raw.filter { cat in
|
||||||
if cat.id == "__all__" { return true }
|
if cat.id == "__all__" { return true }
|
||||||
let n = cat.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
let n = cat.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if n.count <= 3 { return true } // "NL", "BE", …
|
||||||
return n != "alles" && n != "all" && n != "alle"
|
return n != "alles" && n != "all" && n != "alle"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/app.js",
|
"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:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate deploy",
|
"db:migrate": "prisma migrate deploy",
|
||||||
"db:migrate:dev": "prisma migrate dev",
|
"db:migrate:dev": "prisma migrate dev",
|
||||||
|
|
|
||||||
|
|
@ -907,6 +907,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
liveCategories: live.length,
|
liveCategories: live.length,
|
||||||
vodCategories: vod.length,
|
vodCategories: vod.length,
|
||||||
seriesCategories: series.length,
|
seriesCategories: series.length,
|
||||||
|
liveCategoryNames: live.slice(0, 40).map((c) => String(c.category_name ?? "")),
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Test mislukt";
|
const message = err instanceof Error ? err.message : "Test mislukt";
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,45 @@ export type XmltvNowNext = {
|
||||||
|
|
||||||
type CacheEntry<T> = { at: number; data: T };
|
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 {
|
function normalizeBaseUrl(url: string): string {
|
||||||
let u = url.trim().replace(/\/+$/, "");
|
let u = url.trim().replace(/\/+$/, "");
|
||||||
u = u.replace(/\/player_api\.php$/i, "");
|
u = u.replace(/\/player_api\.php$/i, "");
|
||||||
|
|
@ -158,21 +197,19 @@ export class XtreamClient {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Xtream API HTTP ${res.status}`);
|
throw new Error(`Xtream API HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const data = (await res.json()) as T;
|
const data = (await res.json()) as unknown;
|
||||||
// Nooit een niet-array cachen voor list-actions (Xtream geeft soms auth/error-object)
|
// List-actions: coerce object-maps / wrappers naar arrays (XUI-varianten)
|
||||||
if (
|
if (typeof params.action === "string" && LIST_ACTIONS.has(params.action)) {
|
||||||
(params.action === "get_live_categories" ||
|
const list = coerceXtreamList(data);
|
||||||
params.action === "get_live_streams" ||
|
if (!Array.isArray(data) && list.length === 0 && data != null && data !== false) {
|
||||||
params.action === "get_vod_categories" ||
|
// Echt foutobject (auth error e.d.), niet cachen
|
||||||
params.action === "get_vod_streams" ||
|
throw new Error(`Xtream API gaf geen lijst terug voor ${params.action}`);
|
||||||
params.action === "get_series_categories" ||
|
}
|
||||||
params.action === "get_series") &&
|
this.cacheSet(cacheKey, list);
|
||||||
!Array.isArray(data)
|
return list as T;
|
||||||
) {
|
|
||||||
throw new Error(`Xtream API gaf geen lijst terug voor ${params.action}`);
|
|
||||||
}
|
}
|
||||||
this.cacheSet(cacheKey, data);
|
this.cacheSet(cacheKey, data);
|
||||||
return data;
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticate(): Promise<{
|
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,
|
type XmltvNowNext,
|
||||||
} from "../iptv/xtream";
|
} 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. */
|
/** Maak Xtream-categorienamen leesbaarder voor TV-gebruikers. */
|
||||||
export function friendlyCategoryName(raw: string): string {
|
export function friendlyCategoryName(raw: string): string {
|
||||||
let s = raw.trim();
|
let s = raw.trim().replace(/^[\s★☆●▪•\-–—]+/, "");
|
||||||
s = s.replace(/^[A-Z]{2,3}\s*[|·:]\s*/i, "");
|
const m = s.match(/^([A-Z]{2,3})\s*[|·:]\s*(.+)$/i);
|
||||||
s = s.replace(/^[\s★☆●▪•\-–—]+/, "");
|
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";
|
return s.trim() || raw.trim() || "Overig";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,14 +105,15 @@ export async function getViewerIptvCategories(viewerId: string, config: Config)
|
||||||
const xt = await loadXtreamForToken(tokenId, config);
|
const xt = await loadXtreamForToken(tokenId, 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 zijn leidend — nooit blokkeren op de (soms trage/incomplete) bulk-zenderlijst
|
// Categorieën + (korte) zenderlijst parallel — categorieën nooit laten falen op trage streams
|
||||||
const catsRaw = await xt.client.getLiveCategories().catch(() => null);
|
const [catsRaw, streamsRaw] = await Promise.all([
|
||||||
const cats = Array.isArray(catsRaw) ? catsRaw : [];
|
xt.client.getLiveCategories().catch(() => []),
|
||||||
|
Promise.race([
|
||||||
const streamsRaw = await Promise.race([
|
xt.client.getLiveStreams().catch(() => []),
|
||||||
xt.client.getLiveStreams().catch(() => null),
|
new Promise<unknown[]>((resolve) => setTimeout(() => resolve([]), 6_000)),
|
||||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 6_000)),
|
]),
|
||||||
]);
|
]);
|
||||||
|
const cats = Array.isArray(catsRaw) ? catsRaw : [];
|
||||||
const streams = Array.isArray(streamsRaw) ? streamsRaw : [];
|
const streams = Array.isArray(streamsRaw) ? streamsRaw : [];
|
||||||
|
|
||||||
const counts = new Map<string, number>();
|
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);
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const categories = cats
|
let categories = cats
|
||||||
.map((c) => {
|
.map((c) => {
|
||||||
const id = normalizeIptvCategoryId(c.category_id);
|
const id = normalizeIptvCategoryId(c.category_id);
|
||||||
const rawName = String(c.category_name ?? "");
|
const rawName = String(c.category_name ?? "");
|
||||||
|
|
@ -110,10 +134,22 @@ export async function getViewerIptvCategories(viewerId: string, config: Config)
|
||||||
channelCount: counts.get(id) ?? 0,
|
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))
|
.filter((c) => c.id.length > 0 && !isAggregateCategoryName(c.rawName))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, "nl"));
|
.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
|
// Één eigen verzamelknop
|
||||||
categories.unshift({
|
categories.unshift({
|
||||||
id: "__all__",
|
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.
|
* Alleen pure verzamelbakken (Alles / All), géén landpakketten zoals "NL | ALL".
|
||||||
* Exacte match ná friendly-name (landcode-prefix al gestript).
|
* Match op ruwe naam (zonder landcode te strippen).
|
||||||
*/
|
*/
|
||||||
export function isAggregateCategoryName(raw: string): boolean {
|
export function isAggregateCategoryName(raw: string): boolean {
|
||||||
const n = friendlyCategoryName(raw).toLowerCase().replace(/\s+/g, " ").trim();
|
const n = normCatLabel(String(raw ?? "").replace(/^[\s★☆●▪•\-–—]+/, ""));
|
||||||
return (
|
return AGGREGATE_LABELS.has(n);
|
||||||
n === "alles" ||
|
|
||||||
n === "all" ||
|
|
||||||
n === "alle" ||
|
|
||||||
n === "alle zenders" ||
|
|
||||||
n === "all channels" ||
|
|
||||||
n === "all channel" ||
|
|
||||||
n === "all categories" ||
|
|
||||||
n === "all cats" ||
|
|
||||||
n === "everything"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Xtream geeft category_id soms als number, "12" of "12.0". */
|
/** Xtream geeft category_id soms als number, "12" of "12.0". */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue