Fix recently-added using cluster first-seen and add new-releases shelf.
This commit is contained in:
parent
0dbf2c3d7a
commit
06a022089b
6 changed files with 360 additions and 37 deletions
|
|
@ -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");
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue