Fix series showing only S01E01: consolidate duplicate series and list from files.
Merge title/TMDB siblings on meta open, drop defaultVideoId, show episode file counts in admin. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8b3ef15dc9
commit
38bd4a822e
4 changed files with 168 additions and 84 deletions
|
|
@ -8,6 +8,8 @@ interface Series {
|
||||||
title: string;
|
title: string;
|
||||||
year: number | null;
|
year: number | null;
|
||||||
imdbId: string | null;
|
imdbId: string | null;
|
||||||
|
episodesWithFiles?: number;
|
||||||
|
fileCount?: number;
|
||||||
seasons: Array<{
|
seasons: Array<{
|
||||||
seasonNumber: number;
|
seasonNumber: number;
|
||||||
episodes: Array<{
|
episodes: Array<{
|
||||||
|
|
@ -43,7 +45,8 @@ export default function SeriesPage() {
|
||||||
>
|
>
|
||||||
<strong>{s.title}</strong>
|
<strong>{s.title}</strong>
|
||||||
<span style={{ color: "#71717a" }}>
|
<span style={{ color: "#71717a" }}>
|
||||||
{s.seasons.length} season(s) · {expanded === s.id ? "▲" : "▼"}
|
{s.episodesWithFiles ?? 0} afleveringen · {s.fileCount ?? 0} bestanden ·{" "}
|
||||||
|
{s.seasons.length} seizoen(en) · {expanded === s.id ? "▲" : "▼"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{expanded === s.id && s.seasons.map((season) => (
|
{expanded === s.id && s.seasons.map((season) => (
|
||||||
|
|
|
||||||
|
|
@ -157,31 +157,44 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
series: series.map((s) => ({
|
series: series.map((s) => {
|
||||||
id: s.id,
|
const episodesWithFiles = s.seasons.reduce(
|
||||||
title: s.title,
|
(n, season) => n + season.episodes.filter((ep) => ep.mediaFiles.length > 0).length,
|
||||||
year: s.year,
|
0
|
||||||
imdbId: s.imdbId,
|
);
|
||||||
tmdbId: s.tmdbId,
|
const fileCount = s.seasons.reduce(
|
||||||
posterUrl: s.posterUrl,
|
(n, season) =>
|
||||||
seasons: s.seasons.map((season) => ({
|
n + season.episodes.reduce((m, ep) => m + ep.mediaFiles.length, 0),
|
||||||
seasonNumber: season.seasonNumber,
|
0
|
||||||
episodes: season.episodes.map((ep) => ({
|
);
|
||||||
id: ep.id,
|
return {
|
||||||
seasonNumber: ep.seasonNumber,
|
id: s.id,
|
||||||
episodeNumber: ep.episodeNumber,
|
title: s.title,
|
||||||
title: ep.title,
|
year: s.year,
|
||||||
files: ep.mediaFiles.map((f) => ({
|
imdbId: s.imdbId,
|
||||||
id: f.id,
|
tmdbId: s.tmdbId,
|
||||||
node: f.node.name,
|
posterUrl: s.posterUrl,
|
||||||
nodeStatus: f.node.status,
|
episodesWithFiles,
|
||||||
resolution: f.resolution,
|
fileCount,
|
||||||
available: f.available,
|
seasons: s.seasons.map((season) => ({
|
||||||
releaseName: f.releaseName,
|
seasonNumber: season.seasonNumber,
|
||||||
|
episodes: season.episodes.map((ep) => ({
|
||||||
|
id: ep.id,
|
||||||
|
seasonNumber: ep.seasonNumber,
|
||||||
|
episodeNumber: ep.episodeNumber,
|
||||||
|
title: ep.title,
|
||||||
|
files: ep.mediaFiles.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
node: f.node.name,
|
||||||
|
nodeStatus: f.node.status,
|
||||||
|
resolution: f.resolution,
|
||||||
|
available: f.available,
|
||||||
|
releaseName: f.releaseName,
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
})),
|
};
|
||||||
})),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,16 @@ function parseAirDate(raw?: string): Date | undefined {
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Collapse punctuation/spacing so "13 Reasons Why" == "13_Reasons_Why". */
|
||||||
|
function normalizeSeriesKey(title: string): string {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/&/g, "and")
|
||||||
|
.replace(/[^a-z0-9]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
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>();
|
||||||
|
|
@ -128,7 +138,12 @@ export class MetadataService {
|
||||||
});
|
});
|
||||||
const seriesGroups = new Map<string, typeof seriesList>();
|
const seriesGroups = new Map<string, typeof seriesList>();
|
||||||
for (const s of seriesList) {
|
for (const s of seriesList) {
|
||||||
const key = s.title.trim().toLowerCase();
|
const key =
|
||||||
|
s.tmdbId != null
|
||||||
|
? `tmdb:${s.tmdbId}`
|
||||||
|
: s.imdbId
|
||||||
|
? `imdb:${s.imdbId}`
|
||||||
|
: `title:${normalizeSeriesKey(s.title)}`;
|
||||||
const list = seriesGroups.get(key) ?? [];
|
const list = seriesGroups.get(key) ?? [];
|
||||||
list.push(s);
|
list.push(s);
|
||||||
seriesGroups.set(key, list);
|
seriesGroups.set(key, list);
|
||||||
|
|
@ -158,6 +173,7 @@ export class MetadataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async mergeSeriesInto(canonicalId: string, orphanId: string): Promise<void> {
|
private async mergeSeriesInto(canonicalId: string, orphanId: string): Promise<void> {
|
||||||
|
if (canonicalId === orphanId) return;
|
||||||
const orphanSeasons = await prisma.season.findMany({
|
const orphanSeasons = await prisma.season.findMany({
|
||||||
where: { seriesId: orphanId },
|
where: { seriesId: orphanId },
|
||||||
include: { episodes: { include: { mediaFiles: true } } },
|
include: { episodes: { include: { mediaFiles: true } } },
|
||||||
|
|
@ -216,6 +232,37 @@ export class MetadataService {
|
||||||
await prisma.series.delete({ where: { id: orphanId } });
|
await prisma.series.delete({ where: { id: orphanId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull duplicate series rows (same TMDB/IMDb/normalized title) into one canonical
|
||||||
|
* so Stremio meta sees every episode file, not just the TMDB shell with one match.
|
||||||
|
*/
|
||||||
|
async consolidateSeries(seriesId: string): Promise<number> {
|
||||||
|
const canonical = await prisma.series.findUnique({ where: { id: seriesId } });
|
||||||
|
if (!canonical) return 0;
|
||||||
|
|
||||||
|
const all = await prisma.series.findMany({
|
||||||
|
select: { id: true, title: true, tmdbId: true, imdbId: true },
|
||||||
|
});
|
||||||
|
const norm = normalizeSeriesKey(canonical.title);
|
||||||
|
const orphans = all.filter((s) => {
|
||||||
|
if (s.id === canonical.id) return false;
|
||||||
|
if (canonical.tmdbId != null && s.tmdbId === canonical.tmdbId) return true;
|
||||||
|
if (canonical.imdbId && s.imdbId === canonical.imdbId) return true;
|
||||||
|
return normalizeSeriesKey(s.title) === norm;
|
||||||
|
});
|
||||||
|
|
||||||
|
let merged = 0;
|
||||||
|
for (const orphan of orphans) {
|
||||||
|
try {
|
||||||
|
await this.mergeSeriesInto(canonical.id, orphan.id);
|
||||||
|
merged += 1;
|
||||||
|
} catch {
|
||||||
|
// unique conflicts / race — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
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!;
|
||||||
const title = parsed.title.trim();
|
const title = parsed.title.trim();
|
||||||
|
|
@ -589,19 +636,19 @@ export class MetadataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async absorbOrphanSeries(canonical: SeriesRow, localTitle: string): Promise<void> {
|
private async absorbOrphanSeries(canonical: SeriesRow, localTitle: string): Promise<void> {
|
||||||
const titles = Array.from(
|
const keys = new Set(
|
||||||
new Set([canonical.title.trim(), localTitle.trim()].filter(Boolean))
|
[canonical.title, localTitle]
|
||||||
|
.map((t) => normalizeSeriesKey(t.trim()))
|
||||||
|
.filter(Boolean)
|
||||||
);
|
);
|
||||||
const orphans = await prisma.series.findMany({
|
if (keys.size === 0) return;
|
||||||
where: {
|
|
||||||
id: { not: canonical.id },
|
const candidates = await prisma.series.findMany({
|
||||||
tmdbId: null,
|
where: { id: { not: canonical.id }, tmdbId: null },
|
||||||
OR: titles.map((t) => ({
|
select: { id: true, title: true },
|
||||||
title: { equals: t, mode: "insensitive" as const },
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
for (const orphan of orphans) {
|
for (const orphan of candidates) {
|
||||||
|
if (!keys.has(normalizeSeriesKey(orphan.title))) continue;
|
||||||
await this.mergeSeriesInto(canonical.id, orphan.id);
|
await this.mergeSeriesInto(canonical.id, orphan.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const SKIP_EXTRA = [{ name: "skip", isRequired: false }];
|
||||||
|
|
||||||
const MANIFEST = {
|
const MANIFEST = {
|
||||||
id: "com.mediacluster.library",
|
id: "com.mediacluster.library",
|
||||||
version: "1.4.0",
|
version: "1.4.1",
|
||||||
name: "Media Cluster",
|
name: "Media Cluster",
|
||||||
description: "Private distributed media library",
|
description: "Private distributed media library",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
|
|
@ -385,13 +385,24 @@ async function getSeriesMeta(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fold duplicate series rows into this one so every matched file shows up.
|
||||||
|
try {
|
||||||
|
const merged = await metadata.consolidateSeries(series.id);
|
||||||
|
if (merged > 0) {
|
||||||
|
log?.info?.({ seriesId: series.id, merged }, "series meta: consolidated duplicates");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log?.warn?.({ err, seriesId: series.id }, "series meta: consolidate failed");
|
||||||
|
}
|
||||||
|
|
||||||
const seriesDbId = series.id;
|
const seriesDbId = series.id;
|
||||||
void metadata.ensureSeriesArtwork(seriesDbId).catch(() => undefined);
|
void metadata.ensureSeriesArtwork(seriesDbId).catch(() => undefined);
|
||||||
void metadata.enrichSeriesEpisodes(seriesDbId).catch(() => undefined);
|
void metadata.enrichSeriesEpisodes(seriesDbId).catch(() => undefined);
|
||||||
|
|
||||||
const sid = seriesId(series.imdbId, seriesDbId);
|
const sid = seriesId(series.imdbId, seriesDbId);
|
||||||
|
|
||||||
type EpRow = {
|
// Build from media files (source of truth), not episode rows that may lack files.
|
||||||
|
type FileEp = {
|
||||||
id: string;
|
id: string;
|
||||||
seasonNumber: number;
|
seasonNumber: number;
|
||||||
episodeNumber: number;
|
episodeNumber: number;
|
||||||
|
|
@ -399,63 +410,74 @@ async function getSeriesMeta(
|
||||||
overview: string | null;
|
overview: string | null;
|
||||||
airDate: Date | null;
|
airDate: Date | null;
|
||||||
stillUrl: string | null;
|
stillUrl: string | null;
|
||||||
mediaFiles: Array<{ id: string }>;
|
available: boolean;
|
||||||
|
releaseName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadEpisodes(onlyAvailable: boolean): Promise<EpRow[]> {
|
let fileEps: FileEp[] = [];
|
||||||
const mediaFilter = onlyAvailable
|
try {
|
||||||
? { some: { available: true as const } }
|
const rows = await prisma.mediaFile.findMany({
|
||||||
: { some: {} };
|
|
||||||
const mediaSelect = onlyAvailable
|
|
||||||
? { where: { available: true as const }, take: 1 as const, select: { id: true as const } }
|
|
||||||
: { take: 1 as const, select: { id: true as const } };
|
|
||||||
|
|
||||||
// Match on episode.seriesId OR season.seriesId — either side can drift after merges.
|
|
||||||
const rows = await prisma.episode.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
OR: [{ seriesId: seriesDbId }, { season: { seriesId: seriesDbId } }],
|
episode: {
|
||||||
mediaFiles: mediaFilter,
|
OR: [{ seriesId: seriesDbId }, { season: { seriesId: seriesDbId } }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
available: true,
|
||||||
seasonNumber: true,
|
releaseName: true,
|
||||||
episodeNumber: true,
|
episode: {
|
||||||
title: true,
|
select: {
|
||||||
overview: true,
|
id: true,
|
||||||
airDate: true,
|
seasonNumber: true,
|
||||||
mediaFiles: mediaSelect,
|
episodeNumber: true,
|
||||||
|
title: true,
|
||||||
|
overview: true,
|
||||||
|
airDate: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return rows.map((ep) => ({ ...ep, stillUrl: null as string | null }));
|
fileEps = rows
|
||||||
|
.filter((r) => r.episode != null)
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.episode!.id,
|
||||||
|
seasonNumber: r.episode!.seasonNumber,
|
||||||
|
episodeNumber: r.episode!.episodeNumber,
|
||||||
|
title: r.episode!.title,
|
||||||
|
overview: r.episode!.overview,
|
||||||
|
airDate: r.episode!.airDate,
|
||||||
|
stillUrl: null as string | null,
|
||||||
|
available: r.available,
|
||||||
|
releaseName: r.releaseName,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
log?.warn?.({ err, seriesId: seriesDbId }, "series meta: mediaFile query failed");
|
||||||
|
fileEps = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
let episodes: EpRow[] = [];
|
// Prefer available files; if none, still list whatever is linked.
|
||||||
try {
|
const preferred = fileEps.some((e) => e.available)
|
||||||
episodes = await loadEpisodes(true);
|
? fileEps.filter((e) => e.available)
|
||||||
if (episodes.length === 0) episodes = await loadEpisodes(false);
|
: fileEps;
|
||||||
} catch {
|
|
||||||
episodes = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stills are optional — never let still_url break or filter the episode list.
|
|
||||||
const stillById = new Map<string, string | null>();
|
const stillById = new Map<string, string | null>();
|
||||||
if (episodes.length > 0) {
|
const epIds = [...new Set(preferred.map((e) => e.id))];
|
||||||
|
if (epIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
const artRows = await prisma.episode.findMany({
|
const artRows = await prisma.episode.findMany({
|
||||||
where: { id: { in: episodes.map((ep) => ep.id) } },
|
where: { id: { in: epIds } },
|
||||||
select: { id: true, stillUrl: true },
|
select: { id: true, stillUrl: true },
|
||||||
});
|
});
|
||||||
for (const row of artRows) stillById.set(row.id, row.stillUrl);
|
for (const row of artRows) stillById.set(row.id, row.stillUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore missing column / errors
|
// optional column
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let logo: string | undefined;
|
let logo: string | undefined;
|
||||||
try {
|
try {
|
||||||
const art = await prisma.series.findFirst({
|
const art = await prisma.series.findFirst({
|
||||||
where: { id: series.id },
|
where: { id: seriesDbId },
|
||||||
select: { logoUrl: true },
|
select: { logoUrl: true },
|
||||||
});
|
});
|
||||||
logo = art?.logoUrl ?? undefined;
|
logo = art?.logoUrl ?? undefined;
|
||||||
|
|
@ -463,17 +485,18 @@ async function getSeriesMeta(
|
||||||
// optional
|
// optional
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate by S/E (keep first) so Stremio never gets colliding video ids
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const videos = [];
|
const videos = [];
|
||||||
for (const ep of episodes) {
|
// Sort by S/E for stable Stremio season UI
|
||||||
if (ep.mediaFiles.length === 0) continue;
|
preferred.sort(
|
||||||
// Skip specials (S00) — Stremio often hides/misplaces them; keep numbered seasons.
|
(a, b) => a.seasonNumber - b.seasonNumber || a.episodeNumber - b.episodeNumber
|
||||||
|
);
|
||||||
|
for (const ep of preferred) {
|
||||||
if (ep.seasonNumber <= 0 || ep.episodeNumber <= 0) continue;
|
if (ep.seasonNumber <= 0 || ep.episodeNumber <= 0) continue;
|
||||||
const key = `${ep.seasonNumber}:${ep.episodeNumber}`;
|
const key = `${ep.seasonNumber}:${ep.episodeNumber}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
const still = stillById.get(ep.id) ?? ep.stillUrl;
|
const still = stillById.get(ep.id);
|
||||||
videos.push({
|
videos.push({
|
||||||
id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`,
|
id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`,
|
||||||
title:
|
title:
|
||||||
|
|
@ -483,7 +506,6 @@ async function getSeriesMeta(
|
||||||
season: ep.seasonNumber,
|
season: ep.seasonNumber,
|
||||||
episode: ep.episodeNumber,
|
episode: ep.episodeNumber,
|
||||||
overview: ep.overview || undefined,
|
overview: ep.overview || undefined,
|
||||||
// Prefer Metahub-sized stills only when we have them; omit rather than break listing.
|
|
||||||
thumbnail: still || undefined,
|
thumbnail: still || undefined,
|
||||||
released: videoReleased(ep.airDate, series.year, ep.seasonNumber, ep.episodeNumber),
|
released: videoReleased(ep.airDate, series.year, ep.seasonNumber, ep.episodeNumber),
|
||||||
available: true,
|
available: true,
|
||||||
|
|
@ -492,10 +514,11 @@ async function getSeriesMeta(
|
||||||
|
|
||||||
log?.info?.(
|
log?.info?.(
|
||||||
{
|
{
|
||||||
seriesId: series.id,
|
seriesId: seriesDbId,
|
||||||
sid,
|
sid,
|
||||||
videoCount: videos.length,
|
videoCount: videos.length,
|
||||||
episodeRows: episodes.length,
|
fileRows: fileEps.length,
|
||||||
|
sample: preferred.slice(0, 5).map((e) => e.releaseName),
|
||||||
requestId: id,
|
requestId: id,
|
||||||
},
|
},
|
||||||
"series meta"
|
"series meta"
|
||||||
|
|
@ -512,10 +535,8 @@ async function getSeriesMeta(
|
||||||
releaseInfo: series.year ? `${series.year}-` : undefined,
|
releaseInfo: series.year ? `${series.year}-` : undefined,
|
||||||
genres: series.genres,
|
genres: series.genres,
|
||||||
videos,
|
videos,
|
||||||
behaviorHints: {
|
// Do NOT set defaultVideoId — that jumps Stremio straight into one episode's streams.
|
||||||
hasScheduledVideos: false,
|
behaviorHints: { hasScheduledVideos: false },
|
||||||
...(videos[0] ? { defaultVideoId: videos[0].id } : {}),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue