Speed up client home API with lean queries, batch continue-watching, and short cache.

This commit is contained in:
Jos Vooges | STH 2026-09-17 04:29:40 +02:00
parent 08c7e7d574
commit da7b5df51f
6 changed files with 446 additions and 44 deletions

View file

@ -0,0 +1,12 @@
-- Composite indexes for home/catalog shelf + availability lookups
CREATE INDEX IF NOT EXISTS "media_files_available_movie_id_idx"
ON "media_files" ("available", "movie_id");
CREATE INDEX IF NOT EXISTS "media_files_available_episode_id_idx"
ON "media_files" ("available", "episode_id");
CREATE INDEX IF NOT EXISTS "media_files_available_shelf_id_movie_id_idx"
ON "media_files" ("available", "shelf_id", "movie_id");
CREATE INDEX IF NOT EXISTS "media_files_available_shelf_id_episode_id_idx"
ON "media_files" ("available", "shelf_id", "episode_id");

View file

@ -545,6 +545,10 @@ model MediaFile {
@@unique([nodeId, localFileId])
@@index([shelfId])
@@index([discoveredAt])
@@index([available, movieId])
@@index([available, episodeId])
@@index([available, shelfId, movieId])
@@index([available, shelfId, episodeId])
@@map("media_files")
}

View file

@ -1,6 +1,7 @@
import { prisma } from "../database/client";
import type { LibraryEvent } from "@media-cluster/shared-types";
import { MetadataService } from "../metadata/service";
import { scheduleHomeLibraryInvalidation } from "../viewer/home-cache";
const PRUNE_CHUNK = 500;
@ -14,6 +15,7 @@ export class LibrarySyncService {
case "FILE_UPDATED":
try {
await this.metadata.matchMediaFile(nodeId, event.file);
scheduleHomeLibraryInvalidation();
} catch (err) {
console.error(
`Library sync: failed to match ${event.file.releaseName} on node ${nodeId}:`,
@ -26,6 +28,7 @@ export class LibrarySyncService {
where: { nodeId, localFileId: event.file.localFileId },
data: { available: false },
});
scheduleHomeLibraryInvalidation();
break;
}
}
@ -97,6 +100,8 @@ export class LibrarySyncService {
return;
}
scheduleHomeLibraryInvalidation();
const keepIds = options.fileIdsSoFar;
const existing = await prisma.mediaFile.findMany({
where: { nodeId },

View file

@ -0,0 +1,69 @@
/**
* Korte in-memory cache voor GET /client/home.
* Zelfde response-shape; invalidatie bij progress/favorites/prefs/library.
*/
const HOME_TTL_MS = 90_000;
type HomeCacheEntry = {
expiresAt: number;
kidsMode: boolean;
payload: unknown;
};
const byProfile = new Map<string, HomeCacheEntry>();
let libraryGeneration = 0;
let libraryBumpTimer: ReturnType<typeof setTimeout> | null = null;
function cacheKey(profileId: string, kidsMode: boolean): string {
return `${profileId}:${kidsMode ? "1" : "0"}:g${libraryGeneration}`;
}
export function getCachedHome(profileId: string, kidsMode: boolean): unknown | null {
const key = cacheKey(profileId, kidsMode);
const hit = byProfile.get(key);
if (!hit) return null;
if (Date.now() > hit.expiresAt || hit.kidsMode !== kidsMode) {
byProfile.delete(key);
return null;
}
return hit.payload;
}
export function setCachedHome(profileId: string, kidsMode: boolean, payload: unknown): void {
const key = cacheKey(profileId, kidsMode);
byProfile.set(key, {
expiresAt: Date.now() + HOME_TTL_MS,
kidsMode,
payload,
});
// Ruim oude keys voor dit profiel op (andere generation / kidsMode).
for (const k of byProfile.keys()) {
if (k.startsWith(`${profileId}:`) && k !== key) byProfile.delete(k);
}
}
/** Progress, favorites, shelf-prefs — alleen dit profiel. */
export function invalidateHomeForProfile(profileId: string): void {
for (const k of byProfile.keys()) {
if (k.startsWith(`${profileId}:`)) byProfile.delete(k);
}
}
/** Library sync — alle home-caches (gedebounced). */
export function scheduleHomeLibraryInvalidation(): void {
if (libraryBumpTimer) return;
libraryBumpTimer = setTimeout(() => {
libraryBumpTimer = null;
libraryGeneration += 1;
byProfile.clear();
}, 2_000);
}
export function invalidateAllHomes(): void {
if (libraryBumpTimer) {
clearTimeout(libraryBumpTimer);
libraryBumpTimer = null;
}
libraryGeneration += 1;
byProfile.clear();
}

View file

@ -45,6 +45,21 @@ export function kidsMovieSql(movieIdExpr: Prisma.Sql = Prisma.sql`mf.movie_id`):
`;
}
/** Kids-filter wanneer `movies` al als alias `m` in de FROM staat. */
export function kidsMovieAliasSql(): Prisma.Sql {
const arr = Prisma.join(
blockedLower.map((g) => Prisma.sql`${g}`),
", "
);
return Prisma.sql`
AND m.adult = false
AND NOT EXISTS (
SELECT 1 FROM unnest(m.genres) AS g
WHERE lower(g) IN (${arr})
)
`;
}
/** SQL fragment: series via episode kids-safe. */
export function kidsSeriesSql(): Prisma.Sql {
const arr = Prisma.join(
@ -64,6 +79,21 @@ export function kidsSeriesSql(): Prisma.Sql {
`;
}
/** Kids-filter wanneer `series` al als alias `s` in de FROM staat. */
export function kidsSeriesAliasSql(): Prisma.Sql {
const arr = Prisma.join(
blockedLower.map((g) => Prisma.sql`${g}`),
", "
);
return Prisma.sql`
AND s.adult = false
AND NOT EXISTS (
SELECT 1 FROM unnest(s.genres) AS g
WHERE lower(g) IN (${arr})
)
`;
}
export function isKidsBlockedTitle(opts: {
adult?: boolean | null;
genres?: string[] | null;

View file

@ -20,10 +20,17 @@ import { ensureOwnerProfile } from "./profiles";
import {
kidsContentWhere,
kidsMovieSql,
kidsMovieAliasSql,
kidsSeriesSql,
kidsSeriesAliasSql,
isKidsBlockedTitle,
} from "./kids-filter";
import { notifyViewersChanged } from "./admin-events";
import {
getCachedHome,
setCachedHome,
invalidateHomeForProfile,
} from "./home-cache";
const CODE_TTL_MS = 10 * 60 * 1000;
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@ -689,6 +696,9 @@ export class ViewerService {
}
async home(profileId: string, kidsMode: boolean) {
const cached = getCachedHome(profileId, kidsMode);
if (cached) return cached;
const [shelves, prefs, continueItems, recentMovies, recentSeries, newReleases, fourK, spotlight] =
await Promise.all([
prisma.libraryShelf.findMany({
@ -697,11 +707,11 @@ export class ViewerService {
}),
prisma.viewerShelfPreference.findMany({ where: { profileId } }),
this.continueWatching(profileId, kidsMode),
this.recentMovies(48, 0, kidsMode),
this.recentSeries(36, 0, kidsMode),
this.newReleaseMovies(36, 0, kidsMode),
this.fourKMovies(36, 0, kidsMode),
this.spotlight(12, kidsMode),
this.recentMoviesFast(48, kidsMode),
this.recentSeriesFast(36, kidsMode),
this.newReleaseMoviesFast(36, kidsMode),
this.fourKMoviesFast(36, kidsMode),
this.spotlightFast(12, kidsMode),
]);
const prefMap = new Map(prefs.map((p) => [p.shelfId, p]));
@ -780,13 +790,13 @@ export class ViewerService {
});
}
// Plank-items parallel laden (volgorde van `ordered` blijft behouden via Promise.all-index).
// Home: recent in library vooraan (niet A–Z). Favorites tegelijk.
// Plank-items parallel; geen COUNT (Home toont alleen de rij).
const [shelfPayloads, favorites] = await Promise.all([
Promise.all(
ordered.map(async (shelf) => {
const page = await this.shelfItemsPage(shelf.id, shelf.kind, 0, 48, kidsMode, {
sort: "recent",
skipTotal: true,
});
return { shelf, items: page.items };
})
@ -808,12 +818,14 @@ export class ViewerService {
}
}
return {
const payload = {
continueWatching: continueItems,
spotlight,
shelves: rows,
favorites,
};
setCachedHome(profileId, kidsMode, payload);
return payload;
}
async listFavorites(profileId: string, kidsMode = false) {
@ -908,6 +920,7 @@ export class ViewerService {
create: { profileId, mediaType, mediaId: id },
update: {},
});
invalidateHomeForProfile(profileId);
return { favorited: true, items: await this.listFavorites(profileId) };
}
@ -916,6 +929,7 @@ export class ViewerService {
await prisma.viewerFavorite.deleteMany({
where: { profileId, mediaType, mediaId: id },
});
invalidateHomeForProfile(profileId);
return { favorited: false, items: await this.listFavorites(profileId) };
}
@ -1032,6 +1046,225 @@ export class ViewerService {
.map(({ addedAt: _a, ...rest }) => rest);
}
/** Home: sorteer op movies.library_added_at — geen full-library GROUP BY. */
private async recentMoviesFast(take: number, kidsMode: boolean) {
const kidsSql = kidsMode ? kidsMovieAliasSql() : Prisma.empty;
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT m.id AS movie_id
FROM movies m
WHERE EXISTS (
SELECT 1 FROM media_files mf
WHERE mf.movie_id = m.id AND mf.available = true
)
${kidsSql}
ORDER BY m.library_added_at DESC NULLS LAST, m.title ASC
LIMIT ${take}
`;
const ids = idRows.map((r) => r.movie_id);
if (!ids.length) return [];
const movies = await prisma.movie.findMany({
where: { id: { in: ids } },
include: {
mediaFiles: {
where: { available: true },
orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }],
take: 1,
},
},
});
return this.mapMovies(ids, movies);
}
private async recentSeriesFast(take: number, kidsMode: boolean) {
const kidsSql = kidsMode ? kidsSeriesAliasSql() : Prisma.empty;
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
SELECT s.id AS series_id
FROM series s
WHERE EXISTS (
SELECT 1
FROM episodes e
INNER JOIN media_files mf ON mf.episode_id = e.id AND mf.available = true
WHERE e.series_id = s.id
)
${kidsSql}
ORDER BY (
SELECT MAX(e.library_added_at)
FROM episodes e
WHERE e.series_id = s.id
) DESC NULLS LAST,
s.title ASC
LIMIT ${take}
`;
const ids = idRows.map((r) => r.series_id);
if (!ids.length) return [];
const series = await prisma.series.findMany({ where: { id: { in: ids } } });
const byId = new Map(series.map((s) => [s.id, s]));
return ids
.map((id) => byId.get(id))
.filter((s): s is NonNullable<typeof s> => !!s)
.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,
}));
}
private async newReleaseMoviesFast(take: number, kidsMode: boolean) {
const page = await this.moviesByReleaseDate(0, take, { kidsMode, cap: take });
return page.items;
}
private async fourKMoviesFast(take: number, kidsMode: boolean) {
const kidsSql = kidsMode ? kidsMovieSql() : Prisma.empty;
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id
FROM media_files mf
WHERE mf.available = true
AND mf.movie_id IS NOT NULL
AND (
mf.resolution ILIKE '%2160%'
OR mf.resolution ILIKE '%4K%'
OR mf.resolution ILIKE '%UHD%'
)
${kidsSql}
GROUP BY mf.movie_id
ORDER BY MAX(mf.discovered_at) DESC
LIMIT ${take}
`;
const ids = idRows.map((r) => r.movie_id);
if (!ids.length) return [];
const movies = await prisma.movie.findMany({
where: { id: { in: ids } },
include: {
mediaFiles: {
where: {
available: true,
OR: [
{ resolution: { contains: "2160" } },
{ resolution: { contains: "4K", mode: "insensitive" } },
{ resolution: { contains: "UHD", mode: "insensitive" } },
],
},
orderBy: [{ discoveredAt: "desc" }, { lastSeen: "desc" }],
take: 1,
},
},
});
return this.mapMovies(ids, movies);
}
private async spotlightFast(take: number, kidsMode: boolean) {
const kidsMovie = kidsMode ? kidsMovieAliasSql() : Prisma.empty;
const kidsSeries = kidsMode ? kidsSeriesAliasSql() : Prisma.empty;
const [movieIds, seriesIds] = await Promise.all([
prisma.$queryRaw<Array<{ movie_id: string; added_at: Date | null }>>`
SELECT m.id AS movie_id, m.library_added_at AS added_at
FROM movies m
WHERE EXISTS (
SELECT 1 FROM media_files mf
WHERE mf.movie_id = m.id AND mf.available = true
)
${kidsMovie}
ORDER BY m.library_added_at DESC NULLS LAST
LIMIT ${take}
`,
prisma.$queryRaw<Array<{ series_id: string; added_at: Date | null }>>`
SELECT s.id AS series_id,
(
SELECT MAX(e.library_added_at)
FROM episodes e
WHERE e.series_id = s.id
) AS added_at
FROM series s
WHERE EXISTS (
SELECT 1
FROM episodes e
INNER JOIN media_files mf ON mf.episode_id = e.id AND mf.available = true
WHERE e.series_id = s.id
)
${kidsSeries}
ORDER BY added_at DESC NULLS LAST
LIMIT ${take}
`,
]);
type Spot = {
type: "movie" | "series";
id: string;
title: string;
year: number | null;
posterUrl: string | null;
backdropUrl: string | null;
logoUrl: string | null;
mediaFileId: string | null;
addedAt: number;
badge: string;
};
const spots: Spot[] = [];
if (movieIds.length) {
const movies = await prisma.movie.findMany({
where: { id: { in: movieIds.map((r) => r.movie_id) } },
include: {
mediaFiles: {
where: { available: true },
orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }],
take: 1,
},
},
});
const byId = new Map(movies.map((m) => [m.id, m]));
for (const row of movieIds) {
const m = byId.get(row.movie_id);
if (!m) continue;
spots.push({
type: "movie",
id: m.id,
title: m.title,
year: m.year,
posterUrl: m.posterUrl,
backdropUrl: m.backdropUrl,
logoUrl: m.logoUrl,
mediaFileId: m.mediaFiles[0]?.id ?? null,
addedAt: row.added_at?.getTime() ?? 0,
badge: "Nieuw",
});
}
}
if (seriesIds.length) {
const series = await prisma.series.findMany({
where: { id: { in: seriesIds.map((r) => r.series_id) } },
});
const byId = new Map(series.map((s) => [s.id, s]));
for (const row of seriesIds) {
const s = byId.get(row.series_id);
if (!s) continue;
spots.push({
type: "series",
id: s.id,
title: s.title,
year: s.year,
posterUrl: s.posterUrl,
backdropUrl: s.backdropUrl,
logoUrl: s.logoUrl,
mediaFileId: null,
addedAt: row.added_at?.getTime() ?? 0,
badge: "Nieuwe aflevering",
});
}
}
return spots
.sort((a, b) => b.addedAt - a.addedAt)
.slice(0, take)
.map(({ addedAt: _a, ...rest }) => rest);
}
async recentMovies(take: number, skip = 0, kidsMode = false) {
const page = await this.moviesByRecency(skip, take, Prisma.empty, {
cap: RECENT_CATALOG_CAP,
@ -1437,12 +1670,13 @@ export class ViewerService {
skip: number,
take: number,
kidsMode = false,
opts: { sort?: "title" | "recent"; letter?: string } = {}
opts: { sort?: "title" | "recent"; letter?: string; skipTotal?: boolean } = {}
) {
const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty;
const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty;
const sort = opts.sort ?? "title";
const letter = opts.letter?.trim().toUpperCase();
const skipTotal = opts.skipTotal === true;
if (kind === "MOVIE") {
let offset = Math.max(0, skip);
@ -1466,6 +1700,8 @@ export class ViewerService {
offset = 0;
}
let total = 0;
if (!skipTotal) {
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count
FROM media_files mf
@ -1474,7 +1710,8 @@ export class ViewerService {
AND mf.movie_id IS NOT NULL
${kidsMovie}
`;
const total = Number(countRow?.count ?? 0);
total = Number(countRow?.count ?? 0);
}
const idRows =
sort === "title"
? await prisma.$queryRaw<Array<{ movie_id: string }>>`
@ -1505,6 +1742,7 @@ export class ViewerService {
LIMIT ${take}
`;
const ids = idRows.map((r) => r.movie_id);
if (skipTotal) total = offset + ids.length;
if (!ids.length) return { total, items: [], offset };
const movies = await prisma.movie.findMany({
@ -1558,6 +1796,8 @@ export class ViewerService {
offset = 0;
}
let total = 0;
if (!skipTotal) {
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(DISTINCT e.series_id)::bigint AS count
FROM media_files mf
@ -1567,7 +1807,8 @@ export class ViewerService {
AND mf.episode_id IS NOT NULL
${kidsSeries}
`;
const total = Number(countRow?.count ?? 0);
total = Number(countRow?.count ?? 0);
}
const idRows =
sort === "title"
? await prisma.$queryRaw<Array<{ series_id: string }>>`
@ -1599,6 +1840,7 @@ export class ViewerService {
LIMIT ${take}
`;
const ids = idRows.map((r) => r.series_id);
if (skipTotal) total = offset + ids.length;
if (!ids.length) return { total, items: [], offset };
const series = await prisma.series.findMany({ where: { id: { in: ids } } });
@ -1637,17 +1879,59 @@ export class ViewerService {
take: 24,
});
const movieIds = [
...new Set(rows.filter((p) => p.mediaType === "MOVIE" && p.movieId).map((p) => p.movieId!)),
];
const episodeIds = [
...new Set(
rows.filter((p) => p.mediaType === "EPISODE" && p.episodeId).map((p) => p.episodeId!)
),
];
const [movies, episodes, movieFiles, episodeFiles] = await Promise.all([
movieIds.length
? prisma.movie.findMany({ where: { id: { in: movieIds } } })
: Promise.resolve([]),
episodeIds.length
? prisma.episode.findMany({
where: { id: { in: episodeIds } },
include: { series: true },
})
: Promise.resolve([]),
movieIds.length
? prisma.mediaFile.findMany({
where: { movieId: { in: movieIds }, available: true },
orderBy: { updatedAt: "desc" },
select: { id: true, movieId: true },
})
: Promise.resolve([]),
episodeIds.length
? prisma.mediaFile.findMany({
where: { episodeId: { in: episodeIds }, available: true },
orderBy: { updatedAt: "desc" },
select: { id: true, episodeId: true },
})
: Promise.resolve([]),
]);
const movieById = new Map(movies.map((m) => [m.id, m]));
const episodeById = new Map(episodes.map((e) => [e.id, e]));
const fileByMovie = new Map<string, string>();
for (const f of movieFiles) {
if (f.movieId && !fileByMovie.has(f.movieId)) fileByMovie.set(f.movieId, f.id);
}
const fileByEpisode = new Map<string, string>();
for (const f of episodeFiles) {
if (f.episodeId && !fileByEpisode.has(f.episodeId)) fileByEpisode.set(f.episodeId, f.id);
}
const out = [];
const seenSeries = new Set<string>();
for (const p of rows) {
if (p.mediaType === "MOVIE" && p.movieId) {
const movie = await prisma.movie.findUnique({ where: { id: p.movieId } });
const movie = movieById.get(p.movieId);
if (!movie) continue;
if (kidsMode && isKidsBlockedTitle(movie)) continue;
const file = await prisma.mediaFile.findFirst({
where: { movieId: movie.id, available: true },
orderBy: { updatedAt: "desc" },
});
out.push({
type: "movie" as const,
id: movie.id,
@ -1655,7 +1939,7 @@ export class ViewerService {
year: movie.year,
posterUrl: movie.posterUrl,
backdropUrl: movie.backdropUrl,
mediaFileId: file?.id ?? null,
mediaFileId: fileByMovie.get(movie.id) ?? null,
positionMs: p.positionMs,
durationMs: p.durationMs,
progressId: p.id,
@ -1666,16 +1950,9 @@ export class ViewerService {
if (seenSeries.has(p.seriesId)) continue;
seenSeries.add(p.seriesId);
}
const episode = await prisma.episode.findUnique({
where: { id: p.episodeId },
include: { series: true },
});
const episode = episodeById.get(p.episodeId);
if (!episode) continue;
if (kidsMode && isKidsBlockedTitle(episode.series)) continue;
const file = await prisma.mediaFile.findFirst({
where: { episodeId: episode.id, available: true },
orderBy: { updatedAt: "desc" },
});
out.push({
type: "episode" as const,
id: episode.id,
@ -1687,7 +1964,7 @@ export class ViewerService {
year: episode.series.year,
posterUrl: episode.series.posterUrl || episode.stillUrl,
backdropUrl: episode.series.backdropUrl,
mediaFileId: file?.id ?? null,
mediaFileId: fileByEpisode.get(episode.id) ?? null,
positionMs: p.positionMs,
durationMs: p.durationMs,
progressId: p.id,
@ -1726,7 +2003,7 @@ export class ViewerService {
body.completed === false
? false
: autoComplete || existingMovie?.completed === true;
return prisma.watchProgress.upsert({
const movieRow = await prisma.watchProgress.upsert({
where: {
profileId_movieId: { profileId, movieId: body.movieId },
},
@ -1752,6 +2029,8 @@ export class ViewerService {
: {}),
},
});
invalidateHomeForProfile(profileId);
return movieRow;
}
if (!body.episodeId) throw new AppError("INVALID_REQUEST", "episodeId verplicht", 400);
@ -1813,6 +2092,7 @@ export class ViewerService {
if (autoComplete) {
await this.seedNextEpisodeContinue(profileId, body.episodeId);
}
invalidateHomeForProfile(profileId);
return row;
}
@ -1921,6 +2201,7 @@ export class ViewerService {
where: { id: progressId },
data: { hiddenFromContinue: true },
});
invalidateHomeForProfile(profileId);
return { ok: true };
}
@ -2200,6 +2481,7 @@ export class ViewerService {
},
});
}
invalidateHomeForProfile(profileId);
return this.getShelfPrefs(profileId);
}