Add series clearlogo and restore episode thumbnails safely on seasons meta path.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1f180476d1
commit
a0785962ab
5 changed files with 188 additions and 63 deletions
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "series" ADD COLUMN IF NOT EXISTS "logo_url" TEXT;
|
||||||
|
|
@ -167,6 +167,7 @@ model Series {
|
||||||
overview String?
|
overview String?
|
||||||
posterUrl String? @map("poster_url")
|
posterUrl String? @map("poster_url")
|
||||||
backdropUrl String? @map("backdrop_url")
|
backdropUrl String? @map("backdrop_url")
|
||||||
|
logoUrl String? @map("logo_url")
|
||||||
genres String[] @default([])
|
genres String[] @default([])
|
||||||
imdbId String? @unique @map("imdb_id")
|
imdbId String? @unique @map("imdb_id")
|
||||||
tmdbId Int? @unique @map("tmdb_id")
|
tmdbId Int? @unique @map("tmdb_id")
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,11 @@ async function main() {
|
||||||
await prisma.$executeRawUnsafe(
|
await prisma.$executeRawUnsafe(
|
||||||
`ALTER TABLE "episodes" ADD COLUMN IF NOT EXISTS "still_url" TEXT`
|
`ALTER TABLE "episodes" ADD COLUMN IF NOT EXISTS "still_url" TEXT`
|
||||||
);
|
);
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`ALTER TABLE "series" ADD COLUMN IF NOT EXISTS "logo_url" TEXT`
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Could not ensure episodes.still_url:", err);
|
console.warn("Could not ensure still_url/logo_url columns:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repair drifted episode.series_id so series.episodes and seasons.episodes stay aligned.
|
// Repair drifted episode.series_id so series.episodes and seasons.episodes stay aligned.
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ interface TmdbSearchResult {
|
||||||
|
|
||||||
const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500";
|
const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500";
|
||||||
const TMDB_STILL_BASE = "https://image.tmdb.org/t/p/w300";
|
const TMDB_STILL_BASE = "https://image.tmdb.org/t/p/w300";
|
||||||
|
const TMDB_LOGO_BASE = "https://image.tmdb.org/t/p/w500";
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -27,6 +28,7 @@ type TmdbMeta = {
|
||||||
overview?: string;
|
overview?: string;
|
||||||
posterUrl?: string;
|
posterUrl?: string;
|
||||||
backdropUrl?: string;
|
backdropUrl?: string;
|
||||||
|
logoUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TmdbEpisodeMeta = {
|
type TmdbEpisodeMeta = {
|
||||||
|
|
@ -287,6 +289,7 @@ export class MetadataService {
|
||||||
overview: tmdb.overview,
|
overview: tmdb.overview,
|
||||||
posterUrl: tmdb.posterUrl,
|
posterUrl: tmdb.posterUrl,
|
||||||
backdropUrl: tmdb.backdropUrl,
|
backdropUrl: tmdb.backdropUrl,
|
||||||
|
logoUrl: tmdb.logoUrl,
|
||||||
tmdbId: tmdb.id,
|
tmdbId: tmdb.id,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
|
|
@ -294,6 +297,7 @@ export class MetadataService {
|
||||||
overview: tmdb.overview,
|
overview: tmdb.overview,
|
||||||
posterUrl: tmdb.posterUrl,
|
posterUrl: tmdb.posterUrl,
|
||||||
backdropUrl: tmdb.backdropUrl,
|
backdropUrl: tmdb.backdropUrl,
|
||||||
|
logoUrl: tmdb.logoUrl,
|
||||||
year: tmdb.year,
|
year: tmdb.year,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -368,6 +372,34 @@ export class MetadataService {
|
||||||
return episode.id;
|
return episode.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ensure series has logo/poster from TMDB (clearlogo for Stremio detail page). */
|
||||||
|
async ensureSeriesArtwork(seriesId: string): Promise<void> {
|
||||||
|
if (!this.tmdbApiKey) return;
|
||||||
|
try {
|
||||||
|
const series = await prisma.series.findUnique({ where: { id: seriesId } });
|
||||||
|
if (!series?.tmdbId) return;
|
||||||
|
if (series.logoUrl && series.posterUrl && series.backdropUrl) return;
|
||||||
|
|
||||||
|
this.seriesCache.delete(series.tmdbId);
|
||||||
|
const tmdb = await this.getTmdbSeriesDetails(series.tmdbId);
|
||||||
|
if (!tmdb) return;
|
||||||
|
|
||||||
|
await prisma.series.update({
|
||||||
|
where: { id: seriesId },
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore — meta listing must not depend on this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Fill episode titles/stills from TMDB for a series (lazy, used by Stremio meta). */
|
/** Fill episode titles/stills from TMDB for a series (lazy, used by Stremio meta). */
|
||||||
async enrichSeriesEpisodes(seriesId: string): Promise<void> {
|
async enrichSeriesEpisodes(seriesId: string): Promise<void> {
|
||||||
if (!this.tmdbApiKey) return;
|
if (!this.tmdbApiKey) return;
|
||||||
|
|
@ -733,6 +765,7 @@ export class MetadataService {
|
||||||
poster_path?: string;
|
poster_path?: string;
|
||||||
backdrop_path?: string;
|
backdrop_path?: string;
|
||||||
};
|
};
|
||||||
|
const logoUrl = await this.fetchTmdbSeriesLogo(tmdbId);
|
||||||
const meta: TmdbMeta = {
|
const meta: TmdbMeta = {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.name || "Onbekend",
|
title: item.name || "Onbekend",
|
||||||
|
|
@ -744,11 +777,32 @@ export class MetadataService {
|
||||||
backdropUrl: item.backdrop_path
|
backdropUrl: item.backdrop_path
|
||||||
? `${TMDB_BACKDROP_BASE}${item.backdrop_path}`
|
? `${TMDB_BACKDROP_BASE}${item.backdrop_path}`
|
||||||
: undefined,
|
: undefined,
|
||||||
|
logoUrl,
|
||||||
};
|
};
|
||||||
this.seriesCache.set(tmdbId, meta);
|
this.seriesCache.set(tmdbId, meta);
|
||||||
return meta;
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetchTmdbSeriesLogo(tmdbId: number): Promise<string | undefined> {
|
||||||
|
if (!this.tmdbApiKey) return undefined;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
api_key: this.tmdbApiKey,
|
||||||
|
include_image_language: "nl,en,null",
|
||||||
|
});
|
||||||
|
const res = await fetch(`https://api.themoviedb.org/3/tv/${tmdbId}/images?${params}`);
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
logos?: Array<{ file_path?: string; iso_639_1?: string | null }>;
|
||||||
|
};
|
||||||
|
const logos = data.logos ?? [];
|
||||||
|
const preferred =
|
||||||
|
logos.find((l) => l.iso_639_1 === "nl") ||
|
||||||
|
logos.find((l) => l.iso_639_1 === "en") ||
|
||||||
|
logos.find((l) => !l.iso_639_1) ||
|
||||||
|
logos[0];
|
||||||
|
return preferred?.file_path ? `${TMDB_LOGO_BASE}${preferred.file_path}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private async refreshMovieNl(movieId: string, tmdbId: number) {
|
private async refreshMovieNl(movieId: string, tmdbId: number) {
|
||||||
const tmdb = await this.getTmdbMovieDetails(tmdbId);
|
const tmdb = await this.getTmdbMovieDetails(tmdbId);
|
||||||
if (!tmdb) {
|
if (!tmdb) {
|
||||||
|
|
@ -778,6 +832,7 @@ export class MetadataService {
|
||||||
overview: tmdb.overview,
|
overview: tmdb.overview,
|
||||||
posterUrl: tmdb.posterUrl,
|
posterUrl: tmdb.posterUrl,
|
||||||
backdropUrl: tmdb.backdropUrl,
|
backdropUrl: tmdb.backdropUrl,
|
||||||
|
logoUrl: tmdb.logoUrl,
|
||||||
year: tmdb.year,
|
year: tmdb.year,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -318,48 +318,11 @@ async function getMovieMeta(id: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSeriesMeta(id: string, metadata: MetadataService) {
|
async function getSeriesMeta(id: string, metadata: MetadataService) {
|
||||||
// IMPORTANT: use seasons → episodes (same path as catalog availability filter).
|
// seasons → episodes (same path as catalog). stillUrl/logoUrl optional.
|
||||||
// Do not query series.episodes directly — seriesId can drift from season.seriesId.
|
const series = await loadSeriesMetaRow(id);
|
||||||
// Do not select stillUrl — that optional column must never break listing.
|
|
||||||
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: {
|
|
||||||
seasonNumber: true,
|
|
||||||
episodeNumber: true,
|
|
||||||
title: true,
|
|
||||||
overview: true,
|
|
||||||
airDate: true,
|
|
||||||
mediaFiles: {
|
|
||||||
where: { available: true },
|
|
||||||
take: 1,
|
|
||||||
select: { id: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!series) return null;
|
if (!series) return null;
|
||||||
|
|
||||||
// TMDB titles/stills only in background — never block or fail meta.
|
void metadata.ensureSeriesArtwork(series.id).catch(() => undefined);
|
||||||
void metadata.enrichSeriesEpisodes(series.id).catch(() => undefined);
|
void metadata.enrichSeriesEpisodes(series.id).catch(() => undefined);
|
||||||
|
|
||||||
const sid = seriesId(series.imdbId, series.id);
|
const sid = seriesId(series.imdbId, series.id);
|
||||||
|
|
@ -368,29 +331,8 @@ async function getSeriesMeta(id: string, metadata: MetadataService) {
|
||||||
season.episodes.filter((ep) => ep.mediaFiles.length > 0)
|
season.episodes.filter((ep) => ep.mediaFiles.length > 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fallback: same season path, but ignore available flag (node blip / prune race).
|
|
||||||
if (episodeRows.length === 0) {
|
if (episodeRows.length === 0) {
|
||||||
const fallback = await prisma.series.findFirst({
|
const fallback = await loadSeriesMetaRow(id, false);
|
||||||
where: { id: series.id },
|
|
||||||
select: {
|
|
||||||
seasons: {
|
|
||||||
orderBy: { seasonNumber: "asc" },
|
|
||||||
select: {
|
|
||||||
episodes: {
|
|
||||||
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
|
||||||
select: {
|
|
||||||
seasonNumber: true,
|
|
||||||
episodeNumber: true,
|
|
||||||
title: true,
|
|
||||||
overview: true,
|
|
||||||
airDate: true,
|
|
||||||
mediaFiles: { take: 1, select: { id: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
episodeRows =
|
episodeRows =
|
||||||
fallback?.seasons.flatMap((season) =>
|
fallback?.seasons.flatMap((season) =>
|
||||||
season.episodes.filter((ep) => ep.mediaFiles.length > 0)
|
season.episodes.filter((ep) => ep.mediaFiles.length > 0)
|
||||||
|
|
@ -406,6 +348,7 @@ async function getSeriesMeta(id: string, metadata: MetadataService) {
|
||||||
season: ep.seasonNumber,
|
season: ep.seasonNumber,
|
||||||
episode: ep.episodeNumber,
|
episode: ep.episodeNumber,
|
||||||
overview: ep.overview ?? undefined,
|
overview: ep.overview ?? undefined,
|
||||||
|
thumbnail: ep.stillUrl || undefined,
|
||||||
released: formatMetaDate(ep.airDate),
|
released: formatMetaDate(ep.airDate),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -415,6 +358,7 @@ async function getSeriesMeta(id: string, metadata: MetadataService) {
|
||||||
name: series.title,
|
name: series.title,
|
||||||
poster: series.posterUrl ?? undefined,
|
poster: series.posterUrl ?? undefined,
|
||||||
background: series.backdropUrl ?? undefined,
|
background: series.backdropUrl ?? undefined,
|
||||||
|
logo: series.logoUrl ?? undefined,
|
||||||
description: series.overview ?? undefined,
|
description: series.overview ?? undefined,
|
||||||
releaseInfo: series.year ? String(series.year) : undefined,
|
releaseInfo: series.year ? String(series.year) : undefined,
|
||||||
genres: series.genres,
|
genres: series.genres,
|
||||||
|
|
@ -422,6 +366,126 @@ 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 {
|
function formatMetaDate(value: Date | null | undefined): string | undefined {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
const t = value.getTime();
|
const t = value.getTime();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue