Fix Stremio catalog pagination and reduce duplicate movie/series rows.
Catalogs now support skip so Stremio can load more than 100 titles; matching prefers TMDB and can merge orphans; admin can dedupe and see available vs scanned counts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b5236188c6
commit
f24b3f1409
6 changed files with 453 additions and 99 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Nav, useAuth } from "@/components/Nav";
|
import { Nav, useAuth } from "@/components/Nav";
|
||||||
|
|
||||||
interface Dashboard {
|
interface Dashboard {
|
||||||
|
|
@ -11,6 +11,11 @@ interface Dashboard {
|
||||||
movies: number;
|
movies: number;
|
||||||
series: number;
|
series: number;
|
||||||
episodes: number;
|
episodes: number;
|
||||||
|
availableMovies?: number;
|
||||||
|
availableSeries?: number;
|
||||||
|
mediaFiles?: number;
|
||||||
|
availableFiles?: number;
|
||||||
|
scannedFiles?: number;
|
||||||
activeStreams: number;
|
activeStreams: number;
|
||||||
currentBandwidth: string;
|
currentBandwidth: string;
|
||||||
}
|
}
|
||||||
|
|
@ -38,8 +43,9 @@ export default function DashboardPage() {
|
||||||
useAuth();
|
useAuth();
|
||||||
const [data, setData] = useState<Dashboard | null>(null);
|
const [data, setData] = useState<Dashboard | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [dedupeMsg, setDedupeMsg] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
const load = useCallback(() => {
|
||||||
fetch("/api/v1/admin/dashboard", { credentials: "include" })
|
fetch("/api/v1/admin/dashboard", { credentials: "include" })
|
||||||
.then(async (r) => {
|
.then(async (r) => {
|
||||||
const json = await r.json();
|
const json = await r.json();
|
||||||
|
|
@ -54,6 +60,34 @@ export default function DashboardPage() {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function dedupe() {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Duplicaten samenvoegen? Titels met dezelfde naam worden samengevoegd (TMDB-versie wint)."
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDedupeMsg("Bezig…");
|
||||||
|
const res = await fetch("/api/v1/admin/library/dedupe", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setDedupeMsg(json.error?.message ?? "Mislukt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDedupeMsg(
|
||||||
|
`Klaar: ${json.moviesMerged} film-duplicaten, ${json.seriesMerged} serie-duplicaten, ${json.emptied} lege records opgeruimd.`
|
||||||
|
);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Nav />
|
<Nav />
|
||||||
|
|
@ -61,6 +95,7 @@ export default function DashboardPage() {
|
||||||
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Dashboard</h1>
|
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Dashboard</h1>
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
{data && (
|
{data && (
|
||||||
|
<>
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Nodes Online</h2>
|
<h2>Nodes Online</h2>
|
||||||
|
|
@ -71,12 +106,25 @@ export default function DashboardPage() {
|
||||||
<div className="value">{data.nodesOffline ?? 0}</div>
|
<div className="value">{data.nodesOffline ?? 0}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Movies</h2>
|
<h2>Films (uniek)</h2>
|
||||||
<div className="value">{data.movies ?? 0}</div>
|
<div className="value">{data.availableMovies ?? data.movies ?? 0}</div>
|
||||||
|
<p style={{ color: "#71717a", fontSize: "0.75rem", marginTop: "0.5rem" }}>
|
||||||
|
{data.movies ?? 0} rijen totaal
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Series</h2>
|
<h2>Series (uniek)</h2>
|
||||||
<div className="value">{data.series ?? 0}</div>
|
<div className="value">{data.availableSeries ?? data.series ?? 0}</div>
|
||||||
|
<p style={{ color: "#71717a", fontSize: "0.75rem", marginTop: "0.5rem" }}>
|
||||||
|
{data.series ?? 0} rijen totaal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h2>Bestanden</h2>
|
||||||
|
<div className="value">{data.availableFiles ?? 0}</div>
|
||||||
|
<p style={{ color: "#71717a", fontSize: "0.75rem", marginTop: "0.5rem" }}>
|
||||||
|
{data.scannedFiles ?? 0} gescand op nodes
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Episodes</h2>
|
<h2>Episodes</h2>
|
||||||
|
|
@ -99,6 +147,22 @@ export default function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="card" style={{ marginTop: "1.5rem" }}>
|
||||||
|
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none" }}>
|
||||||
|
Bibliotheek opruimen
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", margin: "0.75rem 0" }}>
|
||||||
|
Dubbele film-/serie-rijen samenvoegen. Daarna in Stremio de addon verversen
|
||||||
|
(of herinstalleren) zodat de volledige catalogus met paginatie geladen wordt.
|
||||||
|
</p>
|
||||||
|
<button onClick={dedupe}>Duplicaten samenvoegen</button>
|
||||||
|
{dedupeMsg && (
|
||||||
|
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginTop: "0.75rem" }}>
|
||||||
|
{dedupeMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{!data && !error && <p style={{ color: "#71717a" }}>Laden...</p>}
|
{!data && !error && <p style={{ color: "#71717a" }}>Laden...</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
episodeCount,
|
episodeCount,
|
||||||
activeStreams,
|
activeStreams,
|
||||||
nodes,
|
nodes,
|
||||||
|
availableMovies,
|
||||||
|
availableSeries,
|
||||||
|
mediaFileCount,
|
||||||
|
availableFileCount,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
prisma.node.count({ where: { status: "ONLINE", revoked: false } }),
|
prisma.node.count({ where: { status: "ONLINE", revoked: false } }),
|
||||||
prisma.node.count({ where: { status: "OFFLINE", revoked: false } }),
|
prisma.node.count({ where: { status: "OFFLINE", revoked: false } }),
|
||||||
|
|
@ -32,13 +36,26 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
prisma.playbackSession.count({ where: { status: "ACTIVE", revoked: false } }),
|
prisma.playbackSession.count({ where: { status: "ACTIVE", revoked: false } }),
|
||||||
prisma.node.findMany({
|
prisma.node.findMany({
|
||||||
where: { revoked: false },
|
where: { revoked: false },
|
||||||
select: { totalStorage: true, freeStorage: true, currentBandwidth: true },
|
select: { totalStorage: true, freeStorage: true, currentBandwidth: true, libraryFileCount: true },
|
||||||
}),
|
}),
|
||||||
|
prisma.movie.count({ where: { mediaFiles: { some: { available: true } } } }),
|
||||||
|
prisma.series.count({
|
||||||
|
where: {
|
||||||
|
seasons: {
|
||||||
|
some: {
|
||||||
|
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.mediaFile.count(),
|
||||||
|
prisma.mediaFile.count({ where: { available: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalStorage = nodes.reduce((sum, n) => sum + n.totalStorage, 0n);
|
const totalStorage = nodes.reduce((sum, n) => sum + n.totalStorage, 0n);
|
||||||
const freeStorage = nodes.reduce((sum, n) => sum + n.freeStorage, 0n);
|
const freeStorage = nodes.reduce((sum, n) => sum + n.freeStorage, 0n);
|
||||||
const bandwidth = nodes.reduce((sum, n) => sum + n.currentBandwidth, 0n);
|
const bandwidth = nodes.reduce((sum, n) => sum + n.currentBandwidth, 0n);
|
||||||
|
const scannedFiles = nodes.reduce((sum, n) => sum + n.libraryFileCount, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodesOnline,
|
nodesOnline,
|
||||||
|
|
@ -48,6 +65,11 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
movies: movieCount,
|
movies: movieCount,
|
||||||
series: seriesCount,
|
series: seriesCount,
|
||||||
episodes: episodeCount,
|
episodes: episodeCount,
|
||||||
|
availableMovies,
|
||||||
|
availableSeries,
|
||||||
|
mediaFiles: mediaFileCount,
|
||||||
|
availableFiles: availableFileCount,
|
||||||
|
scannedFiles,
|
||||||
activeStreams,
|
activeStreams,
|
||||||
currentBandwidth: bandwidth.toString(),
|
currentBandwidth: bandwidth.toString(),
|
||||||
};
|
};
|
||||||
|
|
@ -65,7 +87,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
include: { node: { select: { id: true, name: true, status: true } } },
|
include: { node: { select: { id: true, name: true, status: true } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
take: 200,
|
take: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -131,7 +153,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
orderBy: { seasonNumber: "asc" },
|
orderBy: { seasonNumber: "asc" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
take: 100,
|
take: 2000,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -394,6 +416,11 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/admin/library/dedupe", { preHandler: requireAdmin }, async () => {
|
||||||
|
const result = await metadata.dedupeLibrary();
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => {
|
app.post("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => {
|
||||||
const body = request.body as {
|
const body = request.body as {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,12 @@ export function isVideoFile(filename: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanTitle(s: string): string {
|
function cleanTitle(s: string): string {
|
||||||
return s.replace(/[._-]+/g, " ").replace(/\s+/g, " ").trim();
|
let out = s.replace(/[._-]+/g, " ");
|
||||||
|
out = out.replace(
|
||||||
|
/\b(2160p|1080p|720p|480p|4K|UHD|BluRay|WEB-?DL|WEBRip|HDTV|DVDRip|REMUX|PROPER|REPACK|HEVC|H\.?264|H\.?265|x264|x265|AV1|XviD|TrueHD(?:\s*Atmos)?|DTS-?HD|DTS|AAC|AC-?3|EAC3|Atmos|HDR10?\+?|Dolby\s*Vision|DV)\b/gi,
|
||||||
|
" "
|
||||||
|
);
|
||||||
|
return out.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTags(base: string) {
|
function extractTags(base: string) {
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,14 @@ type TmdbMeta = {
|
||||||
backdropUrl?: string;
|
backdropUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MovieRow = Awaited<ReturnType<typeof prisma.movie.create>>;
|
||||||
|
type SeriesRow = Awaited<ReturnType<typeof prisma.series.create>>;
|
||||||
|
|
||||||
export class MetadataService {
|
export class MetadataService {
|
||||||
private movieCache = new Map<number, TmdbMeta>();
|
private movieCache = new Map<number, TmdbMeta>();
|
||||||
private seriesCache = new Map<number, TmdbMeta>();
|
private seriesCache = new Map<number, TmdbMeta>();
|
||||||
|
private movieSearchCache = new Map<string, TmdbMeta | null>();
|
||||||
|
private seriesSearchCache = new Map<string, TmdbMeta | null>();
|
||||||
private refreshedMovieIds = new Set<string>();
|
private refreshedMovieIds = new Set<string>();
|
||||||
private refreshedSeriesIds = new Set<string>();
|
private refreshedSeriesIds = new Set<string>();
|
||||||
|
|
||||||
|
|
@ -49,12 +54,143 @@ export class MetadataService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Merge duplicate movies/series that share a title (prefer TMDB canonical). */
|
||||||
|
async dedupeLibrary(): Promise<{ moviesMerged: number; seriesMerged: number; emptied: number }> {
|
||||||
|
let moviesMerged = 0;
|
||||||
|
let seriesMerged = 0;
|
||||||
|
let emptied = 0;
|
||||||
|
|
||||||
|
const movies = await prisma.movie.findMany({
|
||||||
|
orderBy: [{ tmdbId: { sort: "desc", nulls: "last" } }, { createdAt: "asc" }],
|
||||||
|
include: { _count: { select: { mediaFiles: true } } },
|
||||||
|
});
|
||||||
|
const movieGroups = new Map<string, typeof movies>();
|
||||||
|
for (const m of movies) {
|
||||||
|
const key = `${m.title.trim().toLowerCase()}::${m.year ?? ""}`;
|
||||||
|
const list = movieGroups.get(key) ?? [];
|
||||||
|
list.push(m);
|
||||||
|
movieGroups.set(key, list);
|
||||||
|
}
|
||||||
|
for (const group of movieGroups.values()) {
|
||||||
|
if (group.length < 2) continue;
|
||||||
|
const canonical =
|
||||||
|
group.find((m) => m.tmdbId != null) ??
|
||||||
|
group.find((m) => m._count.mediaFiles > 0) ??
|
||||||
|
group[0];
|
||||||
|
for (const orphan of group) {
|
||||||
|
if (orphan.id === canonical.id) continue;
|
||||||
|
const moved = await prisma.mediaFile.updateMany({
|
||||||
|
where: { movieId: orphan.id },
|
||||||
|
data: { movieId: canonical.id },
|
||||||
|
});
|
||||||
|
moviesMerged += moved.count > 0 ? 1 : 0;
|
||||||
|
await prisma.movie.delete({ where: { id: orphan.id } });
|
||||||
|
emptied += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seriesList = await prisma.series.findMany({
|
||||||
|
orderBy: [{ tmdbId: { sort: "desc", nulls: "last" } }, { createdAt: "asc" }],
|
||||||
|
});
|
||||||
|
const seriesGroups = new Map<string, typeof seriesList>();
|
||||||
|
for (const s of seriesList) {
|
||||||
|
const key = s.title.trim().toLowerCase();
|
||||||
|
const list = seriesGroups.get(key) ?? [];
|
||||||
|
list.push(s);
|
||||||
|
seriesGroups.set(key, list);
|
||||||
|
}
|
||||||
|
for (const group of seriesGroups.values()) {
|
||||||
|
if (group.length < 2) continue;
|
||||||
|
const canonical = group.find((s) => s.tmdbId != null) ?? group[0];
|
||||||
|
for (const orphan of group) {
|
||||||
|
if (orphan.id === canonical.id) continue;
|
||||||
|
await this.mergeSeriesInto(canonical.id, orphan.id);
|
||||||
|
seriesMerged += 1;
|
||||||
|
emptied += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop empty local shells (no TMDB, no files)
|
||||||
|
const emptyMovies = await prisma.movie.findMany({
|
||||||
|
where: { tmdbId: null, mediaFiles: { none: {} } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
for (const m of emptyMovies) {
|
||||||
|
await prisma.movie.delete({ where: { id: m.id } }).catch(() => undefined);
|
||||||
|
emptied += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { moviesMerged, seriesMerged, emptied };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mergeSeriesInto(canonicalId: string, orphanId: string): Promise<void> {
|
||||||
|
const orphanSeasons = await prisma.season.findMany({
|
||||||
|
where: { seriesId: orphanId },
|
||||||
|
include: { episodes: { include: { mediaFiles: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const season of orphanSeasons) {
|
||||||
|
let targetSeason = await prisma.season.findUnique({
|
||||||
|
where: {
|
||||||
|
seriesId_seasonNumber: {
|
||||||
|
seriesId: canonicalId,
|
||||||
|
seasonNumber: season.seasonNumber,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!targetSeason) {
|
||||||
|
targetSeason = await prisma.season.create({
|
||||||
|
data: {
|
||||||
|
seriesId: canonicalId,
|
||||||
|
seasonNumber: season.seasonNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ep of season.episodes) {
|
||||||
|
let targetEp = await prisma.episode.findUnique({
|
||||||
|
where: {
|
||||||
|
seriesId_seasonNumber_episodeNumber: {
|
||||||
|
seriesId: canonicalId,
|
||||||
|
seasonNumber: ep.seasonNumber,
|
||||||
|
episodeNumber: ep.episodeNumber,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!targetEp) {
|
||||||
|
targetEp = await prisma.episode.create({
|
||||||
|
data: {
|
||||||
|
seriesId: canonicalId,
|
||||||
|
seasonId: targetSeason.id,
|
||||||
|
seasonNumber: ep.seasonNumber,
|
||||||
|
episodeNumber: ep.episodeNumber,
|
||||||
|
title: ep.title,
|
||||||
|
overview: ep.overview,
|
||||||
|
airDate: ep.airDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.mediaFile.updateMany({
|
||||||
|
where: { episodeId: ep.id },
|
||||||
|
data: { episodeId: targetEp.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.episode.deleteMany({ where: { seriesId: orphanId } });
|
||||||
|
await prisma.season.deleteMany({ where: { seriesId: orphanId } });
|
||||||
|
await prisma.series.delete({ where: { id: orphanId } });
|
||||||
|
}
|
||||||
|
|
||||||
private async matchMovie(nodeId: string, file: MediaFileInfo): Promise<string | null> {
|
private async matchMovie(nodeId: string, file: MediaFileInfo): Promise<string | null> {
|
||||||
const parsed = file.parsedMovie!;
|
const parsed = file.parsedMovie!;
|
||||||
let movie = await this.findExistingMovie(parsed.title, parsed.year);
|
const title = parsed.title.trim();
|
||||||
|
const year = parsed.year;
|
||||||
|
let movie: MovieRow | null = null;
|
||||||
|
|
||||||
if (!movie && this.tmdbApiKey) {
|
// Prefer TMDB canonical title so NL/EN filenames collapse onto one record.
|
||||||
const tmdb = await this.searchTmdbMovie(parsed.title, parsed.year);
|
if (this.tmdbApiKey) {
|
||||||
|
const tmdb = await this.searchTmdbMovie(title, year);
|
||||||
if (tmdb) {
|
if (tmdb) {
|
||||||
movie = await prisma.movie.upsert({
|
movie = await prisma.movie.upsert({
|
||||||
where: { tmdbId: tmdb.id },
|
where: { tmdbId: tmdb.id },
|
||||||
|
|
@ -74,8 +210,15 @@ export class MetadataService {
|
||||||
year: tmdb.year,
|
year: tmdb.year,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.absorbOrphanMovies(movie, title, year);
|
||||||
}
|
}
|
||||||
} else if (
|
}
|
||||||
|
|
||||||
|
if (!movie) {
|
||||||
|
movie = await this.findExistingMovie(title, year);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
movie?.tmdbId &&
|
movie?.tmdbId &&
|
||||||
this.tmdbApiKey &&
|
this.tmdbApiKey &&
|
||||||
!this.refreshedMovieIds.has(movie.id)
|
!this.refreshedMovieIds.has(movie.id)
|
||||||
|
|
@ -87,8 +230,8 @@ export class MetadataService {
|
||||||
if (!movie) {
|
if (!movie) {
|
||||||
movie = await prisma.movie.create({
|
movie = await prisma.movie.create({
|
||||||
data: {
|
data: {
|
||||||
title: parsed.title,
|
title,
|
||||||
year: parsed.year,
|
year,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -99,12 +242,11 @@ export class MetadataService {
|
||||||
|
|
||||||
private async matchEpisode(nodeId: string, file: MediaFileInfo): Promise<string | null> {
|
private async matchEpisode(nodeId: string, file: MediaFileInfo): Promise<string | null> {
|
||||||
const parsed = file.parsedEpisode!;
|
const parsed = file.parsedEpisode!;
|
||||||
let series = await prisma.series.findFirst({
|
const title = parsed.title.trim();
|
||||||
where: { title: { equals: parsed.title, mode: "insensitive" } },
|
let series: SeriesRow | null = await this.findExistingSeries(title);
|
||||||
});
|
|
||||||
|
|
||||||
if (!series && this.tmdbApiKey) {
|
if (this.tmdbApiKey && (!series || series.tmdbId == null)) {
|
||||||
const tmdb = await this.searchTmdbSeries(parsed.title);
|
const tmdb = await this.searchTmdbSeries(title);
|
||||||
if (tmdb) {
|
if (tmdb) {
|
||||||
series = await prisma.series.upsert({
|
series = await prisma.series.upsert({
|
||||||
where: { tmdbId: tmdb.id },
|
where: { tmdbId: tmdb.id },
|
||||||
|
|
@ -124,8 +266,15 @@ export class MetadataService {
|
||||||
year: tmdb.year,
|
year: tmdb.year,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.absorbOrphanSeries(series, title);
|
||||||
}
|
}
|
||||||
} else if (
|
}
|
||||||
|
|
||||||
|
if (!series) {
|
||||||
|
series = await this.findExistingSeries(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
series?.tmdbId &&
|
series?.tmdbId &&
|
||||||
this.tmdbApiKey &&
|
this.tmdbApiKey &&
|
||||||
!this.refreshedSeriesIds.has(series.id)
|
!this.refreshedSeriesIds.has(series.id)
|
||||||
|
|
@ -136,7 +285,7 @@ export class MetadataService {
|
||||||
|
|
||||||
if (!series) {
|
if (!series) {
|
||||||
series = await prisma.series.create({
|
series = await prisma.series.create({
|
||||||
data: { title: parsed.title },
|
data: { title },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,6 +333,56 @@ export class MetadataService {
|
||||||
return episode.id;
|
return episode.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async absorbOrphanMovies(
|
||||||
|
canonical: MovieRow,
|
||||||
|
localTitle: string,
|
||||||
|
year?: number
|
||||||
|
): Promise<void> {
|
||||||
|
const titles = Array.from(
|
||||||
|
new Set([canonical.title.trim(), localTitle.trim()].filter(Boolean))
|
||||||
|
);
|
||||||
|
const orphans = await prisma.movie.findMany({
|
||||||
|
where: {
|
||||||
|
id: { not: canonical.id },
|
||||||
|
tmdbId: null,
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: titles.map((t) => ({
|
||||||
|
title: { equals: t, mode: "insensitive" as const },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
...(year ? [{ OR: [{ year }, { year: null }] }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const orphan of orphans) {
|
||||||
|
await prisma.mediaFile.updateMany({
|
||||||
|
where: { movieId: orphan.id },
|
||||||
|
data: { movieId: canonical.id },
|
||||||
|
});
|
||||||
|
await prisma.movie.delete({ where: { id: orphan.id } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async absorbOrphanSeries(canonical: SeriesRow, localTitle: string): Promise<void> {
|
||||||
|
const titles = Array.from(
|
||||||
|
new Set([canonical.title.trim(), localTitle.trim()].filter(Boolean))
|
||||||
|
);
|
||||||
|
const orphans = await prisma.series.findMany({
|
||||||
|
where: {
|
||||||
|
id: { not: canonical.id },
|
||||||
|
tmdbId: null,
|
||||||
|
OR: titles.map((t) => ({
|
||||||
|
title: { equals: t, mode: "insensitive" as const },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const orphan of orphans) {
|
||||||
|
await this.mergeSeriesInto(canonical.id, orphan.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async upsertMediaFile(
|
private async upsertMediaFile(
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
file: MediaFileInfo,
|
file: MediaFileInfo,
|
||||||
|
|
@ -229,17 +428,29 @@ export class MetadataService {
|
||||||
title: { equals: title, mode: "insensitive" },
|
title: { equals: title, mode: "insensitive" },
|
||||||
year,
|
year,
|
||||||
},
|
},
|
||||||
|
orderBy: { tmdbId: { sort: "desc", nulls: "last" } },
|
||||||
});
|
});
|
||||||
if (withYear) return withYear;
|
if (withYear) return withYear;
|
||||||
}
|
}
|
||||||
// Title-only fallback avoids duplicates when older rows lacked a year.
|
|
||||||
return prisma.movie.findFirst({
|
return prisma.movie.findFirst({
|
||||||
where: { title: { equals: title, mode: "insensitive" } },
|
where: { title: { equals: title, mode: "insensitive" } },
|
||||||
|
orderBy: { tmdbId: { sort: "desc", nulls: "last" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findExistingSeries(title: string) {
|
||||||
|
return prisma.series.findFirst({
|
||||||
|
where: { title: { equals: title, mode: "insensitive" } },
|
||||||
|
orderBy: { tmdbId: { sort: "desc", nulls: "last" } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async searchTmdbMovie(title: string, year?: number) {
|
private async searchTmdbMovie(title: string, year?: number) {
|
||||||
if (!this.tmdbApiKey) return null;
|
if (!this.tmdbApiKey) return null;
|
||||||
|
const cacheKey = `${title.toLowerCase()}::${year ?? ""}`;
|
||||||
|
if (this.movieSearchCache.has(cacheKey)) {
|
||||||
|
return this.movieSearchCache.get(cacheKey) ?? null;
|
||||||
|
}
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
api_key: this.tmdbApiKey,
|
api_key: this.tmdbApiKey,
|
||||||
query: title,
|
query: title,
|
||||||
|
|
@ -248,15 +459,27 @@ export class MetadataService {
|
||||||
...(year ? { year: String(year) } : {}),
|
...(year ? { year: String(year) } : {}),
|
||||||
});
|
});
|
||||||
const res = await fetch(`https://api.themoviedb.org/3/search/movie?${params}`);
|
const res = await fetch(`https://api.themoviedb.org/3/search/movie?${params}`);
|
||||||
if (!res.ok) return null;
|
if (!res.ok) {
|
||||||
|
this.movieSearchCache.set(cacheKey, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const data = (await res.json()) as TmdbSearchResult;
|
const data = (await res.json()) as TmdbSearchResult;
|
||||||
const item = data.results[0];
|
const item = data.results[0];
|
||||||
if (!item) return null;
|
if (!item) {
|
||||||
return this.getTmdbMovieDetails(item.id, year);
|
this.movieSearchCache.set(cacheKey, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const meta = await this.getTmdbMovieDetails(item.id, year);
|
||||||
|
this.movieSearchCache.set(cacheKey, meta);
|
||||||
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async searchTmdbSeries(title: string) {
|
private async searchTmdbSeries(title: string) {
|
||||||
if (!this.tmdbApiKey) return null;
|
if (!this.tmdbApiKey) return null;
|
||||||
|
const cacheKey = title.toLowerCase();
|
||||||
|
if (this.seriesSearchCache.has(cacheKey)) {
|
||||||
|
return this.seriesSearchCache.get(cacheKey) ?? null;
|
||||||
|
}
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
api_key: this.tmdbApiKey,
|
api_key: this.tmdbApiKey,
|
||||||
query: title,
|
query: title,
|
||||||
|
|
@ -264,11 +487,19 @@ export class MetadataService {
|
||||||
include_adult: "false",
|
include_adult: "false",
|
||||||
});
|
});
|
||||||
const res = await fetch(`https://api.themoviedb.org/3/search/tv?${params}`);
|
const res = await fetch(`https://api.themoviedb.org/3/search/tv?${params}`);
|
||||||
if (!res.ok) return null;
|
if (!res.ok) {
|
||||||
|
this.seriesSearchCache.set(cacheKey, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const data = (await res.json()) as TmdbSearchResult;
|
const data = (await res.json()) as TmdbSearchResult;
|
||||||
const item = data.results[0];
|
const item = data.results[0];
|
||||||
if (!item) return null;
|
if (!item) {
|
||||||
return this.getTmdbSeriesDetails(item.id);
|
this.seriesSearchCache.set(cacheKey, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const meta = await this.getTmdbSeriesDetails(item.id);
|
||||||
|
this.seriesSearchCache.set(cacheKey, meta);
|
||||||
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getTmdbMovieDetails(tmdbId: number, fallbackYear?: number): Promise<TmdbMeta | null> {
|
private async getTmdbMovieDetails(tmdbId: number, fallbackYear?: number): Promise<TmdbMeta | null> {
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,26 @@ import type { Config } from "../config";
|
||||||
|
|
||||||
const ID_PREFIX = "mc";
|
const ID_PREFIX = "mc";
|
||||||
|
|
||||||
|
const CATALOG_PAGE = 100;
|
||||||
|
|
||||||
|
const SEARCH_SKIP_EXTRAS = [
|
||||||
|
{ name: "search", isRequired: false },
|
||||||
|
{ name: "skip", isRequired: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SKIP_EXTRA = [{ name: "skip", isRequired: false }];
|
||||||
|
|
||||||
const MANIFEST = {
|
const MANIFEST = {
|
||||||
id: "com.mediacluster.library",
|
id: "com.mediacluster.library",
|
||||||
version: "1.1.0",
|
version: "1.2.0",
|
||||||
name: "Media Cluster",
|
name: "Media Cluster",
|
||||||
description: "Private distributed media library",
|
description: "Private distributed media library",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{ type: "movie", id: "movies", name: "Films", extra: [{ name: "search", isRequired: false }] },
|
{ type: "movie", id: "movies", name: "Films", extra: SEARCH_SKIP_EXTRAS },
|
||||||
{ type: "series", id: "series", name: "Series", extra: [{ name: "search", isRequired: false }] },
|
{ type: "series", id: "series", name: "Series", extra: SEARCH_SKIP_EXTRAS },
|
||||||
{ type: "movie", id: "recent-movies", name: "Recent toegevoegd" },
|
{ type: "movie", id: "recent-movies", name: "Recent toegevoegd", extra: SKIP_EXTRA },
|
||||||
{ type: "series", id: "recent-series", name: "Recent toegevoegde series" },
|
{ type: "series", id: "recent-series", name: "Recent toegevoegde series", extra: SKIP_EXTRA },
|
||||||
{ type: "movie", id: "4k-movies", name: "4K films" },
|
{ type: "movie", id: "4k-movies", name: "4K films", extra: SEARCH_SKIP_EXTRAS },
|
||||||
],
|
],
|
||||||
resources: ["catalog", "meta", "stream"],
|
resources: ["catalog", "meta", "stream"],
|
||||||
types: ["movie", "series"],
|
types: ["movie", "series"],
|
||||||
|
|
@ -75,13 +84,13 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
|
||||||
return reply.status(401).send({ error: "Invalid addon token" });
|
return reply.status(401).send({ error: "Invalid addon token" });
|
||||||
}
|
}
|
||||||
const extra = (request.params as { "*": string })["*"] ?? "";
|
const extra = (request.params as { "*": string })["*"] ?? "";
|
||||||
const search = parseExtraSearch(extra);
|
const { search, skip } = parseCatalogExtra(extra);
|
||||||
|
|
||||||
if (type === "movie") {
|
if (type === "movie") {
|
||||||
return { metas: await getMovieCatalog(id, config, search) };
|
return { metas: await getMovieCatalog(id, config, search, skip) };
|
||||||
}
|
}
|
||||||
if (type === "series") {
|
if (type === "series") {
|
||||||
return { metas: await getSeriesCatalog(id, config, search) };
|
return { metas: await getSeriesCatalog(id, config, search, skip) };
|
||||||
}
|
}
|
||||||
return { metas: [] };
|
return { metas: [] };
|
||||||
});
|
});
|
||||||
|
|
@ -158,17 +167,29 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseExtraSearch(extra: string): string | undefined {
|
function parseCatalogExtra(extra: string): { search?: string; skip: number } {
|
||||||
const decoded = decodeURIComponent(extra.replace(/\.json$/, ""));
|
const decoded = decodeURIComponent(extra.replace(/\.json$/, ""));
|
||||||
const parts = decoded.split("&");
|
const parts = decoded.split("&");
|
||||||
|
let search: string | undefined;
|
||||||
|
let skip = 0;
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
const [key, ...rest] = part.split("=");
|
const [key, ...rest] = part.split("=");
|
||||||
if (key === "search") return rest.join("=");
|
const value = rest.join("=");
|
||||||
|
if (key === "search" && value) search = value;
|
||||||
|
if (key === "skip") {
|
||||||
|
const n = parseInt(value, 10);
|
||||||
|
if (!Number.isNaN(n) && n > 0) skip = n;
|
||||||
}
|
}
|
||||||
return undefined;
|
}
|
||||||
|
return { search, skip };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMovieCatalog(catalogId: string, config: Config, search?: string) {
|
async function getMovieCatalog(
|
||||||
|
catalogId: string,
|
||||||
|
config: Config,
|
||||||
|
search?: string,
|
||||||
|
skip = 0
|
||||||
|
) {
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (config.HIDE_UNAVAILABLE_TITLES) {
|
if (config.HIDE_UNAVAILABLE_TITLES) {
|
||||||
where.mediaFiles = { some: { available: true } };
|
where.mediaFiles = { some: { available: true } };
|
||||||
|
|
@ -179,8 +200,9 @@ async function getMovieCatalog(catalogId: string, config: Config, search?: strin
|
||||||
|
|
||||||
let movies = await prisma.movie.findMany({
|
let movies = await prisma.movie.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { title: "asc" },
|
orderBy: [{ title: "asc" }, { year: "asc" }],
|
||||||
take: 100,
|
skip,
|
||||||
|
take: CATALOG_PAGE,
|
||||||
include: { mediaFiles: { where: { available: true } } },
|
include: { mediaFiles: { where: { available: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -188,7 +210,8 @@ async function getMovieCatalog(catalogId: string, config: Config, search?: strin
|
||||||
movies = await prisma.movie.findMany({
|
movies = await prisma.movie.findMany({
|
||||||
where: { mediaFiles: { some: { available: true } } },
|
where: { mediaFiles: { some: { available: true } } },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 50,
|
skip,
|
||||||
|
take: CATALOG_PAGE,
|
||||||
include: { mediaFiles: { where: { available: true } } },
|
include: { mediaFiles: { where: { available: true } } },
|
||||||
});
|
});
|
||||||
} else if (catalogId === "4k-movies") {
|
} else if (catalogId === "4k-movies") {
|
||||||
|
|
@ -205,8 +228,9 @@ async function getMovieCatalog(catalogId: string, config: Config, search?: strin
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { title: "asc" },
|
orderBy: [{ title: "asc" }, { year: "asc" }],
|
||||||
take: 100,
|
skip,
|
||||||
|
take: CATALOG_PAGE,
|
||||||
include: { mediaFiles: { where: { available: true } } },
|
include: { mediaFiles: { where: { available: true } } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -225,35 +249,36 @@ async function getMovieCatalog(catalogId: string, config: Config, search?: strin
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSeriesCatalog(catalogId: string, config: Config, search?: string) {
|
async function getSeriesCatalog(
|
||||||
let series = await prisma.series.findMany({
|
catalogId: string,
|
||||||
where: {
|
config: Config,
|
||||||
...(search ? { title: { contains: search, mode: "insensitive" } } : {}),
|
search?: string,
|
||||||
...(config.HIDE_UNAVAILABLE_TITLES
|
skip = 0
|
||||||
? {
|
) {
|
||||||
|
const availableFilter = {
|
||||||
seasons: {
|
seasons: {
|
||||||
some: {
|
some: {
|
||||||
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
: {}),
|
|
||||||
|
let series = await prisma.series.findMany({
|
||||||
|
where: {
|
||||||
|
...(search ? { title: { contains: search, mode: "insensitive" } } : {}),
|
||||||
|
...(config.HIDE_UNAVAILABLE_TITLES ? availableFilter : {}),
|
||||||
},
|
},
|
||||||
orderBy: { title: "asc" },
|
orderBy: [{ title: "asc" }, { year: "asc" }],
|
||||||
take: 100,
|
skip,
|
||||||
|
take: CATALOG_PAGE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (catalogId === "recent-series" && !search) {
|
if (catalogId === "recent-series" && !search) {
|
||||||
series = await prisma.series.findMany({
|
series = await prisma.series.findMany({
|
||||||
where: {
|
where: availableFilter,
|
||||||
seasons: {
|
|
||||||
some: {
|
|
||||||
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 50,
|
skip,
|
||||||
|
take: CATALOG_PAGE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,8 @@ func cleanTitle(s string) string {
|
||||||
s = strings.ReplaceAll(s, ".", " ")
|
s = strings.ReplaceAll(s, ".", " ")
|
||||||
s = strings.ReplaceAll(s, "_", " ")
|
s = strings.ReplaceAll(s, "_", " ")
|
||||||
s = strings.ReplaceAll(s, "-", " ")
|
s = strings.ReplaceAll(s, "-", " ")
|
||||||
|
tagRe := regexp.MustCompile(`(?i)\b(2160p|1080p|720p|480p|4K|UHD|BluRay|WEB-?DL|WEBRip|HDTV|DVDRip|REMUX|PROPER|REPACK|HEVC|H\.?264|H\.?265|x264|x265|AV1|XviD|TrueHD(\s*Atmos)?|DTS-?HD|DTS|AAC|AC-?3|EAC3|Atmos|HDR10?\+?|Dolby\s*Vision|DV)\b`)
|
||||||
|
s = tagRe.ReplaceAllString(s, " ")
|
||||||
return strings.Join(strings.Fields(s), " ")
|
return strings.Join(strings.Fields(s), " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue