From 826e95aaabccd35031d4a9fc8ab40767910b092f Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sat, 5 Sep 2026 01:00:18 +0200 Subject: [PATCH] Fix Recent sorting with stable discoveredAt on media files. Backfill from file mtime and set discoveredAt on first index (and size changes) so new titles rise to the top instead of bulk-import path order (W/Z). Co-authored-by: Cursor --- .../migration.sql | 9 ++ apps/master-api/prisma/schema.prisma | 3 + apps/master-api/src/metadata/service.ts | 29 +++-- apps/master-api/src/stremio/routes.ts | 10 +- apps/master-api/src/viewer/service.ts | 104 ++++++++++-------- 5 files changed, 95 insertions(+), 60 deletions(-) create mode 100644 apps/master-api/prisma/migrations/20260905010000_media_file_discovered_at/migration.sql 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,