diff --git a/apps/master-api/prisma/migrations/20260905010000_media_file_discovered_at/migration.sql b/apps/master-api/prisma/migrations/20260905010000_media_file_discovered_at/migration.sql new file mode 100644 index 0000000..a3b127b --- /dev/null +++ b/apps/master-api/prisma/migrations/20260905010000_media_file_discovered_at/migration.sql @@ -0,0 +1,9 @@ +-- Stable "date added to library" for Recent shelves. +-- Backfill from file mtime (last_seen), not created_at (often bulk-import order). +ALTER TABLE "media_files" ADD COLUMN IF NOT EXISTS "discovered_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +UPDATE "media_files" +SET "discovered_at" = "last_seen" +WHERE "discovered_at" IS NOT NULL; + +CREATE INDEX IF NOT EXISTS "media_files_discovered_at_idx" ON "media_files"("discovered_at"); diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index cb185c4..1ada130 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -440,6 +440,8 @@ model MediaFile { releaseName String @map("release_name") 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). + discoveredAt DateTime @default(now()) @map("discovered_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -451,6 +453,7 @@ model MediaFile { @@unique([nodeId, localFileId]) @@index([shelfId]) + @@index([discoveredAt]) @@map("media_files") } diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index bcf2563..5855a88 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -298,22 +298,25 @@ export class MetadataService { where: { nodeId_localFileId: { nodeId, localFileId: file.localFileId }, }, - select: { id: true, movieId: true, episodeId: true, shelfId: true }, + select: { id: true, movieId: true, episodeId: true, shelfId: true, sizeBytes: true }, }); if (existing && (existing.movieId || existing.episodeId)) { const nextShelf = file.shelfId || null; + const sizeBytes = BigInt(file.sizeBytes); + const sizeChanged = existing.sizeBytes !== sizeBytes; await prisma.mediaFile.update({ where: { id: existing.id }, data: { available: true, shelfId: nextShelf, - sizeBytes: BigInt(file.sizeBytes), + sizeBytes, container: file.container, resolution: file.resolution, videoCodec: file.videoCodec, audioCodec: file.audioCodec, releaseName: file.releaseName, lastSeen: new Date(file.modifiedAt), + ...(sizeChanged ? { discoveredAt: new Date() } : {}), }, }); return existing.movieId ?? existing.episodeId; @@ -1797,7 +1800,15 @@ export class MetadataService { file: MediaFileInfo, refs: { movieId?: string; episodeId?: string } ): Promise { - await prisma.mediaFile.upsert({ + const mtime = new Date(file.modifiedAt); + const sizeBytes = BigInt(file.sizeBytes); + const existing = await prisma.mediaFile.findUnique({ + where: { nodeId_localFileId: { nodeId, localFileId: file.localFileId } }, + select: { sizeBytes: true }, + }); + const sizeChanged = existing != null && existing.sizeBytes !== sizeBytes; + + await prisma.mediaFile.upsert({ where: { nodeId_localFileId: { nodeId, localFileId: file.localFileId }, }, @@ -1807,27 +1818,31 @@ export class MetadataService { movieId: refs.movieId, episodeId: refs.episodeId, shelfId: file.shelfId || null, - sizeBytes: BigInt(file.sizeBytes), + sizeBytes, container: file.container, resolution: file.resolution, videoCodec: file.videoCodec, audioCodec: file.audioCodec, releaseName: file.releaseName, available: true, - lastSeen: new Date(file.modifiedAt), + lastSeen: mtime, + // Eerste indexering = nu (ook als bestand-mtime ouder is) + discoveredAt: new Date(), }, update: { movieId: refs.movieId, episodeId: refs.episodeId, ...(file.shelfId !== undefined ? { shelfId: file.shelfId || null } : {}), - sizeBytes: BigInt(file.sizeBytes), + sizeBytes, container: file.container, resolution: file.resolution, videoCodec: file.videoCodec, audioCodec: file.audioCodec, releaseName: file.releaseName, available: true, - lastSeen: new Date(file.modifiedAt), + lastSeen: mtime, + // Alleen opnieuw “recent” als het bestand inhoudelijk vernieuwd is + ...(sizeChanged ? { discoveredAt: new Date() } : {}), }, }); } diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index 6eeade0..13c6e17 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -417,7 +417,7 @@ async function getMovieCatalog( FROM media_files mf WHERE mf.available = true AND mf.movie_id IS NOT NULL GROUP BY mf.movie_id - ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC + ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC OFFSET ${skip} LIMIT ${CATALOG_PAGE} `; @@ -444,7 +444,7 @@ async function getMovieCatalog( OR mf.resolution ILIKE '%UHD%' ) GROUP BY mf.movie_id - ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC + ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC OFFSET ${skip} LIMIT ${CATALOG_PAGE} `; @@ -485,7 +485,7 @@ async function getMovieCatalog( FROM media_files mf WHERE mf.available = true AND mf.movie_id IS NOT NULL GROUP BY mf.movie_id - ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC + ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC OFFSET ${skip} LIMIT ${CATALOG_PAGE} `; @@ -555,7 +555,7 @@ 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(GREATEST(mf.last_seen, mf.created_at)) DESC + ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC OFFSET ${skip} LIMIT ${CATALOG_PAGE} `; @@ -573,7 +573,7 @@ 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(GREATEST(mf.last_seen, mf.created_at)) DESC + ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_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 00e167d..d0eae14 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -19,6 +19,8 @@ import { MetadataService } from "../metadata/service"; const CODE_TTL_MS = 10 * 60 * 1000; const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const POLL_INTERVAL_SEC = 2; +/** Max titels in Recent-/4K-catalogi (niet de hele bibliotheek). */ +const RECENT_CATALOG_CAP = 120; export type AuthedViewer = { viewerId: string; @@ -420,23 +422,23 @@ export class ViewerService { }; } - /** Nieuwste titels (op mediabestand-datum) voor de home billboard. */ + /** Nieuwste titels (op bestand-mtime van de node) voor de home billboard. */ async spotlight(take: number) { const movieIds = await prisma.$queryRaw>` - SELECT mf.movie_id, MAX(GREATEST(mf.last_seen, mf.created_at)) AS added_at + SELECT mf.movie_id, MAX(mf.discovered_at) AS added_at FROM media_files mf WHERE mf.available = true AND mf.movie_id IS NOT NULL GROUP BY mf.movie_id - ORDER BY added_at DESC + ORDER BY added_at DESC, MAX(mf.discovered_at) DESC LIMIT ${take * 2} `; const seriesIds = await prisma.$queryRaw>` - SELECT e.series_id, MAX(GREATEST(mf.last_seen, mf.created_at)) AS added_at + SELECT e.series_id, MAX(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 GROUP BY e.series_id - ORDER BY added_at DESC + ORDER BY added_at DESC, MAX(mf.discovered_at) DESC LIMIT ${take * 2} `; @@ -513,29 +515,26 @@ export class ViewerService { } async recentMovies(take: number, skip = 0) { - const page = await this.moviesByRecency(skip, take); + const page = await this.moviesByRecency(skip, take, Prisma.empty, { cap: RECENT_CATALOG_CAP }); return page.items; } async recentSeries(take: number, skip = 0) { - const page = await this.seriesByRecency(skip, take); + const page = await this.seriesByRecency(skip, take, { cap: RECENT_CATALOG_CAP }); return page.items; } - /** Films gesorteerd op echte mediadatum (bestand-mtime of eerste indexering). */ + /** + * Sorteer op discovered_at (eerste indexering). Niet op created_at: + * die volgt na bulk-import vaak padvolgorde (W/Z vooraan). + */ private async moviesByRecency( skip: number, take: number, - extraWhereSql: Prisma.Sql = Prisma.empty + extraWhereSql: Prisma.Sql = Prisma.empty, + opts: { cap?: number } = {} ) { - const [countRow] = await prisma.$queryRaw>` - SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count - FROM media_files mf - WHERE mf.available = true - AND mf.movie_id IS NOT NULL - ${extraWhereSql} - `; - const total = Number(countRow?.count ?? 0); + const cap = opts.cap; const idRows = await prisma.$queryRaw>` SELECT mf.movie_id FROM media_files mf @@ -543,11 +542,12 @@ export class ViewerService { AND mf.movie_id IS NOT NULL ${extraWhereSql} GROUP BY mf.movie_id - ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC - OFFSET ${skip} - LIMIT ${take} + ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC, MIN(mf.release_name) ASC + ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty} `; - const ids = idRows.map((r) => r.movie_id); + 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: [] }; const movies = await prisma.movie.findMany({ @@ -589,34 +589,38 @@ export class ViewerService { })); } - private async seriesByRecency(skip: number, take: number) { - 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.available = true AND mf.episode_id IS NOT NULL - `; - const total = Number(countRow?.count ?? 0); + private async seriesByRecency( + skip: number, + take: number, + opts: { cap?: number } = {} + ) { + const cap = opts.cap; const idRows = await prisma.$queryRaw>` SELECT e.series_id 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 GROUP BY e.series_id - ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC - OFFSET ${skip} - LIMIT ${take} + ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC + ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty} `; - const ids = idRows.map((r) => r.series_id); - if (!ids.length) return { total, items: [] as Array<{ - type: "series"; - id: string; - title: string; - year: number | null; - posterUrl: string | null; - backdropUrl: string | null; - mediaFileId: string | null; - }> }; + const allIds = idRows.map((r) => r.series_id); + const total = allIds.length; + const ids = allIds.slice(skip, skip + take); + if (!ids.length) { + return { + total, + items: [] as Array<{ + type: "series"; + id: string; + title: string; + year: number | null; + posterUrl: string | null; + backdropUrl: string | null; + mediaFileId: string | null; + }>, + }; + } const series = await prisma.series.findMany({ where: { id: { in: ids } } }); const byId = new Map(series.map((s) => [s.id, s])); @@ -645,7 +649,8 @@ export class ViewerService { mf.resolution ILIKE '%2160%' OR mf.resolution ILIKE '%4K%' OR mf.resolution ILIKE '%UHD%' - )` + )`, + { cap: RECENT_CATALOG_CAP } ); return page.items; } @@ -678,7 +683,7 @@ export class ViewerService { AND mf.available = true AND mf.movie_id IS NOT NULL GROUP BY mf.movie_id - ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC + ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC OFFSET ${skip} LIMIT ${take} `; @@ -730,7 +735,7 @@ export class ViewerService { AND mf.available = true AND mf.episode_id IS NOT NULL GROUP BY e.series_id - ORDER BY MAX(GREATEST(mf.last_seen, mf.created_at)) DESC + ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC OFFSET ${skip} LIMIT ${take} `; @@ -1030,7 +1035,9 @@ export class ViewerService { } if (id === "recent-movies") { - const page = await this.moviesByRecency(skip, take); + const page = await this.moviesByRecency(skip, take, Prisma.empty, { + cap: RECENT_CATALOG_CAP, + }); return { id, title: "Recent toegevoegd", @@ -1041,7 +1048,7 @@ export class ViewerService { } if (id === "recent-series") { - const page = await this.seriesByRecency(skip, take); + const page = await this.seriesByRecency(skip, take, { cap: RECENT_CATALOG_CAP }); return { id, title: "Recent toegevoegde series", @@ -1059,7 +1066,8 @@ export class ViewerService { mf.resolution ILIKE '%2160%' OR mf.resolution ILIKE '%4K%' OR mf.resolution ILIKE '%UHD%' - )` + )`, + { cap: RECENT_CATALOG_CAP } ); return { id,