Sort home shelves by newest first and add TMDB rematch for scanned titles.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5749e41148
commit
a6a922bf05
4 changed files with 369 additions and 100 deletions
|
|
@ -17,6 +17,7 @@ interface Dashboard {
|
||||||
availableFiles?: number;
|
availableFiles?: number;
|
||||||
moviesMissingImdb?: number;
|
moviesMissingImdb?: number;
|
||||||
moviesWithImdbStreams?: number;
|
moviesWithImdbStreams?: number;
|
||||||
|
moviesMissingTmdb?: number;
|
||||||
seriesMissingImdb?: number;
|
seriesMissingImdb?: number;
|
||||||
seriesWithImdb?: number;
|
seriesWithImdb?: number;
|
||||||
scannedFiles?: number;
|
scannedFiles?: number;
|
||||||
|
|
@ -53,6 +54,8 @@ export default function DashboardPage() {
|
||||||
const [repairBusy, setRepairBusy] = useState(false);
|
const [repairBusy, setRepairBusy] = useState(false);
|
||||||
const [dedupeBusy, setDedupeBusy] = useState(false);
|
const [dedupeBusy, setDedupeBusy] = useState(false);
|
||||||
const [imdbBusy, setImdbBusy] = useState(false);
|
const [imdbBusy, setImdbBusy] = useState(false);
|
||||||
|
const [rematchBusy, setRematchBusy] = useState(false);
|
||||||
|
const [rematchMsg, setRematchMsg] = useState("");
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
fetch("/api/v1/admin/dashboard", { credentials: "include" })
|
fetch("/api/v1/admin/dashboard", { credentials: "include" })
|
||||||
|
|
@ -76,7 +79,7 @@ export default function DashboardPage() {
|
||||||
async function repairLibrary() {
|
async function repairLibrary() {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
"Bibliotheek herstellen na pad-migratie?\n\n• Duplicaten samenvoegen naar IMDb/TMDB-records\n• Dode (unavailable) bestanden verwijderen\n• Dubbele file-rijen opruimen\n• Ontbrekende IMDb-IDs backfillen (max ~400 per run)\n\nDuurt meestal 1–3 minuten. Herhaal indien nodig."
|
"Bibliotheek herstellen?\n\n• Duplicaten samenvoegen\n• Dode bestanden verwijderen\n• TMDB opnieuw matchen voor gescande titels\n• IMDb-IDs backfillen\n\nDuurt 1–3 minuten."
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -94,10 +97,13 @@ export default function DashboardPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const imdb = json.imdb ?? {};
|
const imdb = json.imdb ?? {};
|
||||||
|
const rematch = json.rematch ?? {};
|
||||||
const imdbRemaining =
|
const imdbRemaining =
|
||||||
(imdb.movies?.remaining ?? 0) + (imdb.series?.remaining ?? 0);
|
(imdb.movies?.remaining ?? 0) + (imdb.series?.remaining ?? 0);
|
||||||
|
const rematchRemaining =
|
||||||
|
(rematch.movies?.remaining ?? 0) + (rematch.series?.remaining ?? 0);
|
||||||
setRepairMsg(
|
setRepairMsg(
|
||||||
`Klaar: ${json.moviesMerged ?? 0} film-merges, ${json.seriesMerged ?? 0} serie-merges, ${json.staleFilesRemoved ?? 0} dode files, ${json.duplicateFilesRemoved ?? 0} dubbele files · IMDb +${imdb.movies?.updated ?? 0} films, +${imdb.series?.updated ?? 0} series${imdbRemaining > 0 ? ` · nog ${imdbRemaining} IMDb open (herhaal of gebruik backfill-knop)` : ""}. Herlaad daarna de Stremio-addon.`
|
`Klaar: ${json.moviesMerged ?? 0} film-merges, ${json.seriesMerged ?? 0} serie-merges, ${json.staleFilesRemoved ?? 0} dode files, ${json.duplicateFilesRemoved ?? 0} dubbele files · TMDB +${rematch.movies?.matched ?? 0} films, +${rematch.series?.matched ?? 0} series · IMDb +${imdb.movies?.updated ?? 0} films, +${imdb.series?.updated ?? 0} series${rematchRemaining > 0 ? ` · nog ${rematchRemaining} zonder TMDB` : ""}${imdbRemaining > 0 ? ` · nog ${imdbRemaining} zonder IMDb` : ""}.`
|
||||||
);
|
);
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -133,7 +139,7 @@ export default function DashboardPage() {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ limit: 100 }),
|
body: JSON.stringify({ limit: 50 }),
|
||||||
});
|
});
|
||||||
const json = await res.json().catch(() => ({}));
|
const json = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -163,8 +169,11 @@ export default function DashboardPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (remaining >= prevRemaining && updated === 0) {
|
if (remaining >= prevRemaining && updated === 0) {
|
||||||
|
const failed = (movies.failed ?? 0) + (series.failed ?? 0);
|
||||||
setImdbMsg(
|
setImdbMsg(
|
||||||
`Gestopt na batch ${batch}: films +${totalMoviesUpdated}, series +${totalSeriesUpdated} · nog ${remaining} zonder IMDb (niet beschikbaar op TMDB)`
|
failed > 0
|
||||||
|
? `TMDB rate limit of tijdelijke fout (${failed} mislukt in batch ${batch}). Wacht 1 minuut en probeer opnieuw. Nog ${remaining} open.`
|
||||||
|
: `Gestopt na batch ${batch}: nog ${remaining} zonder IMDb op TMDB (vaak nieuwe 2026-titels). Films +${totalMoviesUpdated}, series +${totalSeriesUpdated}.`
|
||||||
);
|
);
|
||||||
load();
|
load();
|
||||||
return;
|
return;
|
||||||
|
|
@ -185,6 +194,61 @@ export default function DashboardPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function rematchMetadata() {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Gescande titels zonder TMDB-metadata opnieuw matchen? Dit haalt posters/metadata op en helpt nieuwe films op de planken te tonen."
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRematchBusy(true);
|
||||||
|
setRematchMsg("Batch 1 bezig…");
|
||||||
|
let batch = 0;
|
||||||
|
let totalMovies = 0;
|
||||||
|
let totalSeries = 0;
|
||||||
|
try {
|
||||||
|
while (batch < 100) {
|
||||||
|
batch += 1;
|
||||||
|
setRematchMsg(`Batch ${batch} bezig…`);
|
||||||
|
const res = await fetch("/api/v1/admin/library/rematch-metadata", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ limit: 50 }),
|
||||||
|
});
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
setRematchMsg(json.error?.message ?? "Mislukt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const checked = (json.movies?.checked ?? 0) + (json.series?.checked ?? 0);
|
||||||
|
totalMovies += json.movies?.matched ?? 0;
|
||||||
|
totalSeries += json.series?.matched ?? 0;
|
||||||
|
const remaining = (json.movies?.remaining ?? 0) + (json.series?.remaining ?? 0);
|
||||||
|
if (remaining === 0) {
|
||||||
|
setRematchMsg(`Klaar: TMDB +${totalMovies} films, +${totalSeries} series`);
|
||||||
|
load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (checked === 0) {
|
||||||
|
setRematchMsg(`Klaar: +${totalMovies} films, +${totalSeries} series · nog ${remaining} niet gevonden op TMDB`);
|
||||||
|
load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRematchMsg(
|
||||||
|
`Batch ${batch}: +${json.movies?.matched ?? 0} films, +${json.series?.matched ?? 0} series · nog ${remaining} open…`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setRematchMsg(`Batchlimiet bereikt: +${totalMovies} films, +${totalSeries} series — druk opnieuw om verder te gaan`);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setRematchMsg(err instanceof Error ? `Mislukt: ${err.message}` : "Mislukt");
|
||||||
|
} finally {
|
||||||
|
setRematchBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function dedupe() {
|
async function dedupe() {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
|
|
@ -318,10 +382,21 @@ export default function DashboardPage() {
|
||||||
</button>
|
</button>
|
||||||
{dedupeMsg && <p className="muted tiny">{dedupeMsg}</p>}
|
{dedupeMsg && <p className="muted tiny">{dedupeMsg}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="tool-block">
|
||||||
|
<h3 className="card-subtitle">TMDB-metadata (gescande titels)</h3>
|
||||||
|
<p className="muted">
|
||||||
|
Films met stream maar zonder TMDB: {data.moviesMissingTmdb ?? "?"} — nodig voor posters en
|
||||||
|
planken.
|
||||||
|
</p>
|
||||||
|
<button type="button" onClick={rematchMetadata} disabled={rematchBusy}>
|
||||||
|
{rematchBusy ? "Bezig…" : "TMDB opnieuw matchen"}
|
||||||
|
</button>
|
||||||
|
{rematchMsg && <p className="muted tiny">{rematchMsg}</p>}
|
||||||
|
</div>
|
||||||
<div className="tool-block">
|
<div className="tool-block">
|
||||||
<h3 className="card-subtitle">IMDb-backfill</h3>
|
<h3 className="card-subtitle">IMDb-backfill</h3>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Films IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "}
|
Alleen voor titels die al TMDB hebben. Films IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "}
|
||||||
{data.moviesMissingImdb ?? "?"} · Series: {data.seriesWithImdb ?? "?"} / zonder{" "}
|
{data.moviesMissingImdb ?? "?"} · Series: {data.seriesWithImdb ?? "?"} / zonder{" "}
|
||||||
{data.seriesMissingImdb ?? "?"}
|
{data.seriesMissingImdb ?? "?"}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
availableFileCount,
|
availableFileCount,
|
||||||
moviesMissingImdb,
|
moviesMissingImdb,
|
||||||
moviesWithImdbStreams,
|
moviesWithImdbStreams,
|
||||||
|
moviesMissingTmdb,
|
||||||
seriesMissingImdb,
|
seriesMissingImdb,
|
||||||
seriesWithImdb,
|
seriesWithImdb,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
|
|
@ -71,6 +72,9 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
prisma.movie.count({
|
prisma.movie.count({
|
||||||
where: { imdbId: { not: null }, mediaFiles: { some: { available: true } } },
|
where: { imdbId: { not: null }, mediaFiles: { some: { available: true } } },
|
||||||
}),
|
}),
|
||||||
|
prisma.movie.count({
|
||||||
|
where: { tmdbId: null, mediaFiles: { some: { available: true } } },
|
||||||
|
}),
|
||||||
prisma.series.count({
|
prisma.series.count({
|
||||||
where: { imdbId: null, tmdbId: { not: null } },
|
where: { imdbId: null, tmdbId: { not: null } },
|
||||||
}),
|
}),
|
||||||
|
|
@ -111,6 +115,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
availableFiles: availableFileCount,
|
availableFiles: availableFileCount,
|
||||||
moviesMissingImdb,
|
moviesMissingImdb,
|
||||||
moviesWithImdbStreams,
|
moviesWithImdbStreams,
|
||||||
|
moviesMissingTmdb,
|
||||||
seriesMissingImdb,
|
seriesMissingImdb,
|
||||||
seriesWithImdb,
|
seriesWithImdb,
|
||||||
scannedFiles,
|
scannedFiles,
|
||||||
|
|
@ -519,8 +524,18 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
const limit =
|
const limit =
|
||||||
typeof body.limit === "number" && body.limit > 0
|
typeof body.limit === "number" && body.limit > 0
|
||||||
? Math.min(Math.floor(body.limit), 500)
|
? Math.min(Math.floor(body.limit), 500)
|
||||||
|
: 50;
|
||||||
|
const result = await metadata.backfillAllImdbIds({ delayMs: 200, limit });
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/library/rematch-metadata", { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const body = (request.body ?? {}) as { limit?: number };
|
||||||
|
const limit =
|
||||||
|
typeof body.limit === "number" && body.limit > 0
|
||||||
|
? Math.min(Math.floor(body.limit), 300)
|
||||||
: 100;
|
: 100;
|
||||||
const result = await metadata.backfillAllImdbIds({ delayMs: 80, limit });
|
const result = await metadata.rematchLibraryMetadata({ delayMs: 120, limit });
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,22 @@ const TMDB_STILL_BASE = "https://image.tmdb.org/t/p/w300";
|
||||||
const TMDB_LOGO_BASE = "https://image.tmdb.org/t/p/w300";
|
const TMDB_LOGO_BASE = "https://image.tmdb.org/t/p/w300";
|
||||||
const TMDB_BACKDROP_BASE = "https://image.tmdb.org/t/p/original";
|
const TMDB_BACKDROP_BASE = "https://image.tmdb.org/t/p/original";
|
||||||
const TMDB_LANG = "nl-NL";
|
const TMDB_LANG = "nl-NL";
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTmdbJson(url: string, retries = 4): Promise<Response | null> {
|
||||||
|
for (let attempt = 0; attempt < retries; attempt++) {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.status === 429) {
|
||||||
|
await sleep(1000 * (attempt + 1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const METAHUB_LOGO = (imdbId: string) =>
|
const METAHUB_LOGO = (imdbId: string) =>
|
||||||
`https://images.metahub.space/logo/medium/${imdbId}/img`;
|
`https://images.metahub.space/logo/medium/${imdbId}/img`;
|
||||||
|
|
||||||
|
|
@ -885,6 +901,7 @@ export class MetadataService {
|
||||||
seriesMerged: number;
|
seriesMerged: number;
|
||||||
staleFilesRemoved: number;
|
staleFilesRemoved: number;
|
||||||
duplicateFilesRemoved: number;
|
duplicateFilesRemoved: number;
|
||||||
|
rematch: Awaited<ReturnType<MetadataService["rematchLibraryMetadata"]>>;
|
||||||
imdb: Awaited<ReturnType<MetadataService["backfillAllImdbIds"]>>;
|
imdb: Awaited<ReturnType<MetadataService["backfillAllImdbIds"]>>;
|
||||||
}> {
|
}> {
|
||||||
const moviesMerged = await this.mergeAllDuplicateMovies();
|
const moviesMerged = await this.mergeAllDuplicateMovies();
|
||||||
|
|
@ -895,10 +912,142 @@ export class MetadataService {
|
||||||
|
|
||||||
const duplicateFilesRemoved = await this.removeDuplicateMediaFiles();
|
const duplicateFilesRemoved = await this.removeDuplicateMediaFiles();
|
||||||
|
|
||||||
// Cap IMDb backfill so repair finishes in reasonable time; run again if needed.
|
const rematch = await this.rematchLibraryMetadata({ delayMs: 100, limit: 200 });
|
||||||
const imdb = await this.backfillAllImdbIds({ delayMs: 80, limit: 400 });
|
|
||||||
|
|
||||||
return { moviesMerged, seriesMerged, staleFilesRemoved, duplicateFilesRemoved, imdb };
|
// Cap IMDb backfill so repair finishes in reasonable time; run again if needed.
|
||||||
|
const imdb = await this.backfillAllImdbIds({ delayMs: 200, limit: 200 });
|
||||||
|
|
||||||
|
return { moviesMerged, seriesMerged, staleFilesRemoved, duplicateFilesRemoved, rematch, imdb };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-match library titles that have streams but no TMDB id (common after scan-only adds).
|
||||||
|
*/
|
||||||
|
async rematchLibraryMetadata(options?: {
|
||||||
|
delayMs?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<{
|
||||||
|
movies: { checked: number; matched: number; remaining: number };
|
||||||
|
series: { checked: number; matched: number; remaining: number };
|
||||||
|
}> {
|
||||||
|
const delayMs = options?.delayMs ?? 120;
|
||||||
|
const limit = options?.limit && options.limit > 0 ? options.limit : 150;
|
||||||
|
if (!this.tmdbApiKey) {
|
||||||
|
const [movieRemaining, seriesRemaining] = await Promise.all([
|
||||||
|
prisma.movie.count({
|
||||||
|
where: { tmdbId: null, mediaFiles: { some: { available: true } } },
|
||||||
|
}),
|
||||||
|
prisma.series.count({
|
||||||
|
where: {
|
||||||
|
tmdbId: null,
|
||||||
|
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
movies: { checked: 0, matched: 0, remaining: movieRemaining },
|
||||||
|
series: { checked: 0, matched: 0, remaining: seriesRemaining },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingMovies = await prisma.movie.findMany({
|
||||||
|
where: { tmdbId: null, mediaFiles: { some: { available: true } } },
|
||||||
|
orderBy: { updatedAt: "asc" },
|
||||||
|
take: limit,
|
||||||
|
select: { id: true, title: true, year: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let moviesMatched = 0;
|
||||||
|
for (const movie of pendingMovies) {
|
||||||
|
const tmdb = await this.searchTmdbMovie(movie.title, movie.year ?? undefined);
|
||||||
|
if (tmdb) {
|
||||||
|
const canonical = await this.upsertMovieFromTmdb(tmdb);
|
||||||
|
if (canonical.id !== movie.id) {
|
||||||
|
await prisma.mediaFile.updateMany({
|
||||||
|
where: { movieId: movie.id },
|
||||||
|
data: { movieId: canonical.id },
|
||||||
|
});
|
||||||
|
await prisma.movie.delete({ where: { id: movie.id } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
moviesMatched += 1;
|
||||||
|
} else {
|
||||||
|
await prisma.movie
|
||||||
|
.update({ where: { id: movie.id }, data: { updatedAt: new Date() } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
if (delayMs > 0) await sleep(delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingSeries = await prisma.series.findMany({
|
||||||
|
where: {
|
||||||
|
tmdbId: null,
|
||||||
|
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "asc" },
|
||||||
|
take: limit,
|
||||||
|
select: { id: true, title: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let seriesMatched = 0;
|
||||||
|
for (const series of pendingSeries) {
|
||||||
|
const tmdb = await this.searchTmdbSeries(series.title);
|
||||||
|
if (tmdb) {
|
||||||
|
const canonical = await prisma.series.upsert({
|
||||||
|
where: { tmdbId: tmdb.id },
|
||||||
|
create: {
|
||||||
|
title: tmdb.title,
|
||||||
|
year: tmdb.year,
|
||||||
|
overview: tmdb.overview,
|
||||||
|
posterUrl: tmdb.posterUrl,
|
||||||
|
backdropUrl: tmdb.backdropUrl,
|
||||||
|
tmdbId: tmdb.id,
|
||||||
|
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
title: tmdb.title,
|
||||||
|
overview: tmdb.overview,
|
||||||
|
posterUrl: tmdb.posterUrl,
|
||||||
|
backdropUrl: tmdb.backdropUrl,
|
||||||
|
year: tmdb.year,
|
||||||
|
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (canonical.id !== series.id) {
|
||||||
|
await this.mergeSeriesInto(canonical.id, series.id);
|
||||||
|
}
|
||||||
|
seriesMatched += 1;
|
||||||
|
} else {
|
||||||
|
await prisma.series
|
||||||
|
.update({ where: { id: series.id }, data: { updatedAt: new Date() } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
if (delayMs > 0) await sleep(delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [movieRemaining, seriesRemaining] = await Promise.all([
|
||||||
|
prisma.movie.count({
|
||||||
|
where: { tmdbId: null, mediaFiles: { some: { available: true } } },
|
||||||
|
}),
|
||||||
|
prisma.series.count({
|
||||||
|
where: {
|
||||||
|
tmdbId: null,
|
||||||
|
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
movies: {
|
||||||
|
checked: pendingMovies.length,
|
||||||
|
matched: moviesMatched,
|
||||||
|
remaining: movieRemaining,
|
||||||
|
},
|
||||||
|
series: {
|
||||||
|
checked: pendingSeries.length,
|
||||||
|
matched: seriesMatched,
|
||||||
|
remaining: seriesRemaining,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private movieGroupKey(m: { imdbId: string | null; tmdbId: number | null; title: string; year: number | null }): string {
|
private movieGroupKey(m: { imdbId: string | null; tmdbId: number | null; title: string; year: number | null }): string {
|
||||||
|
|
@ -1822,10 +1971,10 @@ export class MetadataService {
|
||||||
private async fetchTmdbMovieImdbId(tmdbId: number): Promise<string | undefined> {
|
private async fetchTmdbMovieImdbId(tmdbId: number): Promise<string | undefined> {
|
||||||
if (!this.tmdbApiKey) return undefined;
|
if (!this.tmdbApiKey) return undefined;
|
||||||
const params = new URLSearchParams({ api_key: this.tmdbApiKey });
|
const params = new URLSearchParams({ api_key: this.tmdbApiKey });
|
||||||
const res = await fetch(
|
const res = await fetchTmdbJson(
|
||||||
`https://api.themoviedb.org/3/movie/${tmdbId}/external_ids?${params}`
|
`https://api.themoviedb.org/3/movie/${tmdbId}/external_ids?${params}`
|
||||||
);
|
);
|
||||||
if (!res.ok) return undefined;
|
if (!res?.ok) return undefined;
|
||||||
const data = (await res.json()) as { imdb_id?: string | null };
|
const data = (await res.json()) as { imdb_id?: string | null };
|
||||||
const id = data.imdb_id?.trim();
|
const id = data.imdb_id?.trim();
|
||||||
return id && /^tt\d+$/i.test(id) ? id : undefined;
|
return id && /^tt\d+$/i.test(id) ? id : undefined;
|
||||||
|
|
@ -1873,10 +2022,10 @@ export class MetadataService {
|
||||||
private async fetchTmdbSeriesImdbId(tmdbId: number): Promise<string | undefined> {
|
private async fetchTmdbSeriesImdbId(tmdbId: number): Promise<string | undefined> {
|
||||||
if (!this.tmdbApiKey) return undefined;
|
if (!this.tmdbApiKey) return undefined;
|
||||||
const params = new URLSearchParams({ api_key: this.tmdbApiKey });
|
const params = new URLSearchParams({ api_key: this.tmdbApiKey });
|
||||||
const res = await fetch(
|
const res = await fetchTmdbJson(
|
||||||
`https://api.themoviedb.org/3/tv/${tmdbId}/external_ids?${params}`
|
`https://api.themoviedb.org/3/tv/${tmdbId}/external_ids?${params}`
|
||||||
);
|
);
|
||||||
if (!res.ok) return undefined;
|
if (!res?.ok) return undefined;
|
||||||
const data = (await res.json()) as { imdb_id?: string | null };
|
const data = (await res.json()) as { imdb_id?: string | null };
|
||||||
const id = data.imdb_id?.trim();
|
const id = data.imdb_id?.trim();
|
||||||
return id && /^tt\d+$/i.test(id) ? id : undefined;
|
return id && /^tt\d+$/i.test(id) ? id : undefined;
|
||||||
|
|
|
||||||
|
|
@ -565,45 +565,45 @@ export class ViewerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fourKMovies(take: number) {
|
async fourKMovies(take: number) {
|
||||||
const movies = await prisma.movie.findMany({
|
const files = await prisma.mediaFile.findMany({
|
||||||
where: {
|
where: {
|
||||||
mediaFiles: {
|
available: true,
|
||||||
some: {
|
movieId: { not: null },
|
||||||
available: true,
|
OR: [
|
||||||
OR: [
|
{ resolution: { contains: "2160" } },
|
||||||
{ resolution: { contains: "2160" } },
|
{ resolution: { contains: "4K", mode: "insensitive" } },
|
||||||
{ resolution: { contains: "4K", mode: "insensitive" } },
|
{ resolution: { contains: "UHD", mode: "insensitive" } },
|
||||||
{ resolution: { contains: "UHD", mode: "insensitive" } },
|
],
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take,
|
take: Math.max(take * 3, take),
|
||||||
include: {
|
include: { movie: true },
|
||||||
mediaFiles: {
|
|
||||||
where: {
|
|
||||||
available: true,
|
|
||||||
OR: [
|
|
||||||
{ resolution: { contains: "2160" } },
|
|
||||||
{ resolution: { contains: "4K", mode: "insensitive" } },
|
|
||||||
{ resolution: { contains: "UHD", mode: "insensitive" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return movies.map((m) => ({
|
const seen = new Set<string>();
|
||||||
type: "movie" as const,
|
const out: Array<{
|
||||||
id: m.id,
|
type: "movie";
|
||||||
title: m.title,
|
id: string;
|
||||||
year: m.year,
|
title: string;
|
||||||
posterUrl: m.posterUrl,
|
year: number | null;
|
||||||
backdropUrl: m.backdropUrl,
|
posterUrl: string | null;
|
||||||
mediaFileId: m.mediaFiles[0]?.id ?? null,
|
backdropUrl: string | null;
|
||||||
}));
|
mediaFileId: string | null;
|
||||||
|
}> = [];
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.movieId || !f.movie || seen.has(f.movieId)) continue;
|
||||||
|
seen.add(f.movieId);
|
||||||
|
out.push({
|
||||||
|
type: "movie",
|
||||||
|
id: f.movie.id,
|
||||||
|
title: f.movie.title,
|
||||||
|
year: f.movie.year,
|
||||||
|
posterUrl: f.movie.posterUrl,
|
||||||
|
backdropUrl: f.movie.backdropUrl,
|
||||||
|
mediaFileId: f.id,
|
||||||
|
});
|
||||||
|
if (out.length >= take) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
|
async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) {
|
||||||
|
|
@ -619,66 +619,96 @@ export class ViewerService {
|
||||||
take: number
|
take: number
|
||||||
) {
|
) {
|
||||||
if (kind === "MOVIE") {
|
if (kind === "MOVIE") {
|
||||||
const where = {
|
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||||
mediaFiles: { some: { shelfId, available: true } },
|
SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count
|
||||||
};
|
FROM media_files mf
|
||||||
const [total, movies] = await Promise.all([
|
WHERE mf.shelf_id = ${shelfId}
|
||||||
prisma.movie.count({ where }),
|
AND mf.available = true
|
||||||
prisma.movie.findMany({
|
AND mf.movie_id IS NOT NULL
|
||||||
where,
|
`;
|
||||||
orderBy: [{ title: "asc" }, { year: "asc" }],
|
const total = Number(countRow?.count ?? 0);
|
||||||
skip,
|
const idRows = await prisma.$queryRaw<Array<{ movie_id: string }>>`
|
||||||
take,
|
SELECT mf.movie_id
|
||||||
include: {
|
FROM media_files mf
|
||||||
mediaFiles: {
|
WHERE mf.shelf_id = ${shelfId}
|
||||||
where: { shelfId, available: true },
|
AND mf.available = true
|
||||||
orderBy: { createdAt: "desc" },
|
AND mf.movie_id IS NOT NULL
|
||||||
take: 1,
|
GROUP BY mf.movie_id
|
||||||
},
|
ORDER BY MAX(mf.created_at) DESC
|
||||||
|
OFFSET ${skip}
|
||||||
|
LIMIT ${take}
|
||||||
|
`;
|
||||||
|
const ids = idRows.map((r) => r.movie_id);
|
||||||
|
if (!ids.length) return { total, items: [] };
|
||||||
|
|
||||||
|
const movies = await prisma.movie.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
include: {
|
||||||
|
mediaFiles: {
|
||||||
|
where: { shelfId, available: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 1,
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
]);
|
});
|
||||||
|
const byId = new Map(movies.map((m) => [m.id, m]));
|
||||||
return {
|
return {
|
||||||
total,
|
total,
|
||||||
items: movies.map((m) => ({
|
items: ids
|
||||||
type: "movie" as const,
|
.map((id) => byId.get(id))
|
||||||
id: m.id,
|
.filter((m): m is NonNullable<typeof m> => !!m)
|
||||||
title: m.title,
|
.map((m) => ({
|
||||||
year: m.year,
|
type: "movie" as const,
|
||||||
posterUrl: m.posterUrl,
|
id: m.id,
|
||||||
backdropUrl: m.backdropUrl,
|
title: m.title,
|
||||||
mediaFileId: m.mediaFiles[0]?.id ?? null,
|
year: m.year,
|
||||||
})),
|
posterUrl: m.posterUrl,
|
||||||
|
backdropUrl: m.backdropUrl,
|
||||||
|
mediaFileId: m.mediaFiles[0]?.id ?? null,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = {
|
const [countRow] = await prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||||
episodes: {
|
SELECT COUNT(DISTINCT e.series_id)::bigint AS count
|
||||||
some: {
|
FROM media_files mf
|
||||||
mediaFiles: { some: { shelfId, available: true } },
|
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||||
},
|
WHERE mf.shelf_id = ${shelfId}
|
||||||
},
|
AND mf.available = true
|
||||||
};
|
AND mf.episode_id IS NOT NULL
|
||||||
const [total, series] = await Promise.all([
|
`;
|
||||||
prisma.series.count({ where }),
|
const total = Number(countRow?.count ?? 0);
|
||||||
prisma.series.findMany({
|
const idRows = await prisma.$queryRaw<Array<{ series_id: string }>>`
|
||||||
where,
|
SELECT e.series_id
|
||||||
orderBy: [{ title: "asc" }, { year: "asc" }],
|
FROM media_files mf
|
||||||
skip,
|
INNER JOIN episodes e ON e.id = mf.episode_id
|
||||||
take,
|
WHERE mf.shelf_id = ${shelfId}
|
||||||
}),
|
AND mf.available = true
|
||||||
]);
|
AND mf.episode_id IS NOT NULL
|
||||||
|
GROUP BY e.series_id
|
||||||
|
ORDER BY MAX(mf.created_at) DESC
|
||||||
|
OFFSET ${skip}
|
||||||
|
LIMIT ${take}
|
||||||
|
`;
|
||||||
|
const ids = idRows.map((r) => r.series_id);
|
||||||
|
if (!ids.length) return { total, items: [] };
|
||||||
|
|
||||||
|
const series = await prisma.series.findMany({ where: { id: { in: ids } } });
|
||||||
|
const byId = new Map(series.map((s) => [s.id, s]));
|
||||||
return {
|
return {
|
||||||
total,
|
total,
|
||||||
items: series.map((s) => ({
|
items: ids
|
||||||
type: "series" as const,
|
.map((id) => byId.get(id))
|
||||||
id: s.id,
|
.filter((s): s is NonNullable<typeof s> => !!s)
|
||||||
title: s.title,
|
.map((s) => ({
|
||||||
year: s.year,
|
type: "series" as const,
|
||||||
posterUrl: s.posterUrl,
|
id: s.id,
|
||||||
backdropUrl: s.backdropUrl,
|
title: s.title,
|
||||||
mediaFileId: null as string | null,
|
year: s.year,
|
||||||
})),
|
posterUrl: s.posterUrl,
|
||||||
|
backdropUrl: s.backdropUrl,
|
||||||
|
mediaFileId: null as string | null,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue