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:
Jos Vooges | STH 2026-08-25 03:55:27 +02:00
parent b5236188c6
commit f24b3f1409
6 changed files with 453 additions and 99 deletions

View file

@ -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,44 +95,74 @@ 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="card"> <div className="grid">
<h2>Nodes Online</h2> <div className="card">
<div className="value">{data.nodesOnline ?? 0}</div> <h2>Nodes Online</h2>
</div> <div className="value">{data.nodesOnline ?? 0}</div>
<div className="card"> </div>
<h2>Nodes Offline</h2> <div className="card">
<div className="value">{data.nodesOffline ?? 0}</div> <h2>Nodes Offline</h2>
</div> <div className="value">{data.nodesOffline ?? 0}</div>
<div className="card"> </div>
<h2>Movies</h2> <div className="card">
<div className="value">{data.movies ?? 0}</div> <h2>Films (uniek)</h2>
</div> <div className="value">{data.availableMovies ?? data.movies ?? 0}</div>
<div className="card"> <p style={{ color: "#71717a", fontSize: "0.75rem", marginTop: "0.5rem" }}>
<h2>Series</h2> {data.movies ?? 0} rijen totaal
<div className="value">{data.series ?? 0}</div> </p>
</div> </div>
<div className="card"> <div className="card">
<h2>Episodes</h2> <h2>Series (uniek)</h2>
<div className="value">{data.episodes ?? 0}</div> <div className="value">{data.availableSeries ?? data.series ?? 0}</div>
</div> <p style={{ color: "#71717a", fontSize: "0.75rem", marginTop: "0.5rem" }}>
<div className="card"> {data.series ?? 0} rijen totaal
<h2>Active Streams</h2> </p>
<div className="value">{data.activeStreams ?? 0}</div> </div>
</div> <div className="card">
<div className="card"> <h2>Bestanden</h2>
<h2>Total Storage</h2> <div className="value">{data.availableFiles ?? 0}</div>
<div className="value" style={{ fontSize: "1.5rem" }}> <p style={{ color: "#71717a", fontSize: "0.75rem", marginTop: "0.5rem" }}>
{formatBytes(data.totalStorage)} {data.scannedFiles ?? 0} gescand op nodes
</p>
</div>
<div className="card">
<h2>Episodes</h2>
<div className="value">{data.episodes ?? 0}</div>
</div>
<div className="card">
<h2>Active Streams</h2>
<div className="value">{data.activeStreams ?? 0}</div>
</div>
<div className="card">
<h2>Total Storage</h2>
<div className="value" style={{ fontSize: "1.5rem" }}>
{formatBytes(data.totalStorage)}
</div>
</div>
<div className="card">
<h2>Free Storage</h2>
<div className="value" style={{ fontSize: "1.5rem" }}>
{formatBytes(data.freeStorage)}
</div>
</div> </div>
</div> </div>
<div className="card"> <div className="card" style={{ marginTop: "1.5rem" }}>
<h2>Free Storage</h2> <h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none" }}>
<div className="value" style={{ fontSize: "1.5rem" }}> Bibliotheek opruimen
{formatBytes(data.freeStorage)} </h2>
</div> <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> </div>
</div> </>
)} )}
{!data && !error && <p style={{ color: "#71717a" }}>Laden...</p>} {!data && !error && <p style={{ color: "#71717a" }}>Laden...</p>}
</div> </div>

View file

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

View file

@ -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) {

View file

@ -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> {

View file

@ -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(
catalogId: string,
config: Config,
search?: string,
skip = 0
) {
const availableFilter = {
seasons: {
some: {
episodes: { some: { mediaFiles: { some: { available: true } } } },
},
},
};
let series = await prisma.series.findMany({ let series = await prisma.series.findMany({
where: { where: {
...(search ? { title: { contains: search, mode: "insensitive" } } : {}), ...(search ? { title: { contains: search, mode: "insensitive" } } : {}),
...(config.HIDE_UNAVAILABLE_TITLES ...(config.HIDE_UNAVAILABLE_TITLES ? availableFilter : {}),
? {
seasons: {
some: {
episodes: { some: { mediaFiles: { some: { available: true } } } },
},
},
}
: {}),
}, },
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,
}); });
} }

View file

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