From da7b5df51f75569693da871667a14125bc4fab9e Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Thu, 17 Sep 2026 04:29:40 +0200 Subject: [PATCH] Speed up client home API with lean queries, batch continue-watching, and short cache. --- .../migration.sql | 12 + apps/master-api/prisma/schema.prisma | 4 + apps/master-api/src/media/sync.ts | 5 + apps/master-api/src/viewer/home-cache.ts | 69 ++++ apps/master-api/src/viewer/kids-filter.ts | 30 ++ apps/master-api/src/viewer/service.ts | 370 +++++++++++++++--- 6 files changed, 446 insertions(+), 44 deletions(-) create mode 100644 apps/master-api/prisma/migrations/20260917050000_home_query_indexes/migration.sql create mode 100644 apps/master-api/src/viewer/home-cache.ts diff --git a/apps/master-api/prisma/migrations/20260917050000_home_query_indexes/migration.sql b/apps/master-api/prisma/migrations/20260917050000_home_query_indexes/migration.sql new file mode 100644 index 0000000..af07975 --- /dev/null +++ b/apps/master-api/prisma/migrations/20260917050000_home_query_indexes/migration.sql @@ -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"); diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index 71e034f..43c8923 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -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") } diff --git a/apps/master-api/src/media/sync.ts b/apps/master-api/src/media/sync.ts index 9dbe3cd..55ba129 100644 --- a/apps/master-api/src/media/sync.ts +++ b/apps/master-api/src/media/sync.ts @@ -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 }, diff --git a/apps/master-api/src/viewer/home-cache.ts b/apps/master-api/src/viewer/home-cache.ts new file mode 100644 index 0000000..a6d9cc7 --- /dev/null +++ b/apps/master-api/src/viewer/home-cache.ts @@ -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(); +let libraryGeneration = 0; +let libraryBumpTimer: ReturnType | 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(); +} diff --git a/apps/master-api/src/viewer/kids-filter.ts b/apps/master-api/src/viewer/kids-filter.ts index efd0dda..7617742 100644 --- a/apps/master-api/src/viewer/kids-filter.ts +++ b/apps/master-api/src/viewer/kids-filter.ts @@ -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; diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 635e56b..9207b7e 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -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>` + 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>` + 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 => !!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>` + 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>` + 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>` + 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,15 +1700,18 @@ export class ViewerService { offset = 0; } - const [countRow] = await prisma.$queryRaw>` - SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count - FROM media_files mf - WHERE mf.shelf_id = ${shelfId} - AND mf.available = true - AND mf.movie_id IS NOT NULL - ${kidsMovie} - `; - const total = Number(countRow?.count ?? 0); + let total = 0; + if (!skipTotal) { + const [countRow] = await prisma.$queryRaw>` + SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count + FROM media_files mf + WHERE mf.shelf_id = ${shelfId} + AND mf.available = true + AND mf.movie_id IS NOT NULL + ${kidsMovie} + `; + total = Number(countRow?.count ?? 0); + } const idRows = sort === "title" ? await prisma.$queryRaw>` @@ -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,16 +1796,19 @@ export class ViewerService { offset = 0; } - const [countRow] = await prisma.$queryRaw>` - SELECT COUNT(DISTINCT e.series_id)::bigint AS count - FROM media_files mf - 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 - ${kidsSeries} - `; - const total = Number(countRow?.count ?? 0); + let total = 0; + if (!skipTotal) { + const [countRow] = await prisma.$queryRaw>` + SELECT COUNT(DISTINCT e.series_id)::bigint AS count + FROM media_files mf + 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 + ${kidsSeries} + `; + total = Number(countRow?.count ?? 0); + } const idRows = sort === "title" ? await prisma.$queryRaw>` @@ -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(); + for (const f of movieFiles) { + if (f.movieId && !fileByMovie.has(f.movieId)) fileByMovie.set(f.movieId, f.id); + } + const fileByEpisode = new Map(); + for (const f of episodeFiles) { + if (f.episodeId && !fileByEpisode.has(f.episodeId)) fileByEpisode.set(f.episodeId, f.id); + } + const out = []; const seenSeries = new Set(); 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); }