Restore IPTV categories; only drop provider aggregate folders.

Keep one Alle zenders entry, backfill per-category counts when the bulk stream list is incomplete, and do not block the linked IPTV line on lastError.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-07 19:20:31 +02:00
parent a0311c031e
commit 318f613b40
2 changed files with 81 additions and 31 deletions

View file

@ -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()
// Geen synthetische "Alles"-chip; API heeft al "Alle zenders" e.d. // Alleen provider-dubbel "Alles/All" weg; behoud onze "__all__" (Alle zenders)
categories = raw.filter { cat in categories = raw.filter { cat in
if cat.id == "__all__" { return true }
let n = cat.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let n = cat.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return n != "alles" && n != "all" return n != "alles" && n != "all" && n != "alle"
} }
let saved = UserDefaults.standard.string(forKey: Self.lastCategoryKey) let saved = UserDefaults.standard.string(forKey: Self.lastCategoryKey)

View file

@ -26,19 +26,20 @@ export async function resolveViewerIptvToken(viewerId: string): Promise<string |
}); });
if (!viewer?.enabled || !viewer.appAccess || !viewer.iptvAccess) return null; if (!viewer?.enabled || !viewer.appAccess || !viewer.iptvAccess) return null;
// Gekoppelde lijn altijd proberen — lastError mag TV niet stilleggen
if (viewer.iptvAddonTokenId) { if (viewer.iptvAddonTokenId) {
const linked = await prisma.addonToken.findFirst({ const linked = await prisma.addonToken.findFirst({
where: { where: {
id: viewer.iptvAddonTokenId, id: viewer.iptvAddonTokenId,
revoked: false, revoked: false,
iptvLine: { enableLive: true, lastError: null }, iptvLine: { enableLive: true },
}, },
select: { id: true }, select: { id: true },
}); });
if (linked) return linked.id; if (linked) return linked.id;
} }
const fallback = await prisma.iptvLine.findFirst({ const fallbackOk = await prisma.iptvLine.findFirst({
where: { where: {
enableLive: true, enableLive: true,
lastError: null, lastError: null,
@ -47,7 +48,17 @@ export async function resolveViewerIptvToken(viewerId: string): Promise<string |
select: { addonTokenId: true }, select: { addonTokenId: true },
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
}); });
return fallback?.addonTokenId ?? null; if (fallbackOk) return fallbackOk.addonTokenId;
const fallbackAny = await prisma.iptvLine.findFirst({
where: {
enableLive: true,
addonToken: { revoked: false },
},
select: { addonTokenId: true },
orderBy: { updatedAt: "desc" },
});
return fallbackAny?.addonTokenId ?? null;
} }
export async function getViewerIptvStatus(viewerId: string, config: Config) { export async function getViewerIptvStatus(viewerId: string, config: Config) {
@ -83,39 +94,65 @@ export async function getViewerIptvCategories(viewerId: string, config: Config)
counts.set(key, (counts.get(key) ?? 0) + 1); counts.set(key, (counts.get(key) ?? 0) + 1);
} }
// Geen filter op channelCount>0: de volledige get_live_streams-lijst is soms // Als de bulk-lijst incompleet is: per categorie bijtellen (gecached in XtreamClient)
// incompleet of category_id-format wijkt af, waardoor echte categorieën verdwenen. const idsNeedingCount = cats
const categories = cats .map((c) => normalizeIptvCategoryId(c.category_id))
.map((c) => { .filter((id) => id && (counts.get(id) ?? 0) === 0);
const id = normalizeIptvCategoryId(c.category_id); if (idsNeedingCount.length > 0) {
return { await mapPool(idsNeedingCount, 8, async (id) => {
id, const list = await xt.client.getLiveStreams(id).catch(() => null);
name: friendlyCategoryName(c.category_name), if (Array.isArray(list) && list.length > 0) {
rawName: c.category_name, counts.set(id, list.length);
channelCount: counts.get(id) ?? 0,
};
})
.filter((c) => c.id.length > 0)
.sort((a, b) => {
// Categorieën mét getelde zenders eerst, daarna alfabetisch
if ((a.channelCount > 0) !== (b.channelCount > 0)) {
return a.channelCount > 0 ? -1 : 1;
} }
return a.name.localeCompare(b.name, "nl");
});
if (streams.length > 0) {
categories.unshift({
id: "__all__",
name: "Alle zenders",
rawName: "Alle zenders",
channelCount: streams.length,
}); });
} }
const categories = cats
.map((c) => {
const id = normalizeIptvCategoryId(c.category_id);
const rawName = c.category_name ?? "";
return {
id,
name: friendlyCategoryName(rawName),
rawName,
channelCount: counts.get(id) ?? 0,
};
})
// Alleen lege ids + provider-verzamelcategorieën weg (niet echte genre-planken)
.filter((c) => c.id.length > 0 && !isAggregateCategoryName(c.rawName))
.sort((a, b) => a.name.localeCompare(b.name, "nl"));
// Één verzamelknop van ons — niet van de IPTV-provider
const totalChannels =
streams.length > 0
? streams.length
: [...counts.values()].reduce((a, b) => a + b, 0);
categories.unshift({
id: "__all__",
name: "Alle zenders",
rawName: "Alle zenders",
channelCount: totalChannels,
});
return { categories }; return { categories };
} }
/** Alleen echte verzamelbakken (Alles / All / Alle zenders), geen Sport/Films/etc. */
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"
);
}
/** Xtream geeft category_id soms als number, "12" of "12.0". */ /** Xtream geeft category_id soms als number, "12" of "12.0". */
export function normalizeIptvCategoryId(raw: unknown): string { export function normalizeIptvCategoryId(raw: unknown): string {
if (raw == null) return ""; if (raw == null) return "";
@ -125,6 +162,18 @@ export function normalizeIptvCategoryId(raw: unknown): string {
return s; return s;
} }
async function mapPool<T>(items: T[], concurrency: number, fn: (item: T) => Promise<void>) {
const queue = [...items];
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
while (queue.length > 0) {
const item = queue.shift();
if (item === undefined) return;
await fn(item);
}
});
await Promise.all(workers);
}
function sortChannels(streams: XtreamLiveStream[]): XtreamLiveStream[] { function sortChannels(streams: XtreamLiveStream[]): XtreamLiveStream[] {
return [...streams].sort((a, b) => (a.num ?? a.stream_id) - (b.num ?? b.stream_id)); return [...streams].sort((a, b) => (a.num ?? a.stream_id) - (b.num ?? b.stream_id));
} }