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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-05 01:00:18 +02:00
parent 421baae3d7
commit 826e95aaab
5 changed files with 95 additions and 60 deletions

View file

@ -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");

View file

@ -440,6 +440,8 @@ model MediaFile {
releaseName String @map("release_name") releaseName String @map("release_name")
available Boolean @default(true) available Boolean @default(true)
lastSeen DateTime @default(now()) @map("last_seen") 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") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -451,6 +453,7 @@ model MediaFile {
@@unique([nodeId, localFileId]) @@unique([nodeId, localFileId])
@@index([shelfId]) @@index([shelfId])
@@index([discoveredAt])
@@map("media_files") @@map("media_files")
} }

View file

@ -298,22 +298,25 @@ export class MetadataService {
where: { where: {
nodeId_localFileId: { nodeId, localFileId: file.localFileId }, 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)) { if (existing && (existing.movieId || existing.episodeId)) {
const nextShelf = file.shelfId || null; const nextShelf = file.shelfId || null;
const sizeBytes = BigInt(file.sizeBytes);
const sizeChanged = existing.sizeBytes !== sizeBytes;
await prisma.mediaFile.update({ await prisma.mediaFile.update({
where: { id: existing.id }, where: { id: existing.id },
data: { data: {
available: true, available: true,
shelfId: nextShelf, shelfId: nextShelf,
sizeBytes: BigInt(file.sizeBytes), sizeBytes,
container: file.container, container: file.container,
resolution: file.resolution, resolution: file.resolution,
videoCodec: file.videoCodec, videoCodec: file.videoCodec,
audioCodec: file.audioCodec, audioCodec: file.audioCodec,
releaseName: file.releaseName, releaseName: file.releaseName,
lastSeen: new Date(file.modifiedAt), lastSeen: new Date(file.modifiedAt),
...(sizeChanged ? { discoveredAt: new Date() } : {}),
}, },
}); });
return existing.movieId ?? existing.episodeId; return existing.movieId ?? existing.episodeId;
@ -1797,7 +1800,15 @@ export class MetadataService {
file: MediaFileInfo, file: MediaFileInfo,
refs: { movieId?: string; episodeId?: string } refs: { movieId?: string; episodeId?: string }
): Promise<void> { ): Promise<void> {
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: { where: {
nodeId_localFileId: { nodeId, localFileId: file.localFileId }, nodeId_localFileId: { nodeId, localFileId: file.localFileId },
}, },
@ -1807,27 +1818,31 @@ export class MetadataService {
movieId: refs.movieId, movieId: refs.movieId,
episodeId: refs.episodeId, episodeId: refs.episodeId,
shelfId: file.shelfId || null, shelfId: file.shelfId || null,
sizeBytes: BigInt(file.sizeBytes), sizeBytes,
container: file.container, container: file.container,
resolution: file.resolution, resolution: file.resolution,
videoCodec: file.videoCodec, videoCodec: file.videoCodec,
audioCodec: file.audioCodec, audioCodec: file.audioCodec,
releaseName: file.releaseName, releaseName: file.releaseName,
available: true, available: true,
lastSeen: new Date(file.modifiedAt), lastSeen: mtime,
// Eerste indexering = nu (ook als bestand-mtime ouder is)
discoveredAt: new Date(),
}, },
update: { update: {
movieId: refs.movieId, movieId: refs.movieId,
episodeId: refs.episodeId, episodeId: refs.episodeId,
...(file.shelfId !== undefined ? { shelfId: file.shelfId || null } : {}), ...(file.shelfId !== undefined ? { shelfId: file.shelfId || null } : {}),
sizeBytes: BigInt(file.sizeBytes), sizeBytes,
container: file.container, container: file.container,
resolution: file.resolution, resolution: file.resolution,
videoCodec: file.videoCodec, videoCodec: file.videoCodec,
audioCodec: file.audioCodec, audioCodec: file.audioCodec,
releaseName: file.releaseName, releaseName: file.releaseName,
available: true, available: true,
lastSeen: new Date(file.modifiedAt), lastSeen: mtime,
// Alleen opnieuw “recent” als het bestand inhoudelijk vernieuwd is
...(sizeChanged ? { discoveredAt: new Date() } : {}),
}, },
}); });
} }

View file

@ -417,7 +417,7 @@ async function getMovieCatalog(
FROM media_files mf FROM media_files mf
WHERE mf.available = true AND mf.movie_id IS NOT NULL WHERE mf.available = true AND mf.movie_id IS NOT NULL
GROUP BY mf.movie_id 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} OFFSET ${skip}
LIMIT ${CATALOG_PAGE} LIMIT ${CATALOG_PAGE}
`; `;
@ -444,7 +444,7 @@ async function getMovieCatalog(
OR mf.resolution ILIKE '%UHD%' OR mf.resolution ILIKE '%UHD%'
) )
GROUP BY mf.movie_id 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} OFFSET ${skip}
LIMIT ${CATALOG_PAGE} LIMIT ${CATALOG_PAGE}
`; `;
@ -485,7 +485,7 @@ async function getMovieCatalog(
FROM media_files mf FROM media_files mf
WHERE mf.available = true AND mf.movie_id IS NOT NULL WHERE mf.available = true AND mf.movie_id IS NOT NULL
GROUP BY mf.movie_id 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} OFFSET ${skip}
LIMIT ${CATALOG_PAGE} LIMIT ${CATALOG_PAGE}
`; `;
@ -555,7 +555,7 @@ async function getSeriesCatalog(
INNER JOIN episodes e ON e.id = mf.episode_id INNER JOIN episodes e ON e.id = mf.episode_id
WHERE mf.available = true AND mf.episode_id IS NOT NULL WHERE mf.available = true AND mf.episode_id IS NOT NULL
GROUP BY e.series_id 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} OFFSET ${skip}
LIMIT ${CATALOG_PAGE} LIMIT ${CATALOG_PAGE}
`; `;
@ -573,7 +573,7 @@ async function getSeriesCatalog(
INNER JOIN episodes e ON e.id = mf.episode_id INNER JOIN episodes e ON e.id = mf.episode_id
WHERE mf.available = true AND mf.episode_id IS NOT NULL WHERE mf.available = true AND mf.episode_id IS NOT NULL
GROUP BY e.series_id 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} OFFSET ${skip}
LIMIT ${CATALOG_PAGE} LIMIT ${CATALOG_PAGE}
`; `;

View file

@ -19,6 +19,8 @@ import { MetadataService } from "../metadata/service";
const CODE_TTL_MS = 10 * 60 * 1000; const CODE_TTL_MS = 10 * 60 * 1000;
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const POLL_INTERVAL_SEC = 2; const POLL_INTERVAL_SEC = 2;
/** Max titels in Recent-/4K-catalogi (niet de hele bibliotheek). */
const RECENT_CATALOG_CAP = 120;
export type AuthedViewer = { export type AuthedViewer = {
viewerId: string; 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) { async spotlight(take: number) {
const movieIds = await prisma.$queryRaw<Array<{ movie_id: string; added_at: Date }>>` const movieIds = await prisma.$queryRaw<Array<{ movie_id: string; added_at: Date }>>`
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 FROM media_files mf
WHERE mf.available = true AND mf.movie_id IS NOT NULL WHERE mf.available = true AND mf.movie_id IS NOT NULL
GROUP BY mf.movie_id GROUP BY mf.movie_id
ORDER BY added_at DESC ORDER BY added_at DESC, MAX(mf.discovered_at) DESC
LIMIT ${take * 2} LIMIT ${take * 2}
`; `;
const seriesIds = await prisma.$queryRaw<Array<{ series_id: string; added_at: Date }>>` const seriesIds = await prisma.$queryRaw<Array<{ series_id: string; added_at: Date }>>`
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 FROM media_files mf
INNER JOIN episodes e ON e.id = mf.episode_id INNER JOIN episodes e ON e.id = mf.episode_id
WHERE mf.available = true AND mf.episode_id IS NOT NULL WHERE mf.available = true AND mf.episode_id IS NOT NULL
GROUP BY e.series_id GROUP BY e.series_id
ORDER BY added_at DESC ORDER BY added_at DESC, MAX(mf.discovered_at) DESC
LIMIT ${take * 2} LIMIT ${take * 2}
`; `;
@ -513,29 +515,26 @@ export class ViewerService {
} }
async recentMovies(take: number, skip = 0) { 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; return page.items;
} }
async recentSeries(take: number, skip = 0) { 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; 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( private async moviesByRecency(
skip: number, skip: number,
take: number, take: number,
extraWhereSql: Prisma.Sql = Prisma.empty extraWhereSql: Prisma.Sql = Prisma.empty,
opts: { cap?: number } = {}
) { ) {
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>` const cap = opts.cap;
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 idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>` const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id SELECT mf.movie_id
FROM media_files mf FROM media_files mf
@ -543,11 +542,12 @@ export class ViewerService {
AND mf.movie_id IS NOT NULL AND mf.movie_id IS NOT NULL
${extraWhereSql} ${extraWhereSql}
GROUP BY mf.movie_id 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, MIN(mf.release_name) ASC
OFFSET ${skip} ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty}
LIMIT ${take}
`; `;
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: [] }; if (!ids.length) return { total, items: [] };
const movies = await prisma.movie.findMany({ const movies = await prisma.movie.findMany({
@ -589,34 +589,38 @@ export class ViewerService {
})); }));
} }
private async seriesByRecency(skip: number, take: number) { private async seriesByRecency(
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>` skip: number,
SELECT COUNT(DISTINCT e.series_id)::bigint AS count take: number,
FROM media_files mf opts: { cap?: number } = {}
INNER JOIN episodes e ON e.id = mf.episode_id ) {
WHERE mf.available = true AND mf.episode_id IS NOT NULL const cap = opts.cap;
`;
const total = Number(countRow?.count ?? 0);
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>` const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
SELECT e.series_id SELECT e.series_id
FROM media_files mf FROM media_files mf
INNER JOIN episodes e ON e.id = mf.episode_id INNER JOIN episodes e ON e.id = mf.episode_id
WHERE mf.available = true AND mf.episode_id IS NOT NULL WHERE mf.available = true AND mf.episode_id IS NOT NULL
GROUP BY e.series_id 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} ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty}
LIMIT ${take}
`; `;
const ids = idRows.map((r) => r.series_id); const allIds = idRows.map((r) => r.series_id);
if (!ids.length) return { total, items: [] as Array<{ const total = allIds.length;
type: "series"; const ids = allIds.slice(skip, skip + take);
id: string; if (!ids.length) {
title: string; return {
year: number | null; total,
posterUrl: string | null; items: [] as Array<{
backdropUrl: string | null; type: "series";
mediaFileId: string | null; 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 series = await prisma.series.findMany({ where: { id: { in: ids } } });
const byId = new Map(series.map((s) => [s.id, s])); const byId = new Map(series.map((s) => [s.id, s]));
@ -645,7 +649,8 @@ export class ViewerService {
mf.resolution ILIKE '%2160%' mf.resolution ILIKE '%2160%'
OR mf.resolution ILIKE '%4K%' OR mf.resolution ILIKE '%4K%'
OR mf.resolution ILIKE '%UHD%' OR mf.resolution ILIKE '%UHD%'
)` )`,
{ cap: RECENT_CATALOG_CAP }
); );
return page.items; return page.items;
} }
@ -678,7 +683,7 @@ export class ViewerService {
AND mf.available = true AND mf.available = true
AND mf.movie_id IS NOT NULL AND mf.movie_id IS NOT NULL
GROUP BY mf.movie_id 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} OFFSET ${skip}
LIMIT ${take} LIMIT ${take}
`; `;
@ -730,7 +735,7 @@ export class ViewerService {
AND mf.available = true AND mf.available = true
AND mf.episode_id IS NOT NULL AND mf.episode_id IS NOT NULL
GROUP BY e.series_id 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} OFFSET ${skip}
LIMIT ${take} LIMIT ${take}
`; `;
@ -1030,7 +1035,9 @@ export class ViewerService {
} }
if (id === "recent-movies") { 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 { return {
id, id,
title: "Recent toegevoegd", title: "Recent toegevoegd",
@ -1041,7 +1048,7 @@ export class ViewerService {
} }
if (id === "recent-series") { if (id === "recent-series") {
const page = await this.seriesByRecency(skip, take); const page = await this.seriesByRecency(skip, take, { cap: RECENT_CATALOG_CAP });
return { return {
id, id,
title: "Recent toegevoegde series", title: "Recent toegevoegde series",
@ -1059,7 +1066,8 @@ export class ViewerService {
mf.resolution ILIKE '%2160%' mf.resolution ILIKE '%2160%'
OR mf.resolution ILIKE '%4K%' OR mf.resolution ILIKE '%4K%'
OR mf.resolution ILIKE '%UHD%' OR mf.resolution ILIKE '%UHD%'
)` )`,
{ cap: RECENT_CATALOG_CAP }
); );
return { return {
id, id,