Decouple series episode listing from logo/still columns so meta cannot go empty again.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 04:57:55 +02:00
parent a0785962ab
commit 189448ebdb
2 changed files with 110 additions and 133 deletions

View file

@ -289,7 +289,6 @@ export class MetadataService {
overview: tmdb.overview,
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
logoUrl: tmdb.logoUrl,
tmdbId: tmdb.id,
},
update: {
@ -297,10 +296,14 @@ export class MetadataService {
overview: tmdb.overview,
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
logoUrl: tmdb.logoUrl,
year: tmdb.year,
},
});
if (tmdb.logoUrl) {
await prisma.series
.update({ where: { id: series.id }, data: { logoUrl: tmdb.logoUrl } })
.catch(() => undefined);
}
await this.absorbOrphanSeries(series, title);
}
}
@ -389,12 +392,16 @@ export class MetadataService {
data: {
posterUrl: tmdb.posterUrl ?? series.posterUrl,
backdropUrl: tmdb.backdropUrl ?? series.backdropUrl,
logoUrl: tmdb.logoUrl ?? series.logoUrl,
overview: tmdb.overview ?? series.overview,
title: tmdb.title || series.title,
year: tmdb.year ?? series.year,
},
});
if (tmdb.logoUrl) {
await prisma.series
.update({ where: { id: seriesId }, data: { logoUrl: tmdb.logoUrl } })
.catch(() => undefined);
}
} catch {
// ignore — meta listing must not depend on this
}
@ -832,9 +839,15 @@ export class MetadataService {
overview: tmdb.overview,
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
logoUrl: tmdb.logoUrl,
year: tmdb.year,
},
}).then(async (row) => {
if (tmdb.logoUrl) {
await prisma.series
.update({ where: { id: seriesId }, data: { logoUrl: tmdb.logoUrl } })
.catch(() => undefined);
}
return row;
});
}
}

View file

@ -318,8 +318,44 @@ async function getMovieMeta(id: string) {
}
async function getSeriesMeta(id: string, metadata: MetadataService) {
// seasons → episodes (same path as catalog). stillUrl/logoUrl optional.
const series = await loadSeriesMetaRow(id);
// Core listing query: NEVER select stillUrl/logoUrl (optional columns broke meta before).
const series = await prisma.series.findFirst({
where: {
OR: [{ imdbId: id }, { id: unwrapLocalId(id, "series") }],
},
select: {
id: true,
title: true,
year: true,
overview: true,
posterUrl: true,
backdropUrl: true,
genres: true,
imdbId: true,
seasons: {
orderBy: { seasonNumber: "asc" },
select: {
episodes: {
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
select: {
id: true,
seasonNumber: true,
episodeNumber: true,
title: true,
overview: true,
airDate: true,
mediaFiles: {
where: { available: true },
take: 1,
select: { id: true },
},
},
},
},
},
},
});
if (!series) return null;
void metadata.ensureSeriesArtwork(series.id).catch(() => undefined);
@ -327,19 +363,67 @@ async function getSeriesMeta(id: string, metadata: MetadataService) {
const sid = seriesId(series.imdbId, series.id);
let episodeRows = series.seasons.flatMap((season) =>
let episodes = series.seasons.flatMap((season) =>
season.episodes.filter((ep) => ep.mediaFiles.length > 0)
);
if (episodeRows.length === 0) {
const fallback = await loadSeriesMetaRow(id, false);
episodeRows =
if (episodes.length === 0) {
const fallback = await prisma.series.findFirst({
where: { id: series.id },
select: {
seasons: {
orderBy: { seasonNumber: "asc" },
select: {
episodes: {
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
select: {
id: true,
seasonNumber: true,
episodeNumber: true,
title: true,
overview: true,
airDate: true,
mediaFiles: { take: 1, select: { id: true } },
},
},
},
},
},
});
episodes =
fallback?.seasons.flatMap((season) =>
season.episodes.filter((ep) => ep.mediaFiles.length > 0)
) ?? [];
}
const videos = episodeRows.map((ep) => ({
// Optional extras — failure must not clear videos.
const stillByEpisodeId = new Map<string, string>();
let logo: string | undefined;
try {
const art = await prisma.series.findFirst({
where: { id: series.id },
select: {
logoUrl: true,
seasons: {
select: {
episodes: {
select: { id: true, stillUrl: true },
},
},
},
},
});
logo = art?.logoUrl ?? undefined;
for (const season of art?.seasons ?? []) {
for (const ep of season.episodes) {
if (ep.stillUrl) stillByEpisodeId.set(ep.id, ep.stillUrl);
}
}
} catch {
// columns may not exist yet
}
const videos = episodes.map((ep) => ({
id: `${sid}:${ep.seasonNumber}:${ep.episodeNumber}`,
title:
ep.title && !/^episode\s+\d+$/i.test(ep.title)
@ -348,7 +432,7 @@ async function getSeriesMeta(id: string, metadata: MetadataService) {
season: ep.seasonNumber,
episode: ep.episodeNumber,
overview: ep.overview ?? undefined,
thumbnail: ep.stillUrl || undefined,
thumbnail: stillByEpisodeId.get(ep.id),
released: formatMetaDate(ep.airDate),
}));
@ -358,7 +442,7 @@ async function getSeriesMeta(id: string, metadata: MetadataService) {
name: series.title,
poster: series.posterUrl ?? undefined,
background: series.backdropUrl ?? undefined,
logo: series.logoUrl ?? undefined,
logo,
description: series.overview ?? undefined,
releaseInfo: series.year ? String(series.year) : undefined,
genres: series.genres,
@ -366,126 +450,6 @@ async function getSeriesMeta(id: string, metadata: MetadataService) {
};
}
type SeriesMetaRow = {
id: string;
title: string;
year: number | null;
overview: string | null;
posterUrl: string | null;
backdropUrl: string | null;
logoUrl: string | null;
genres: string[];
imdbId: string | null;
seasons: Array<{
episodes: Array<{
seasonNumber: number;
episodeNumber: number;
title: string | null;
overview: string | null;
airDate: Date | null;
stillUrl: string | null;
mediaFiles: Array<{ id: string }>;
}>;
}>;
};
async function loadSeriesMetaRow(
id: string,
onlyAvailable = true
): Promise<SeriesMetaRow | null> {
const where = {
OR: [{ imdbId: id }, { id: unwrapLocalId(id, "series") }],
};
const mediaFiles = onlyAvailable
? { where: { available: true as const }, take: 1 as const, select: { id: true as const } }
: { take: 1 as const, select: { id: true as const } };
try {
const row = await prisma.series.findFirst({
where,
select: {
id: true,
title: true,
year: true,
overview: true,
posterUrl: true,
backdropUrl: true,
logoUrl: true,
genres: true,
imdbId: true,
seasons: {
orderBy: { seasonNumber: "asc" as const },
select: {
episodes: {
orderBy: [
{ seasonNumber: "asc" as const },
{ episodeNumber: "asc" as const },
],
select: {
seasonNumber: true,
episodeNumber: true,
title: true,
overview: true,
airDate: true,
stillUrl: true,
mediaFiles,
},
},
},
},
},
});
if (row) return row;
} catch {
// logo_url / still_url missing — fall back without those columns
}
try {
const row = await prisma.series.findFirst({
where,
select: {
id: true,
title: true,
year: true,
overview: true,
posterUrl: true,
backdropUrl: true,
genres: true,
imdbId: true,
seasons: {
orderBy: { seasonNumber: "asc" as const },
select: {
episodes: {
orderBy: [
{ seasonNumber: "asc" as const },
{ episodeNumber: "asc" as const },
],
select: {
seasonNumber: true,
episodeNumber: true,
title: true,
overview: true,
airDate: true,
mediaFiles,
},
},
},
},
},
});
if (!row) return null;
return {
...row,
logoUrl: null,
seasons: row.seasons.map((s) => ({
episodes: s.episodes.map((ep) => ({ ...ep, stillUrl: null })),
})),
};
} catch {
return null;
}
}
function formatMetaDate(value: Date | null | undefined): string | undefined {
if (!value) return undefined;
const t = value.getTime();