Sort home shelves by newest first and add TMDB rematch for scanned titles.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-02 03:42:03 +02:00
parent 5749e41148
commit a6a922bf05
4 changed files with 369 additions and 100 deletions

View file

@ -17,6 +17,7 @@ interface Dashboard {
availableFiles?: number; availableFiles?: number;
moviesMissingImdb?: number; moviesMissingImdb?: number;
moviesWithImdbStreams?: number; moviesWithImdbStreams?: number;
moviesMissingTmdb?: number;
seriesMissingImdb?: number; seriesMissingImdb?: number;
seriesWithImdb?: number; seriesWithImdb?: number;
scannedFiles?: number; scannedFiles?: number;
@ -53,6 +54,8 @@ export default function DashboardPage() {
const [repairBusy, setRepairBusy] = useState(false); const [repairBusy, setRepairBusy] = useState(false);
const [dedupeBusy, setDedupeBusy] = useState(false); const [dedupeBusy, setDedupeBusy] = useState(false);
const [imdbBusy, setImdbBusy] = useState(false); const [imdbBusy, setImdbBusy] = useState(false);
const [rematchBusy, setRematchBusy] = useState(false);
const [rematchMsg, setRematchMsg] = useState("");
const load = useCallback(() => { const load = useCallback(() => {
fetch("/api/v1/admin/dashboard", { credentials: "include" }) fetch("/api/v1/admin/dashboard", { credentials: "include" })
@ -76,7 +79,7 @@ export default function DashboardPage() {
async function repairLibrary() { async function repairLibrary() {
if ( if (
!confirm( !confirm(
"Bibliotheek herstellen na pad-migratie?\n\n• Duplicaten samenvoegen naar IMDb/TMDB-records\n• Dode (unavailable) bestanden verwijderen\n• Dubbele file-rijen opruimen\n• Ontbrekende IMDb-IDs backfillen (max ~400 per run)\n\nDuurt meestal 1–3 minuten. Herhaal indien nodig." "Bibliotheek herstellen?\n\n• Duplicaten samenvoegen\n• Dode bestanden verwijderen\n• TMDB opnieuw matchen voor gescande titels\n• IMDb-IDs backfillen\n\nDuurt 1–3 minuten."
) )
) { ) {
return; return;
@ -94,10 +97,13 @@ export default function DashboardPage() {
return; return;
} }
const imdb = json.imdb ?? {}; const imdb = json.imdb ?? {};
const rematch = json.rematch ?? {};
const imdbRemaining = const imdbRemaining =
(imdb.movies?.remaining ?? 0) + (imdb.series?.remaining ?? 0); (imdb.movies?.remaining ?? 0) + (imdb.series?.remaining ?? 0);
const rematchRemaining =
(rematch.movies?.remaining ?? 0) + (rematch.series?.remaining ?? 0);
setRepairMsg( setRepairMsg(
`Klaar: ${json.moviesMerged ?? 0} film-merges, ${json.seriesMerged ?? 0} serie-merges, ${json.staleFilesRemoved ?? 0} dode files, ${json.duplicateFilesRemoved ?? 0} dubbele files · IMDb +${imdb.movies?.updated ?? 0} films, +${imdb.series?.updated ?? 0} series${imdbRemaining > 0 ? ` · nog ${imdbRemaining} IMDb open (herhaal of gebruik backfill-knop)` : ""}. Herlaad daarna de Stremio-addon.` `Klaar: ${json.moviesMerged ?? 0} film-merges, ${json.seriesMerged ?? 0} serie-merges, ${json.staleFilesRemoved ?? 0} dode files, ${json.duplicateFilesRemoved ?? 0} dubbele files · TMDB +${rematch.movies?.matched ?? 0} films, +${rematch.series?.matched ?? 0} series · IMDb +${imdb.movies?.updated ?? 0} films, +${imdb.series?.updated ?? 0} series${rematchRemaining > 0 ? ` · nog ${rematchRemaining} zonder TMDB` : ""}${imdbRemaining > 0 ? ` · nog ${imdbRemaining} zonder IMDb` : ""}.`
); );
load(); load();
} catch (err) { } catch (err) {
@ -133,7 +139,7 @@ export default function DashboardPage() {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ limit: 100 }), body: JSON.stringify({ limit: 50 }),
}); });
const json = await res.json().catch(() => ({})); const json = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
@ -163,8 +169,11 @@ export default function DashboardPage() {
return; return;
} }
if (remaining >= prevRemaining && updated === 0) { if (remaining >= prevRemaining && updated === 0) {
const failed = (movies.failed ?? 0) + (series.failed ?? 0);
setImdbMsg( setImdbMsg(
`Gestopt na batch ${batch}: films +${totalMoviesUpdated}, series +${totalSeriesUpdated} · nog ${remaining} zonder IMDb (niet beschikbaar op TMDB)` failed > 0
? `TMDB rate limit of tijdelijke fout (${failed} mislukt in batch ${batch}). Wacht 1 minuut en probeer opnieuw. Nog ${remaining} open.`
: `Gestopt na batch ${batch}: nog ${remaining} zonder IMDb op TMDB (vaak nieuwe 2026-titels). Films +${totalMoviesUpdated}, series +${totalSeriesUpdated}.`
); );
load(); load();
return; return;
@ -185,6 +194,61 @@ export default function DashboardPage() {
} }
} }
async function rematchMetadata() {
if (
!confirm(
"Gescande titels zonder TMDB-metadata opnieuw matchen? Dit haalt posters/metadata op en helpt nieuwe films op de planken te tonen."
)
) {
return;
}
setRematchBusy(true);
setRematchMsg("Batch 1 bezig…");
let batch = 0;
let totalMovies = 0;
let totalSeries = 0;
try {
while (batch < 100) {
batch += 1;
setRematchMsg(`Batch ${batch} bezig…`);
const res = await fetch("/api/v1/admin/library/rematch-metadata", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ limit: 50 }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
setRematchMsg(json.error?.message ?? "Mislukt");
return;
}
const checked = (json.movies?.checked ?? 0) + (json.series?.checked ?? 0);
totalMovies += json.movies?.matched ?? 0;
totalSeries += json.series?.matched ?? 0;
const remaining = (json.movies?.remaining ?? 0) + (json.series?.remaining ?? 0);
if (remaining === 0) {
setRematchMsg(`Klaar: TMDB +${totalMovies} films, +${totalSeries} series`);
load();
return;
}
if (checked === 0) {
setRematchMsg(`Klaar: +${totalMovies} films, +${totalSeries} series · nog ${remaining} niet gevonden op TMDB`);
load();
return;
}
setRematchMsg(
`Batch ${batch}: +${json.movies?.matched ?? 0} films, +${json.series?.matched ?? 0} series · nog ${remaining} open…`
);
}
setRematchMsg(`Batchlimiet bereikt: +${totalMovies} films, +${totalSeries} series — druk opnieuw om verder te gaan`);
load();
} catch (err) {
setRematchMsg(err instanceof Error ? `Mislukt: ${err.message}` : "Mislukt");
} finally {
setRematchBusy(false);
}
}
async function dedupe() { async function dedupe() {
if ( if (
!confirm( !confirm(
@ -318,10 +382,21 @@ export default function DashboardPage() {
</button> </button>
{dedupeMsg && <p className="muted tiny">{dedupeMsg}</p>} {dedupeMsg && <p className="muted tiny">{dedupeMsg}</p>}
</div> </div>
<div className="tool-block">
<h3 className="card-subtitle">TMDB-metadata (gescande titels)</h3>
<p className="muted">
Films met stream maar zonder TMDB: {data.moviesMissingTmdb ?? "?"} — nodig voor posters en
planken.
</p>
<button type="button" onClick={rematchMetadata} disabled={rematchBusy}>
{rematchBusy ? "Bezig…" : "TMDB opnieuw matchen"}
</button>
{rematchMsg && <p className="muted tiny">{rematchMsg}</p>}
</div>
<div className="tool-block"> <div className="tool-block">
<h3 className="card-subtitle">IMDb-backfill</h3> <h3 className="card-subtitle">IMDb-backfill</h3>
<p className="muted"> <p className="muted">
Films IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "} Alleen voor titels die al TMDB hebben. Films IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "}
{data.moviesMissingImdb ?? "?"} · Series: {data.seriesWithImdb ?? "?"} / zonder{" "} {data.moviesMissingImdb ?? "?"} · Series: {data.seriesWithImdb ?? "?"} / zonder{" "}
{data.seriesMissingImdb ?? "?"} {data.seriesMissingImdb ?? "?"}
</p> </p>

View file

@ -29,6 +29,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
availableFileCount, availableFileCount,
moviesMissingImdb, moviesMissingImdb,
moviesWithImdbStreams, moviesWithImdbStreams,
moviesMissingTmdb,
seriesMissingImdb, seriesMissingImdb,
seriesWithImdb, seriesWithImdb,
] = await Promise.all([ ] = await Promise.all([
@ -71,6 +72,9 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
prisma.movie.count({ prisma.movie.count({
where: { imdbId: { not: null }, mediaFiles: { some: { available: true } } }, where: { imdbId: { not: null }, mediaFiles: { some: { available: true } } },
}), }),
prisma.movie.count({
where: { tmdbId: null, mediaFiles: { some: { available: true } } },
}),
prisma.series.count({ prisma.series.count({
where: { imdbId: null, tmdbId: { not: null } }, where: { imdbId: null, tmdbId: { not: null } },
}), }),
@ -111,6 +115,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
availableFiles: availableFileCount, availableFiles: availableFileCount,
moviesMissingImdb, moviesMissingImdb,
moviesWithImdbStreams, moviesWithImdbStreams,
moviesMissingTmdb,
seriesMissingImdb, seriesMissingImdb,
seriesWithImdb, seriesWithImdb,
scannedFiles, scannedFiles,
@ -519,8 +524,18 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
const limit = const limit =
typeof body.limit === "number" && body.limit > 0 typeof body.limit === "number" && body.limit > 0
? Math.min(Math.floor(body.limit), 500) ? Math.min(Math.floor(body.limit), 500)
: 50;
const result = await metadata.backfillAllImdbIds({ delayMs: 200, limit });
return result;
});
app.post("/api/v1/admin/library/rematch-metadata", { preHandler: requireAdmin }, async (request) => {
const body = (request.body ?? {}) as { limit?: number };
const limit =
typeof body.limit === "number" && body.limit > 0
? Math.min(Math.floor(body.limit), 300)
: 100; : 100;
const result = await metadata.backfillAllImdbIds({ delayMs: 80, limit }); const result = await metadata.rematchLibraryMetadata({ delayMs: 120, limit });
return result; return result;
}); });

View file

@ -21,6 +21,22 @@ const TMDB_STILL_BASE = "https://image.tmdb.org/t/p/w300";
const TMDB_LOGO_BASE = "https://image.tmdb.org/t/p/w300"; const TMDB_LOGO_BASE = "https://image.tmdb.org/t/p/w300";
const TMDB_BACKDROP_BASE = "https://image.tmdb.org/t/p/original"; const TMDB_BACKDROP_BASE = "https://image.tmdb.org/t/p/original";
const TMDB_LANG = "nl-NL"; const TMDB_LANG = "nl-NL";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchTmdbJson(url: string, retries = 4): Promise<Response | null> {
for (let attempt = 0; attempt < retries; attempt++) {
const res = await fetch(url);
if (res.status === 429) {
await sleep(1000 * (attempt + 1));
continue;
}
return res;
}
return null;
}
const METAHUB_LOGO = (imdbId: string) => const METAHUB_LOGO = (imdbId: string) =>
`https://images.metahub.space/logo/medium/${imdbId}/img`; `https://images.metahub.space/logo/medium/${imdbId}/img`;
@ -885,6 +901,7 @@ export class MetadataService {
seriesMerged: number; seriesMerged: number;
staleFilesRemoved: number; staleFilesRemoved: number;
duplicateFilesRemoved: number; duplicateFilesRemoved: number;
rematch: Awaited<ReturnType<MetadataService["rematchLibraryMetadata"]>>;
imdb: Awaited<ReturnType<MetadataService["backfillAllImdbIds"]>>; imdb: Awaited<ReturnType<MetadataService["backfillAllImdbIds"]>>;
}> { }> {
const moviesMerged = await this.mergeAllDuplicateMovies(); const moviesMerged = await this.mergeAllDuplicateMovies();
@ -895,10 +912,142 @@ export class MetadataService {
const duplicateFilesRemoved = await this.removeDuplicateMediaFiles(); const duplicateFilesRemoved = await this.removeDuplicateMediaFiles();
// Cap IMDb backfill so repair finishes in reasonable time; run again if needed. const rematch = await this.rematchLibraryMetadata({ delayMs: 100, limit: 200 });
const imdb = await this.backfillAllImdbIds({ delayMs: 80, limit: 400 });
return { moviesMerged, seriesMerged, staleFilesRemoved, duplicateFilesRemoved, imdb }; // Cap IMDb backfill so repair finishes in reasonable time; run again if needed.
const imdb = await this.backfillAllImdbIds({ delayMs: 200, limit: 200 });
return { moviesMerged, seriesMerged, staleFilesRemoved, duplicateFilesRemoved, rematch, imdb };
}
/**
* Re-match library titles that have streams but no TMDB id (common after scan-only adds).
*/
async rematchLibraryMetadata(options?: {
delayMs?: number;
limit?: number;
}): Promise<{
movies: { checked: number; matched: number; remaining: number };
series: { checked: number; matched: number; remaining: number };
}> {
const delayMs = options?.delayMs ?? 120;
const limit = options?.limit && options.limit > 0 ? options.limit : 150;
if (!this.tmdbApiKey) {
const [movieRemaining, seriesRemaining] = await Promise.all([
prisma.movie.count({
where: { tmdbId: null, mediaFiles: { some: { available: true } } },
}),
prisma.series.count({
where: {
tmdbId: null,
episodes: { some: { mediaFiles: { some: { available: true } } } },
},
}),
]);
return {
movies: { checked: 0, matched: 0, remaining: movieRemaining },
series: { checked: 0, matched: 0, remaining: seriesRemaining },
};
}
const pendingMovies = await prisma.movie.findMany({
where: { tmdbId: null, mediaFiles: { some: { available: true } } },
orderBy: { updatedAt: "asc" },
take: limit,
select: { id: true, title: true, year: true },
});
let moviesMatched = 0;
for (const movie of pendingMovies) {
const tmdb = await this.searchTmdbMovie(movie.title, movie.year ?? undefined);
if (tmdb) {
const canonical = await this.upsertMovieFromTmdb(tmdb);
if (canonical.id !== movie.id) {
await prisma.mediaFile.updateMany({
where: { movieId: movie.id },
data: { movieId: canonical.id },
});
await prisma.movie.delete({ where: { id: movie.id } }).catch(() => undefined);
}
moviesMatched += 1;
} else {
await prisma.movie
.update({ where: { id: movie.id }, data: { updatedAt: new Date() } })
.catch(() => undefined);
}
if (delayMs > 0) await sleep(delayMs);
}
const pendingSeries = await prisma.series.findMany({
where: {
tmdbId: null,
episodes: { some: { mediaFiles: { some: { available: true } } } },
},
orderBy: { updatedAt: "asc" },
take: limit,
select: { id: true, title: true },
});
let seriesMatched = 0;
for (const series of pendingSeries) {
const tmdb = await this.searchTmdbSeries(series.title);
if (tmdb) {
const canonical = await prisma.series.upsert({
where: { tmdbId: tmdb.id },
create: {
title: tmdb.title,
year: tmdb.year,
overview: tmdb.overview,
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
tmdbId: tmdb.id,
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
},
update: {
title: tmdb.title,
overview: tmdb.overview,
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
year: tmdb.year,
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
},
});
if (canonical.id !== series.id) {
await this.mergeSeriesInto(canonical.id, series.id);
}
seriesMatched += 1;
} else {
await prisma.series
.update({ where: { id: series.id }, data: { updatedAt: new Date() } })
.catch(() => undefined);
}
if (delayMs > 0) await sleep(delayMs);
}
const [movieRemaining, seriesRemaining] = await Promise.all([
prisma.movie.count({
where: { tmdbId: null, mediaFiles: { some: { available: true } } },
}),
prisma.series.count({
where: {
tmdbId: null,
episodes: { some: { mediaFiles: { some: { available: true } } } },
},
}),
]);
return {
movies: {
checked: pendingMovies.length,
matched: moviesMatched,
remaining: movieRemaining,
},
series: {
checked: pendingSeries.length,
matched: seriesMatched,
remaining: seriesRemaining,
},
};
} }
private movieGroupKey(m: { imdbId: string | null; tmdbId: number | null; title: string; year: number | null }): string { private movieGroupKey(m: { imdbId: string | null; tmdbId: number | null; title: string; year: number | null }): string {
@ -1822,10 +1971,10 @@ export class MetadataService {
private async fetchTmdbMovieImdbId(tmdbId: number): Promise<string | undefined> { private async fetchTmdbMovieImdbId(tmdbId: number): Promise<string | undefined> {
if (!this.tmdbApiKey) return undefined; if (!this.tmdbApiKey) return undefined;
const params = new URLSearchParams({ api_key: this.tmdbApiKey }); const params = new URLSearchParams({ api_key: this.tmdbApiKey });
const res = await fetch( const res = await fetchTmdbJson(
`https://api.themoviedb.org/3/movie/${tmdbId}/external_ids?${params}` `https://api.themoviedb.org/3/movie/${tmdbId}/external_ids?${params}`
); );
if (!res.ok) return undefined; if (!res?.ok) return undefined;
const data = (await res.json()) as { imdb_id?: string | null }; const data = (await res.json()) as { imdb_id?: string | null };
const id = data.imdb_id?.trim(); const id = data.imdb_id?.trim();
return id && /^tt\d+$/i.test(id) ? id : undefined; return id && /^tt\d+$/i.test(id) ? id : undefined;
@ -1873,10 +2022,10 @@ export class MetadataService {
private async fetchTmdbSeriesImdbId(tmdbId: number): Promise<string | undefined> { private async fetchTmdbSeriesImdbId(tmdbId: number): Promise<string | undefined> {
if (!this.tmdbApiKey) return undefined; if (!this.tmdbApiKey) return undefined;
const params = new URLSearchParams({ api_key: this.tmdbApiKey }); const params = new URLSearchParams({ api_key: this.tmdbApiKey });
const res = await fetch( const res = await fetchTmdbJson(
`https://api.themoviedb.org/3/tv/${tmdbId}/external_ids?${params}` `https://api.themoviedb.org/3/tv/${tmdbId}/external_ids?${params}`
); );
if (!res.ok) return undefined; if (!res?.ok) return undefined;
const data = (await res.json()) as { imdb_id?: string | null }; const data = (await res.json()) as { imdb_id?: string | null };
const id = data.imdb_id?.trim(); const id = data.imdb_id?.trim();
return id && /^tt\d+$/i.test(id) ? id : undefined; return id && /^tt\d+$/i.test(id) ? id : undefined;

View file

@ -565,45 +565,45 @@ export class ViewerService {
} }
async fourKMovies(take: number) { async fourKMovies(take: number) {
const movies = await prisma.movie.findMany({ const files = await prisma.mediaFile.findMany({
where: { where: {
mediaFiles: { available: true,
some: { movieId: { not: null },
available: true, OR: [
OR: [ { resolution: { contains: "2160" } },
{ resolution: { contains: "2160" } }, { resolution: { contains: "4K", mode: "insensitive" } },
{ resolution: { contains: "4K", mode: "insensitive" } }, { resolution: { contains: "UHD", mode: "insensitive" } },
{ resolution: { contains: "UHD", mode: "insensitive" } }, ],
],
},
},
}, },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take, take: Math.max(take * 3, take),
include: { include: { movie: true },
mediaFiles: {
where: {
available: true,
OR: [
{ resolution: { contains: "2160" } },
{ resolution: { contains: "4K", mode: "insensitive" } },
{ resolution: { contains: "UHD", mode: "insensitive" } },
],
},
orderBy: { createdAt: "desc" },
take: 1,
},
},
}); });
return movies.map((m) => ({ const seen = new Set<string>();
type: "movie" as const, const out: Array<{
id: m.id, type: "movie";
title: m.title, id: string;
year: m.year, title: string;
posterUrl: m.posterUrl, year: number | null;
backdropUrl: m.backdropUrl, posterUrl: string | null;
mediaFileId: m.mediaFiles[0]?.id ?? 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;
} }
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) { async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
@ -619,66 +619,96 @@ export class ViewerService {
take: number take: number
) { ) {
if (kind === "MOVIE") { if (kind === "MOVIE") {
const where = { const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
mediaFiles: { some: { shelfId, available: true } }, SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count
}; FROM media_files mf
const [total, movies] = await Promise.all([ WHERE mf.shelf_id = ${shelfId}
prisma.movie.count({ where }), AND mf.available = true
prisma.movie.findMany({ AND mf.movie_id IS NOT NULL
where, `;
orderBy: [{ title: "asc" }, { year: "asc" }], const total = Number(countRow?.count ?? 0);
skip, const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
take, SELECT mf.movie_id
include: { FROM media_files mf
mediaFiles: { WHERE mf.shelf_id = ${shelfId}
where: { shelfId, available: true }, AND mf.available = true
orderBy: { createdAt: "desc" }, AND mf.movie_id IS NOT NULL
take: 1, GROUP BY mf.movie_id
}, ORDER BY MAX(mf.created_at) DESC
OFFSET ${skip}
LIMIT ${take}
`;
const ids = idRows.map((r) => r.movie_id);
if (!ids.length) return { total, items: [] };
const movies = await prisma.movie.findMany({
where: { id: { in: ids } },
include: {
mediaFiles: {
where: { shelfId, available: true },
orderBy: { createdAt: "desc" },
take: 1,
}, },
}), },
]); });
const byId = new Map(movies.map((m) => [m.id, m]));
return { return {
total, total,
items: movies.map((m) => ({ items: ids
type: "movie" as const, .map((id) => byId.get(id))
id: m.id, .filter((m): m is NonNullable<typeof m> => !!m)
title: m.title, .map((m) => ({
year: m.year, type: "movie" as const,
posterUrl: m.posterUrl, id: m.id,
backdropUrl: m.backdropUrl, title: m.title,
mediaFileId: m.mediaFiles[0]?.id ?? null, year: m.year,
})), posterUrl: m.posterUrl,
backdropUrl: m.backdropUrl,
mediaFileId: m.mediaFiles[0]?.id ?? null,
})),
}; };
} }
const where = { const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
episodes: { SELECT COUNT(DISTINCT e.series_id)::bigint AS count
some: { FROM media_files mf
mediaFiles: { some: { shelfId, available: true } }, INNER JOIN episodes e ON e.id = mf.episode_id
}, WHERE mf.shelf_id = ${shelfId}
}, AND mf.available = true
}; AND mf.episode_id IS NOT NULL
const [total, series] = await Promise.all([ `;
prisma.series.count({ where }), const total = Number(countRow?.count ?? 0);
prisma.series.findMany({ const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
where, SELECT e.series_id
orderBy: [{ title: "asc" }, { year: "asc" }], FROM media_files mf
skip, INNER JOIN episodes e ON e.id = mf.episode_id
take, WHERE mf.shelf_id = ${shelfId}
}), AND mf.available = true
]); AND mf.episode_id IS NOT NULL
GROUP BY e.series_id
ORDER BY MAX(mf.created_at) DESC
OFFSET ${skip}
LIMIT ${take}
`;
const ids = idRows.map((r) => r.series_id);
if (!ids.length) return { total, items: [] };
const series = await prisma.series.findMany({ where: { id: { in: ids } } });
const byId = new Map(series.map((s) => [s.id, s]));
return { return {
total, total,
items: series.map((s) => ({ items: ids
type: "series" as const, .map((id) => byId.get(id))
id: s.id, .filter((s): s is NonNullable<typeof s> => !!s)
title: s.title, .map((s) => ({
year: s.year, type: "series" as const,
posterUrl: s.posterUrl, id: s.id,
backdropUrl: s.backdropUrl, title: s.title,
mediaFileId: null as string | null, year: s.year,
})), posterUrl: s.posterUrl,
backdropUrl: s.backdropUrl,
mediaFileId: null as string | null,
})),
}; };
} }