diff --git a/apps/master-api/prisma/migrations/20260912010000_library_added_at_release_date/migration.sql b/apps/master-api/prisma/migrations/20260912010000_library_added_at_release_date/migration.sql new file mode 100644 index 0000000..da3d6b3 --- /dev/null +++ b/apps/master-api/prisma/migrations/20260912010000_library_added_at_release_date/migration.sql @@ -0,0 +1,38 @@ +-- Cluster “date added” (title/episode) + TMDB release date for “Net uitgebracht”. + +ALTER TABLE "movies" ADD COLUMN IF NOT EXISTS "release_date" TIMESTAMP(3); +ALTER TABLE "movies" ADD COLUMN IF NOT EXISTS "library_added_at" TIMESTAMP(3); +ALTER TABLE "episodes" ADD COLUMN IF NOT EXISTS "library_added_at" TIMESTAMP(3); + +-- Prefer file mtime over scan flood: if last_seen is older than discovered_at, restore it. +UPDATE "media_files" +SET "discovered_at" = "last_seen" +WHERE "last_seen" IS NOT NULL + AND "last_seen" < "discovered_at"; + +-- First-seen in cluster = earliest file discovery for that title/episode. +UPDATE "movies" m +SET "library_added_at" = src.min_d +FROM ( + SELECT movie_id, MIN(discovered_at) AS min_d + FROM media_files + WHERE movie_id IS NOT NULL AND available = true + GROUP BY movie_id +) src +WHERE m.id = src.movie_id + AND m.library_added_at IS NULL; + +UPDATE "episodes" e +SET "library_added_at" = src.min_d +FROM ( + SELECT episode_id, MIN(discovered_at) AS min_d + FROM media_files + WHERE episode_id IS NOT NULL AND available = true + GROUP BY episode_id +) src +WHERE e.id = src.episode_id + AND e.library_added_at IS NULL; + +CREATE INDEX IF NOT EXISTS "movies_library_added_at_idx" ON "movies"("library_added_at"); +CREATE INDEX IF NOT EXISTS "movies_release_date_idx" ON "movies"("release_date"); +CREATE INDEX IF NOT EXISTS "episodes_library_added_at_idx" ON "episodes"("library_added_at"); diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index 1604ab5..aee6a04 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -422,6 +422,13 @@ model Movie { runtime Int? imdbId String? @unique @map("imdb_id") tmdbId Int? @unique @map("tmdb_id") + /** TMDB release_date — for “Net uitgebracht”. */ + releaseDate DateTime? @map("release_date") + /** + * Eerste keer dat deze titel in het cluster beschikbaar kwam. + * Bump’t niet als een tweede node dezelfde film aanlevert. + */ + libraryAddedAt DateTime? @map("library_added_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -430,6 +437,8 @@ model Movie { subtitles LibrarySubtitle[] @@index([adult]) + @@index([libraryAddedAt]) + @@index([releaseDate]) @@map("movies") } @@ -482,6 +491,8 @@ model Episode { airDate DateTime? @map("air_date") imdbId String? @map("imdb_id") tmdbId Int? @map("tmdb_id") + /** Eerste keer dat deze aflevering in het cluster kwam (geen bump door 2e node). */ + libraryAddedAt DateTime? @map("library_added_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -491,6 +502,7 @@ model Episode { matches LibraryMatch[] @@unique([seriesId, seasonNumber, episodeNumber]) + @@index([libraryAddedAt]) @@map("episodes") } @@ -515,7 +527,7 @@ model MediaFile { path String? @map("path") available Boolean @default(true) lastSeen DateTime @default(now()) @map("last_seen") - /// Eerste keer dat dit bestand in de library kwam (wijzigt niet bij rematch/upsert). + /// Eerste indexering van dit bestand: bij create ≈ file-mtime (niet scan-now). discoveredAt DateTime @default(now()) @map("discovered_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index 4c6f693..8e903a8 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -179,6 +179,56 @@ async function main() { console.warn("Could not ensure node media path columns:", err); } + try { + await prisma.$executeRawUnsafe( + `ALTER TABLE "movies" ADD COLUMN IF NOT EXISTS "release_date" TIMESTAMP(3)` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE "movies" ADD COLUMN IF NOT EXISTS "library_added_at" TIMESTAMP(3)` + ); + await prisma.$executeRawUnsafe( + `ALTER TABLE "episodes" ADD COLUMN IF NOT EXISTS "library_added_at" TIMESTAMP(3)` + ); + await prisma.$executeRawUnsafe(` + UPDATE "media_files" + SET "discovered_at" = "last_seen" + WHERE "last_seen" IS NOT NULL AND "last_seen" < "discovered_at" + `); + await prisma.$executeRawUnsafe(` + UPDATE "movies" m + SET "library_added_at" = src.min_d + FROM ( + SELECT movie_id, MIN(discovered_at) AS min_d + FROM media_files + WHERE movie_id IS NOT NULL AND available = true + GROUP BY movie_id + ) src + WHERE m.id = src.movie_id AND m.library_added_at IS NULL + `); + await prisma.$executeRawUnsafe(` + UPDATE "episodes" e + SET "library_added_at" = src.min_d + FROM ( + SELECT episode_id, MIN(discovered_at) AS min_d + FROM media_files + WHERE episode_id IS NOT NULL AND available = true + GROUP BY episode_id + ) src + WHERE e.id = src.episode_id AND e.library_added_at IS NULL + `); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS "movies_library_added_at_idx" ON "movies"("library_added_at")` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS "movies_release_date_idx" ON "movies"("release_date")` + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS "episodes_library_added_at_idx" ON "episodes"("library_added_at")` + ); + } catch (err) { + console.warn("Could not ensure library_added_at / release_date:", err); + } + try { await prisma.$executeRawUnsafe(` DO $$ BEGIN diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index 191b029..ab6a424 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -86,8 +86,26 @@ type TmdbMeta = { logoUrl?: string; imdbId?: string; adult?: boolean; + /** YYYY-MM-DD from TMDB */ + releaseDate?: string; }; +/** File mtime → discoveredAt (no future dates; invalid → now). */ +export function discoveredAtFromFileMtime(modifiedAt: string | Date | null | undefined): Date { + const now = Date.now(); + const mtime = modifiedAt ? new Date(modifiedAt) : new Date(NaN); + if (Number.isNaN(mtime.getTime())) return new Date(now); + // >1 day in the future → treat as now (bad clocks / copy artefacts) + if (mtime.getTime() > now + 86_400_000) return new Date(now); + return mtime; +} + +function parseTmdbDate(raw: string | null | undefined): Date | null { + if (!raw?.trim()) return null; + const d = new Date(`${raw.trim()}T00:00:00.000Z`); + return Number.isNaN(d.getTime()) ? null : d; +} + /** Extra fields for Stremio meta detail pages. */ export type StremioMetaExtras = { logo?: string; @@ -341,6 +359,7 @@ export class MetadataService { const nextShelf = file.shelfId || null; const sizeBytes = BigInt(file.sizeBytes); const sizeChanged = existing.sizeBytes !== sizeBytes; + const discoveredAt = sizeChanged ? new Date() : undefined; await prisma.mediaFile.update({ where: { id: existing.id }, data: { @@ -354,9 +373,16 @@ export class MetadataService { releaseName: file.releaseName, ...(file.path ? { path: file.path } : {}), lastSeen: new Date(file.modifiedAt), - ...(sizeChanged ? { discoveredAt: new Date() } : {}), + ...(discoveredAt ? { discoveredAt } : {}), }, }); + if (discoveredAt) { + await this.ensureLibraryAddedAt({ + movieId: existing.movieId, + episodeId: existing.episodeId, + at: discoveredAt, + }); + } return existing.movieId ?? existing.episodeId; } @@ -1975,6 +2001,7 @@ export class MetadataService { /** TMDB upsert with safe IMDb linking (unique conflicts must not block file matching). */ private async upsertMovieFromTmdb(tmdb: TmdbMeta): Promise { + const releaseDate = parseTmdbDate(tmdb.releaseDate); const data = { title: tmdb.title, year: tmdb.year, @@ -1983,6 +2010,7 @@ export class MetadataService { backdropUrl: tmdb.backdropUrl, tmdbId: tmdb.id, adult: tmdb.adult === true, + ...(releaseDate ? { releaseDate } : {}), }; try { return await prisma.movie.upsert({ @@ -2014,6 +2042,29 @@ export class MetadataService { } } + /** + * Zet libraryAddedAt één keer (cluster first-seen). + * Extra node / tweede kopie bump’t niet. + */ + private async ensureLibraryAddedAt(opts: { + movieId?: string | null; + episodeId?: string | null; + at: Date; + }): Promise { + if (opts.movieId) { + await prisma.movie.updateMany({ + where: { id: opts.movieId, libraryAddedAt: null }, + data: { libraryAddedAt: opts.at }, + }); + } + if (opts.episodeId) { + await prisma.episode.updateMany({ + where: { id: opts.episodeId, libraryAddedAt: null }, + data: { libraryAddedAt: opts.at }, + }); + } + } + private async upsertMediaFile( nodeId: string, file: MediaFileInfo, @@ -2026,6 +2077,8 @@ export class MetadataService { select: { sizeBytes: true }, }); const sizeChanged = existing != null && existing.sizeBytes !== sizeBytes; + const discoveredAtCreate = discoveredAtFromFileMtime(file.modifiedAt); + const discoveredAtUpdate = sizeChanged ? new Date() : undefined; await prisma.mediaFile.upsert({ where: { @@ -2046,8 +2099,7 @@ export class MetadataService { path: file.path || null, available: true, lastSeen: mtime, - // Eerste indexering = nu (ook als bestand-mtime ouder is) - discoveredAt: new Date(), + discoveredAt: discoveredAtCreate, }, update: { ...("movieId" in refs ? { movieId: refs.movieId ?? null } : {}), @@ -2062,10 +2114,25 @@ export class MetadataService { ...(file.path ? { path: file.path } : {}), available: true, lastSeen: mtime, - // Alleen opnieuw “recent” als het bestand inhoudelijk vernieuwd is - ...(sizeChanged ? { discoveredAt: new Date() } : {}), + ...(discoveredAtUpdate ? { discoveredAt: discoveredAtUpdate } : {}), }, }); + + const at = discoveredAtUpdate ?? (existing ? null : discoveredAtCreate); + if (at) { + await this.ensureLibraryAddedAt({ + movieId: refs.movieId, + episodeId: refs.episodeId, + at, + }); + } else if (refs.movieId || refs.episodeId) { + // Existing file re-linked / rematched: still seed libraryAddedAt if missing. + await this.ensureLibraryAddedAt({ + movieId: refs.movieId, + episodeId: refs.episodeId, + at: discoveredAtCreate, + }); + } } private async findExistingMovie(title: string, year?: number) { @@ -2227,6 +2294,7 @@ export class MetadataService { : undefined, imdbId, adult: item.adult === true, + releaseDate: item.release_date || undefined, }; this.movieCache.set(tmdbId, meta); return meta; diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index a8ab64a..3d9e770 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -34,6 +34,7 @@ const BASE_CATALOGS = [ { type: "series", id: "series", name: "Series", extra: SEARCH_SKIP_EXTRAS }, { type: "movie", id: "recent-movies", name: "Recent toegevoegd", extra: SKIP_EXTRA }, { type: "series", id: "recent-series", name: "Recent toegevoegde series", extra: SKIP_EXTRA }, + { type: "movie", id: "new-releases", name: "Net uitgebracht", extra: SKIP_EXTRA }, { type: "movie", id: "4k-movies", name: "4K films", extra: SEARCH_SKIP_EXTRAS }, ]; @@ -447,11 +448,13 @@ async function getMovieCatalog( if (catalogId === "recent-movies" && !search) { const idRows = await prisma.$queryRaw>` - SELECT mf.movie_id + SELECT m.id AS movie_id FROM media_files mf + INNER JOIN movies m ON m.id = mf.movie_id WHERE mf.available = true AND mf.movie_id IS NOT NULL - GROUP BY mf.movie_id - ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC + GROUP BY m.id, m.library_added_at + ORDER BY COALESCE(m.library_added_at, MAX(mf.discovered_at)) DESC NULLS LAST, + MAX(mf.discovered_at) DESC OFFSET ${skip} LIMIT ${CATALOG_PAGE} `; @@ -465,11 +468,49 @@ async function getMovieCatalog( return ids.map((id) => byId.get(id)).filter((m): m is NonNullable => !!m); }) : []; + } else if (catalogId === "new-releases") { + if (!search) { + const idRows = await prisma.$queryRaw>` + SELECT m.id AS movie_id + FROM movies m + WHERE ( + ( + m.release_date IS NOT NULL + AND m.release_date <= CURRENT_TIMESTAMP + AND m.release_date >= (CURRENT_TIMESTAMP - interval '3 years') + ) + OR ( + m.release_date IS NULL + AND m.year IS NOT NULL + AND m.year >= (EXTRACT(YEAR FROM CURRENT_TIMESTAMP)::int - 3) + ) + ) + AND EXISTS ( + SELECT 1 FROM media_files mf + WHERE mf.movie_id = m.id AND mf.available = true + ) + ORDER BY COALESCE(m.release_date, make_date(m.year, 6, 1)) DESC NULLS LAST, + m.title ASC + OFFSET ${skip} + LIMIT ${CATALOG_PAGE} + `; + const ids = idRows.map((r) => r.movie_id); + movies = ids.length + ? await prisma.movie.findMany({ + where: { id: { in: ids } }, + include: { mediaFiles: { where: { available: true } } }, + }).then((rows) => { + const byId = new Map(rows.map((m) => [m.id, m])); + return ids.map((id) => byId.get(id)).filter((m): m is NonNullable => !!m); + }) + : []; + } } else if (catalogId === "4k-movies") { if (!search) { const idRows = await prisma.$queryRaw>` - SELECT mf.movie_id + SELECT m.id AS movie_id FROM media_files mf + INNER JOIN movies m ON m.id = mf.movie_id WHERE mf.available = true AND mf.movie_id IS NOT NULL AND ( @@ -477,8 +518,9 @@ async function getMovieCatalog( OR mf.resolution ILIKE '%4K%' OR mf.resolution ILIKE '%UHD%' ) - GROUP BY mf.movie_id - ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC + GROUP BY m.id, m.library_added_at + ORDER BY COALESCE(m.library_added_at, MAX(mf.discovered_at)) DESC NULLS LAST, + MAX(mf.discovered_at) DESC OFFSET ${skip} LIMIT ${CATALOG_PAGE} `; @@ -515,11 +557,13 @@ async function getMovieCatalog( } } else if (catalogId === "movies" && !search) { const idRows = await prisma.$queryRaw>` - SELECT mf.movie_id + SELECT m.id AS movie_id FROM media_files mf + INNER JOIN movies m ON m.id = mf.movie_id WHERE mf.available = true AND mf.movie_id IS NOT NULL - GROUP BY mf.movie_id - ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC + GROUP BY m.id, m.library_added_at + ORDER BY COALESCE(m.library_added_at, MAX(mf.discovered_at)) DESC NULLS LAST, + MAX(mf.discovered_at) DESC OFFSET ${skip} LIMIT ${CATALOG_PAGE} `; @@ -589,7 +633,8 @@ async function getSeriesCatalog( INNER JOIN episodes e ON e.id = mf.episode_id WHERE mf.available = true AND mf.episode_id IS NOT NULL GROUP BY e.series_id - ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC + ORDER BY MAX(COALESCE(e.library_added_at, mf.discovered_at)) DESC NULLS LAST, + MAX(mf.discovered_at) DESC OFFSET ${skip} LIMIT ${CATALOG_PAGE} `; @@ -607,7 +652,8 @@ async function getSeriesCatalog( INNER JOIN episodes e ON e.id = mf.episode_id WHERE mf.available = true AND mf.episode_id IS NOT NULL GROUP BY e.series_id - ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC + ORDER BY MAX(COALESCE(e.library_added_at, mf.discovered_at)) DESC NULLS LAST, + MAX(mf.discovered_at) DESC OFFSET ${skip} LIMIT ${CATALOG_PAGE} `; diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index c268790..55e4962 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -626,7 +626,7 @@ export class ViewerService { } async home(profileId: string, kidsMode: boolean) { - const [shelves, prefs, continueItems, recentMovies, recentSeries, fourK, spotlight] = + const [shelves, prefs, continueItems, recentMovies, recentSeries, newReleases, fourK, spotlight] = await Promise.all([ prisma.libraryShelf.findMany({ where: { enabled: true }, @@ -636,6 +636,7 @@ export class ViewerService { 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), ]); @@ -695,6 +696,16 @@ export class ViewerService { items: recentSeries, }); } + if (newReleases.length) { + rows.push({ + id: "system:new-releases", + title: "Net uitgebracht", + name: "Net uitgebracht", + kind: "MOVIE", + slug: "new-releases", + items: newReleases, + }); + } if (fourK.length) { rows.push({ id: "system:4k-movies", @@ -707,12 +718,14 @@ export class ViewerService { } // Plank-items parallel laden (volgorde van `ordered` blijft behouden via Promise.all-index). - // Favorites hangt niet van planken af → tegelijk ophalen i.p.v. erna. + // Home: recent in library vooraan (niet A–Z). Favorites tegelijk. const [shelfPayloads, favorites] = await Promise.all([ Promise.all( ordered.map(async (shelf) => { - const items = await this.shelfItems(shelf.id, shelf.kind, 48, kidsMode); - return { shelf, items }; + const page = await this.shelfItemsPage(shelf.id, shelf.kind, 0, 48, kidsMode, { + sort: "recent", + }); + return { shelf, items: page.items }; }) ), this.listFavorites(profileId, kidsMode), @@ -854,27 +867,30 @@ export class ViewerService { return this.addFavorite(profileId, mediaType, id); } - /** Nieuwste titels (op bestand-mtime van de node) voor de home billboard. */ + /** Nieuwste titels (cluster first-seen) voor de home billboard. */ async spotlight(take: number, kidsMode = false) { const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty; const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty; const movieIds = await prisma.$queryRaw>` - SELECT mf.movie_id, MAX(mf.discovered_at) AS added_at + SELECT m.id AS movie_id, + COALESCE(m.library_added_at, MIN(mf.discovered_at)) AS added_at FROM media_files mf + INNER JOIN movies m ON m.id = mf.movie_id WHERE mf.available = true AND mf.movie_id IS NOT NULL ${kidsMovie} - GROUP BY mf.movie_id - ORDER BY added_at DESC, MAX(mf.discovered_at) DESC + GROUP BY m.id, m.library_added_at + ORDER BY added_at DESC NULLS LAST LIMIT ${take * 2} `; const seriesIds = await prisma.$queryRaw>` - SELECT e.series_id, MAX(mf.discovered_at) AS added_at + SELECT e.series_id, + MAX(COALESCE(e.library_added_at, mf.discovered_at)) AS added_at FROM media_files mf INNER JOIN episodes e ON e.id = mf.episode_id WHERE mf.available = true AND mf.episode_id IS NOT NULL ${kidsSeries} GROUP BY e.series_id - ORDER BY added_at DESC, MAX(mf.discovered_at) DESC + ORDER BY added_at DESC NULLS LAST LIMIT ${take * 2} `; @@ -967,8 +983,8 @@ export class ViewerService { } /** - * Sorteer op discovered_at (eerste indexering). Niet op created_at: - * die volgt na bulk-import vaak padvolgorde (W/Z vooraan). + * Sorteer op library_added_at (cluster first-seen). Fallback discovered_at. + * Tweede node bump’t niet; mtime bij eerste index voorkomt scan-flood. */ private async moviesByRecency( skip: number, @@ -979,14 +995,73 @@ export class ViewerService { const cap = opts.cap; const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty; const idRows = await prisma.$queryRaw>` - SELECT mf.movie_id + SELECT m.id AS movie_id FROM media_files mf + INNER JOIN movies m ON m.id = mf.movie_id WHERE mf.available = true AND mf.movie_id IS NOT NULL ${extraWhereSql} ${kidsSql} - GROUP BY mf.movie_id - ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC, MIN(mf.release_name) ASC + GROUP BY m.id, m.library_added_at + ORDER BY COALESCE(m.library_added_at, MAX(mf.discovered_at)) DESC NULLS LAST, + MAX(mf.discovered_at) DESC, + MIN(mf.release_name) ASC + ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty} + `; + const allIds = idRows.map((r) => r.movie_id); + const total = allIds.length; + const ids = allIds.slice(skip, skip + take); + if (!ids.length) return { total, items: [], offset: skip }; + + const movies = await prisma.movie.findMany({ + where: { id: { in: ids } }, + include: { + mediaFiles: { + where: { available: true }, + orderBy: [{ lastSeen: "desc" }, { createdAt: "desc" }], + take: 1, + }, + }, + }); + return { total, items: this.mapMovies(ids, movies), offset: skip }; + } + + /** Films gesorteerd op TMDB-releasedatum (net uitgebracht). */ + async newReleaseMovies(take: number, skip = 0, kidsMode = false) { + const page = await this.moviesByReleaseDate(skip, take, { cap: RECENT_CATALOG_CAP, kidsMode }); + return page.items; + } + + private async moviesByReleaseDate( + skip: number, + take: number, + opts: { cap?: number; kidsMode?: boolean; withinYears?: number } = {} + ) { + const cap = opts.cap; + const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty; + const withinYears = opts.withinYears ?? 3; + const idRows = await prisma.$queryRaw>` + SELECT m.id AS movie_id + FROM movies m + WHERE ( + ( + m.release_date IS NOT NULL + AND m.release_date <= CURRENT_TIMESTAMP + AND m.release_date >= (CURRENT_TIMESTAMP - (${withinYears}::text || ' years')::interval) + ) + OR ( + m.release_date IS NULL + AND m.year IS NOT NULL + AND m.year >= (EXTRACT(YEAR FROM CURRENT_TIMESTAMP)::int - ${withinYears}) + ) + ) + AND EXISTS ( + SELECT 1 FROM media_files mf + WHERE mf.movie_id = m.id AND mf.available = true + ${kidsSql} + ) + ORDER BY COALESCE(m.release_date, make_date(m.year, 6, 1)) DESC NULLS LAST, + m.title ASC ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty} `; const allIds = idRows.map((r) => r.movie_id); @@ -1118,7 +1193,8 @@ export class ViewerService { WHERE mf.available = true AND mf.episode_id IS NOT NULL ${kidsSql} GROUP BY e.series_id - ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC + ORDER BY MAX(COALESCE(e.library_added_at, mf.discovered_at)) DESC NULLS LAST, + MAX(mf.discovered_at) DESC ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty} `; const allIds = idRows.map((r) => r.series_id); @@ -1327,14 +1403,16 @@ export class ViewerService { LIMIT ${take} ` : await prisma.$queryRaw>` - SELECT mf.movie_id + SELECT m.id AS movie_id FROM media_files mf + INNER JOIN movies m ON m.id = mf.movie_id WHERE mf.shelf_id = ${shelfId} AND mf.available = true AND mf.movie_id IS NOT NULL ${kidsMovie} - GROUP BY mf.movie_id - ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC + GROUP BY m.id, m.library_added_at + ORDER BY COALESCE(m.library_added_at, MAX(mf.discovered_at)) DESC NULLS LAST, + MAX(mf.discovered_at) DESC OFFSET ${offset} LIMIT ${take} `; @@ -1427,7 +1505,8 @@ export class ViewerService { AND mf.episode_id IS NOT NULL ${kidsSeries} GROUP BY e.series_id - ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC + ORDER BY MAX(COALESCE(e.library_added_at, mf.discovered_at)) DESC NULLS LAST, + MAX(mf.discovered_at) DESC OFFSET ${offset} LIMIT ${take} `; @@ -1771,6 +1850,36 @@ export class ViewerService { }; } + if (id === "new-releases" || id === "net-uitgebracht") { + const sort = requestedSort ?? "recent"; + const page = + sort === "title" + ? await this.moviesByTitle( + skip, + take, + Prisma.sql`AND EXISTS ( + SELECT 1 FROM movies mx + WHERE mx.id = mf.movie_id + AND mx.release_date IS NOT NULL + AND mx.release_date <= CURRENT_TIMESTAMP + AND mx.release_date >= (CURRENT_TIMESTAMP - interval '3 years') + )`, + { kidsMode, letter } + ) + : await this.moviesByReleaseDate(skip, take, { + cap: RECENT_CATALOG_CAP, + kidsMode, + }); + return { + id: "new-releases", + title: "Net uitgebracht", + kind: "MOVIE" as const, + total: page.total, + offset: page.offset ?? skip, + items: page.items, + }; + } + if (id === "recent-series") { const sort = requestedSort ?? "recent"; const page =