Fix shelf catalog pagination so browse shows all titles.

Shelf endpoints now return a real total and page by skip/take instead of capping around 60.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-01 00:10:26 +02:00
parent 38a779c5f8
commit 7e624dd452
2 changed files with 93 additions and 75 deletions

View file

@ -79,7 +79,12 @@ fun BrowseScreen(
val page = api.browse(catalogId, skip = skip, take = 60) val page = api.browse(catalogId, skip = skip, take = 60)
title = page.title.ifBlank { titleHint ?: catalogId } title = page.title.ifBlank { titleHint ?: catalogId }
total = page.total total = page.total
items = if (reset) page.items else items + page.items.filter { n -> items.none { it.id == n.id } } items = if (reset) {
page.items
} else {
val existing = items.map { "${it.type}:${it.id}" }.toSet()
items + page.items.filter { "${it.type}:${it.id}" !in existing }
}
} catch (e: Exception) { } catch (e: Exception) {
error = e.message error = e.message
} finally { } finally {

View file

@ -328,7 +328,16 @@ export class ViewerService {
title: string; title: string;
kind: "MOVIE" | "SERIES"; kind: "MOVIE" | "SERIES";
slug: string; slug: string;
items: Awaited<ReturnType<ViewerService["shelfItems"]>>; items: Array<{
type: "movie" | "series";
id: string;
title: string;
year: number | null;
posterUrl: string | null;
backdropUrl: string | null;
mediaFileId: string | null;
badge?: string;
}>;
}> = []; }> = [];
if (recentMovies.length) { if (recentMovies.length) {
@ -567,80 +576,79 @@ export class ViewerService {
} }
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) { async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
// Sorteer op laatst toegevoegd mediabestand (niet op metadata-aanmaakdatum) const page = await this.shelfItemsPage(shelfId, kind, 0, take);
if (kind === "MOVIE") { return page.items;
const files = await prisma.mediaFile.findMany({
where: { shelfId, available: true, movieId: { not: null } },
orderBy: { createdAt: "desc" },
take: Math.max(take * 4, take),
include: { movie: true },
});
const seen = new Set<string>();
const out: Array<{
type: "movie";
id: string;
title: string;
year: number | null;
posterUrl: string | null;
backdropUrl: string | null;
mediaFileId: string | null;
}> = [];
for (const f of files) {
if (!f.movieId || !f.movie || seen.has(f.movieId)) continue;
seen.add(f.movieId);
out.push({
type: "movie",
id: f.movie.id,
title: f.movie.title,
year: f.movie.year,
posterUrl: f.movie.posterUrl,
backdropUrl: f.movie.backdropUrl,
mediaFileId: f.id,
});
if (out.length >= take) break;
}
return out;
} }
const files = await prisma.mediaFile.findMany({ /** Volledige plank met echte total + skip/take (voor browse/Alles). */
where: { async shelfItemsPage(
shelfId, shelfId: string,
available: true, kind: "MOVIE" | "SERIES",
episodeId: { not: null }, skip: number,
}, take: number
orderBy: { createdAt: "desc" }, ) {
take: Math.max(take * 6, take), if (kind === "MOVIE") {
const where = {
mediaFiles: { some: { shelfId, available: true } },
};
const [total, movies] = await Promise.all([
prisma.movie.count({ where }),
prisma.movie.findMany({
where,
orderBy: [{ title: "asc" }, { year: "asc" }],
skip,
take,
include: { include: {
episode: { include: { series: true } }, mediaFiles: {
where: { shelfId, available: true },
orderBy: { createdAt: "desc" },
take: 1,
}, },
}); },
const seen = new Set<string>(); }),
const out: Array<{ ]);
type: "series"; return {
id: string; total,
title: string; items: movies.map((m) => ({
year: number | null; type: "movie" as const,
posterUrl: string | null; id: m.id,
backdropUrl: string | null; title: m.title,
mediaFileId: string | null; year: m.year,
}> = []; posterUrl: m.posterUrl,
for (const f of files) { backdropUrl: m.backdropUrl,
const series = f.episode?.series; mediaFileId: m.mediaFiles[0]?.id ?? null,
const seriesId = f.episode?.seriesId; })),
if (!series || !seriesId || seen.has(seriesId)) continue; };
seen.add(seriesId);
out.push({
type: "series",
id: series.id,
title: series.title,
year: series.year,
posterUrl: series.posterUrl,
backdropUrl: series.backdropUrl,
mediaFileId: null,
});
if (out.length >= take) break;
} }
return out;
const where = {
episodes: {
some: {
mediaFiles: { some: { shelfId, available: true } },
},
},
};
const [total, series] = await Promise.all([
prisma.series.count({ where }),
prisma.series.findMany({
where,
orderBy: [{ title: "asc" }, { year: "asc" }],
skip,
take,
}),
]);
return {
total,
items: series.map((s) => ({
type: "series" as const,
id: s.id,
title: s.title,
year: s.year,
posterUrl: s.posterUrl,
backdropUrl: s.backdropUrl,
mediaFileId: null as string | null,
})),
};
} }
async continueWatching(viewerId: string) { async continueWatching(viewerId: string) {
@ -990,13 +998,18 @@ export class ViewerService {
})) || null; })) || null;
if (!shelf) throw new AppError("NOT_FOUND", "Catalogus niet gevonden", 404); if (!shelf) throw new AppError("NOT_FOUND", "Catalogus niet gevonden", 404);
const items = await this.shelfItems(shelf.id, shelf.kind, skip + take); const page = await this.shelfItemsPage(
shelf.id,
shelf.kind as "MOVIE" | "SERIES",
skip,
take
);
return { return {
id: shelf.slug, id: shelf.slug,
title: shelf.name, title: shelf.name,
kind: shelf.kind, kind: shelf.kind,
total: items.length + skip, total: page.total,
items: items.slice(skip, skip + take), items: page.items,
}; };
} }