Fix recently-added using cluster first-seen and add new-releases shelf.

This commit is contained in:
Jos Vooges | STH 2026-09-12 02:21:16 +02:00
parent 0dbf2c3d7a
commit 06a022089b
6 changed files with 360 additions and 37 deletions

View file

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

View file

@ -422,6 +422,13 @@ model Movie {
runtime Int? runtime Int?
imdbId String? @unique @map("imdb_id") imdbId String? @unique @map("imdb_id")
tmdbId Int? @unique @map("tmdb_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") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -430,6 +437,8 @@ model Movie {
subtitles LibrarySubtitle[] subtitles LibrarySubtitle[]
@@index([adult]) @@index([adult])
@@index([libraryAddedAt])
@@index([releaseDate])
@@map("movies") @@map("movies")
} }
@ -482,6 +491,8 @@ model Episode {
airDate DateTime? @map("air_date") airDate DateTime? @map("air_date")
imdbId String? @map("imdb_id") imdbId String? @map("imdb_id")
tmdbId Int? @map("tmdb_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") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -491,6 +502,7 @@ model Episode {
matches LibraryMatch[] matches LibraryMatch[]
@@unique([seriesId, seasonNumber, episodeNumber]) @@unique([seriesId, seasonNumber, episodeNumber])
@@index([libraryAddedAt])
@@map("episodes") @@map("episodes")
} }
@ -515,7 +527,7 @@ model MediaFile {
path String? @map("path") path String? @map("path")
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). /// Eerste indexering van dit bestand: bij create ≈ file-mtime (niet scan-now).
discoveredAt DateTime @default(now()) @map("discovered_at") 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")

View file

@ -179,6 +179,56 @@ async function main() {
console.warn("Could not ensure node media path columns:", err); 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 { try {
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
DO $$ BEGIN DO $$ BEGIN

View file

@ -86,8 +86,26 @@ type TmdbMeta = {
logoUrl?: string; logoUrl?: string;
imdbId?: string; imdbId?: string;
adult?: boolean; 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. */ /** Extra fields for Stremio meta detail pages. */
export type StremioMetaExtras = { export type StremioMetaExtras = {
logo?: string; logo?: string;
@ -341,6 +359,7 @@ export class MetadataService {
const nextShelf = file.shelfId || null; const nextShelf = file.shelfId || null;
const sizeBytes = BigInt(file.sizeBytes); const sizeBytes = BigInt(file.sizeBytes);
const sizeChanged = existing.sizeBytes !== sizeBytes; const sizeChanged = existing.sizeBytes !== sizeBytes;
const discoveredAt = sizeChanged ? new Date() : undefined;
await prisma.mediaFile.update({ await prisma.mediaFile.update({
where: { id: existing.id }, where: { id: existing.id },
data: { data: {
@ -354,9 +373,16 @@ export class MetadataService {
releaseName: file.releaseName, releaseName: file.releaseName,
...(file.path ? { path: file.path } : {}), ...(file.path ? { path: file.path } : {}),
lastSeen: new Date(file.modifiedAt), 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; 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). */ /** TMDB upsert with safe IMDb linking (unique conflicts must not block file matching). */
private async upsertMovieFromTmdb(tmdb: TmdbMeta): Promise<MovieRow> { private async upsertMovieFromTmdb(tmdb: TmdbMeta): Promise<MovieRow> {
const releaseDate = parseTmdbDate(tmdb.releaseDate);
const data = { const data = {
title: tmdb.title, title: tmdb.title,
year: tmdb.year, year: tmdb.year,
@ -1983,6 +2010,7 @@ export class MetadataService {
backdropUrl: tmdb.backdropUrl, backdropUrl: tmdb.backdropUrl,
tmdbId: tmdb.id, tmdbId: tmdb.id,
adult: tmdb.adult === true, adult: tmdb.adult === true,
...(releaseDate ? { releaseDate } : {}),
}; };
try { try {
return await prisma.movie.upsert({ 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<void> {
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( private async upsertMediaFile(
nodeId: string, nodeId: string,
file: MediaFileInfo, file: MediaFileInfo,
@ -2026,6 +2077,8 @@ export class MetadataService {
select: { sizeBytes: true }, select: { sizeBytes: true },
}); });
const sizeChanged = existing != null && existing.sizeBytes !== sizeBytes; const sizeChanged = existing != null && existing.sizeBytes !== sizeBytes;
const discoveredAtCreate = discoveredAtFromFileMtime(file.modifiedAt);
const discoveredAtUpdate = sizeChanged ? new Date() : undefined;
await prisma.mediaFile.upsert({ await prisma.mediaFile.upsert({
where: { where: {
@ -2046,8 +2099,7 @@ export class MetadataService {
path: file.path || null, path: file.path || null,
available: true, available: true,
lastSeen: mtime, lastSeen: mtime,
// Eerste indexering = nu (ook als bestand-mtime ouder is) discoveredAt: discoveredAtCreate,
discoveredAt: new Date(),
}, },
update: { update: {
...("movieId" in refs ? { movieId: refs.movieId ?? null } : {}), ...("movieId" in refs ? { movieId: refs.movieId ?? null } : {}),
@ -2062,10 +2114,25 @@ export class MetadataService {
...(file.path ? { path: file.path } : {}), ...(file.path ? { path: file.path } : {}),
available: true, available: true,
lastSeen: mtime, lastSeen: mtime,
// Alleen opnieuw “recent” als het bestand inhoudelijk vernieuwd is ...(discoveredAtUpdate ? { discoveredAt: discoveredAtUpdate } : {}),
...(sizeChanged ? { discoveredAt: new Date() } : {}),
}, },
}); });
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) { private async findExistingMovie(title: string, year?: number) {
@ -2227,6 +2294,7 @@ export class MetadataService {
: undefined, : undefined,
imdbId, imdbId,
adult: item.adult === true, adult: item.adult === true,
releaseDate: item.release_date || undefined,
}; };
this.movieCache.set(tmdbId, meta); this.movieCache.set(tmdbId, meta);
return meta; return meta;

View file

@ -34,6 +34,7 @@ const BASE_CATALOGS = [
{ type: "series", id: "series", name: "Series", extra: SEARCH_SKIP_EXTRAS }, { type: "series", id: "series", name: "Series", extra: SEARCH_SKIP_EXTRAS },
{ type: "movie", id: "recent-movies", name: "Recent toegevoegd", extra: SKIP_EXTRA }, { type: "movie", id: "recent-movies", name: "Recent toegevoegd", extra: SKIP_EXTRA },
{ type: "series", id: "recent-series", name: "Recent toegevoegde series", 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 }, { type: "movie", id: "4k-movies", name: "4K films", extra: SEARCH_SKIP_EXTRAS },
]; ];
@ -447,11 +448,13 @@ async function getMovieCatalog(
if (catalogId === "recent-movies" && !search) { if (catalogId === "recent-movies" && !search) {
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>` const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id SELECT m.id AS movie_id
FROM media_files mf 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 WHERE mf.available = true AND mf.movie_id IS NOT NULL
GROUP BY mf.movie_id GROUP BY m.id, m.library_added_at
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC ORDER BY COALESCE(m.library_added_at, MAX(mf.discovered_at)) DESC NULLS LAST,
MAX(mf.discovered_at) DESC
OFFSET ${skip} OFFSET ${skip}
LIMIT ${CATALOG_PAGE} LIMIT ${CATALOG_PAGE}
`; `;
@ -465,11 +468,49 @@ async function getMovieCatalog(
return ids.map((id) => byId.get(id)).filter((m): m is NonNullable<typeof m> => !!m); return ids.map((id) => byId.get(id)).filter((m): m is NonNullable<typeof m> => !!m);
}) })
: []; : [];
} else if (catalogId === "new-releases") {
if (!search) {
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
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<typeof m> => !!m);
})
: [];
}
} else if (catalogId === "4k-movies") { } else if (catalogId === "4k-movies") {
if (!search) { if (!search) {
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>` const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id SELECT m.id AS movie_id
FROM media_files mf FROM media_files mf
INNER JOIN movies m ON m.id = mf.movie_id
WHERE mf.available = true WHERE mf.available = true
AND mf.movie_id IS NOT NULL AND mf.movie_id IS NOT NULL
AND ( AND (
@ -477,8 +518,9 @@ async function getMovieCatalog(
OR mf.resolution ILIKE '%4K%' OR mf.resolution ILIKE '%4K%'
OR mf.resolution ILIKE '%UHD%' OR mf.resolution ILIKE '%UHD%'
) )
GROUP BY mf.movie_id GROUP BY m.id, m.library_added_at
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC ORDER BY COALESCE(m.library_added_at, MAX(mf.discovered_at)) DESC NULLS LAST,
MAX(mf.discovered_at) DESC
OFFSET ${skip} OFFSET ${skip}
LIMIT ${CATALOG_PAGE} LIMIT ${CATALOG_PAGE}
`; `;
@ -515,11 +557,13 @@ async function getMovieCatalog(
} }
} else if (catalogId === "movies" && !search) { } else if (catalogId === "movies" && !search) {
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>` const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id SELECT m.id AS movie_id
FROM media_files mf 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 WHERE mf.available = true AND mf.movie_id IS NOT NULL
GROUP BY mf.movie_id GROUP BY m.id, m.library_added_at
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC ORDER BY COALESCE(m.library_added_at, MAX(mf.discovered_at)) DESC NULLS LAST,
MAX(mf.discovered_at) DESC
OFFSET ${skip} OFFSET ${skip}
LIMIT ${CATALOG_PAGE} LIMIT ${CATALOG_PAGE}
`; `;
@ -589,7 +633,8 @@ 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(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} OFFSET ${skip}
LIMIT ${CATALOG_PAGE} LIMIT ${CATALOG_PAGE}
`; `;
@ -607,7 +652,8 @@ 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(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} OFFSET ${skip}
LIMIT ${CATALOG_PAGE} LIMIT ${CATALOG_PAGE}
`; `;

View file

@ -626,7 +626,7 @@ export class ViewerService {
} }
async home(profileId: string, kidsMode: boolean) { 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([ await Promise.all([
prisma.libraryShelf.findMany({ prisma.libraryShelf.findMany({
where: { enabled: true }, where: { enabled: true },
@ -636,6 +636,7 @@ export class ViewerService {
this.continueWatching(profileId, kidsMode), this.continueWatching(profileId, kidsMode),
this.recentMovies(48, 0, kidsMode), this.recentMovies(48, 0, kidsMode),
this.recentSeries(36, 0, kidsMode), this.recentSeries(36, 0, kidsMode),
this.newReleaseMovies(36, 0, kidsMode),
this.fourKMovies(36, 0, kidsMode), this.fourKMovies(36, 0, kidsMode),
this.spotlight(12, kidsMode), this.spotlight(12, kidsMode),
]); ]);
@ -695,6 +696,16 @@ export class ViewerService {
items: recentSeries, 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) { if (fourK.length) {
rows.push({ rows.push({
id: "system:4k-movies", id: "system:4k-movies",
@ -707,12 +718,14 @@ export class ViewerService {
} }
// Plank-items parallel laden (volgorde van `ordered` blijft behouden via Promise.all-index). // 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([ const [shelfPayloads, favorites] = await Promise.all([
Promise.all( Promise.all(
ordered.map(async (shelf) => { ordered.map(async (shelf) => {
const items = await this.shelfItems(shelf.id, shelf.kind, 48, kidsMode); const page = await this.shelfItemsPage(shelf.id, shelf.kind, 0, 48, kidsMode, {
return { shelf, items }; sort: "recent",
});
return { shelf, items: page.items };
}) })
), ),
this.listFavorites(profileId, kidsMode), this.listFavorites(profileId, kidsMode),
@ -854,27 +867,30 @@ export class ViewerService {
return this.addFavorite(profileId, mediaType, id); 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) { async spotlight(take: number, kidsMode = false) {
const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty; const kidsMovie = kidsMode ? kidsMovieSql() : Prisma.empty;
const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty; const kidsSeries = kidsMode ? kidsSeriesSql() : Prisma.empty;
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(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 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 WHERE mf.available = true AND mf.movie_id IS NOT NULL
${kidsMovie} ${kidsMovie}
GROUP BY mf.movie_id GROUP BY m.id, m.library_added_at
ORDER BY added_at DESC, MAX(mf.discovered_at) DESC ORDER BY added_at DESC NULLS LAST
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(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 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
${kidsSeries} ${kidsSeries}
GROUP BY e.series_id 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} LIMIT ${take * 2}
`; `;
@ -967,8 +983,8 @@ export class ViewerService {
} }
/** /**
* Sorteer op discovered_at (eerste indexering). Niet op created_at: * Sorteer op library_added_at (cluster first-seen). Fallback discovered_at.
* die volgt na bulk-import vaak padvolgorde (W/Z vooraan). * Tweede node bump’t niet; mtime bij eerste index voorkomt scan-flood.
*/ */
private async moviesByRecency( private async moviesByRecency(
skip: number, skip: number,
@ -979,14 +995,73 @@ export class ViewerService {
const cap = opts.cap; const cap = opts.cap;
const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty; const kidsSql = opts.kidsMode ? kidsMovieSql() : Prisma.empty;
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>` const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id SELECT m.id AS movie_id
FROM media_files mf FROM media_files mf
INNER JOIN movies m ON m.id = mf.movie_id
WHERE mf.available = true WHERE mf.available = true
AND mf.movie_id IS NOT NULL AND mf.movie_id IS NOT NULL
${extraWhereSql} ${extraWhereSql}
${kidsSql} ${kidsSql}
GROUP BY mf.movie_id GROUP BY m.id, m.library_added_at
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC, MIN(mf.release_name) ASC 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<Array<{ movie_id: string }>>`
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} ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty}
`; `;
const allIds = idRows.map((r) => r.movie_id); 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 WHERE mf.available = true AND mf.episode_id IS NOT NULL
${kidsSql} ${kidsSql}
GROUP BY e.series_id 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} ${cap != null ? Prisma.sql`LIMIT ${cap}` : Prisma.empty}
`; `;
const allIds = idRows.map((r) => r.series_id); const allIds = idRows.map((r) => r.series_id);
@ -1327,14 +1403,16 @@ export class ViewerService {
LIMIT ${take} LIMIT ${take}
` `
: await prisma.$queryRaw<Array<{ movie_id: string }>>` : await prisma.$queryRaw<Array<{ movie_id: string }>>`
SELECT mf.movie_id SELECT m.id AS movie_id
FROM media_files mf FROM media_files mf
INNER JOIN movies m ON m.id = mf.movie_id
WHERE mf.shelf_id = ${shelfId} WHERE mf.shelf_id = ${shelfId}
AND mf.available = true AND mf.available = true
AND mf.movie_id IS NOT NULL AND mf.movie_id IS NOT NULL
${kidsMovie} ${kidsMovie}
GROUP BY mf.movie_id GROUP BY m.id, m.library_added_at
ORDER BY MAX(mf.discovered_at) DESC, MAX(mf.created_at) DESC ORDER BY COALESCE(m.library_added_at, MAX(mf.discovered_at)) DESC NULLS LAST,
MAX(mf.discovered_at) DESC
OFFSET ${offset} OFFSET ${offset}
LIMIT ${take} LIMIT ${take}
`; `;
@ -1427,7 +1505,8 @@ export class ViewerService {
AND mf.episode_id IS NOT NULL AND mf.episode_id IS NOT NULL
${kidsSeries} ${kidsSeries}
GROUP BY e.series_id 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} OFFSET ${offset}
LIMIT ${take} 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") { if (id === "recent-series") {
const sort = requestedSort ?? "recent"; const sort = requestedSort ?? "recent";
const page = const page =