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")
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")
}

View file

@ -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<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: {
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() } : {}),
},
});
}

View file

@ -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}
`;

View file

@ -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<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
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<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
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<Array<{ count: bigint }>>`
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<Array<{ movie_id: string }>>`
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<Array<{ count: bigint }>>`
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<Array<{ series_id: string }>>`
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,