diff --git a/apps/master-api/prisma/migrations/20260825052000_series_logo_url/migration.sql b/apps/master-api/prisma/migrations/20260825052000_series_logo_url/migration.sql new file mode 100644 index 0000000..7c0ae98 --- /dev/null +++ b/apps/master-api/prisma/migrations/20260825052000_series_logo_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "series" ADD COLUMN IF NOT EXISTS "logo_url" TEXT; diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index f39fc89..a74dba7 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -167,6 +167,7 @@ model Series { overview String? posterUrl String? @map("poster_url") backdropUrl String? @map("backdrop_url") + logoUrl String? @map("logo_url") genres String[] @default([]) imdbId String? @unique @map("imdb_id") tmdbId Int? @unique @map("tmdb_id") diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index ddac506..a7f4a16 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -83,8 +83,11 @@ async function main() { await prisma.$executeRawUnsafe( `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) { - 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. diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index 6fc260c..aaceb3b 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -17,6 +17,7 @@ interface TmdbSearchResult { const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"; 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_LANG = "nl-NL"; @@ -27,6 +28,7 @@ type TmdbMeta = { overview?: string; posterUrl?: string; backdropUrl?: string; + logoUrl?: string; }; type TmdbEpisodeMeta = { @@ -287,6 +289,7 @@ export class MetadataService { overview: tmdb.overview, posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, + logoUrl: tmdb.logoUrl, tmdbId: tmdb.id, }, update: { @@ -294,6 +297,7 @@ export class MetadataService { overview: tmdb.overview, posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, + logoUrl: tmdb.logoUrl, year: tmdb.year, }, }); @@ -368,6 +372,34 @@ export class MetadataService { return episode.id; } + /** Ensure series has logo/poster from TMDB (clearlogo for Stremio detail page). */ + async ensureSeriesArtwork(seriesId: string): Promise { + 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). */ async enrichSeriesEpisodes(seriesId: string): Promise { if (!this.tmdbApiKey) return; @@ -733,6 +765,7 @@ export class MetadataService { poster_path?: string; backdrop_path?: string; }; + const logoUrl = await this.fetchTmdbSeriesLogo(tmdbId); const meta: TmdbMeta = { id: item.id, title: item.name || "Onbekend", @@ -744,11 +777,32 @@ export class MetadataService { backdropUrl: item.backdrop_path ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined, + logoUrl, }; this.seriesCache.set(tmdbId, meta); return meta; } + private async fetchTmdbSeriesLogo(tmdbId: number): Promise { + 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) { const tmdb = await this.getTmdbMovieDetails(tmdbId); if (!tmdb) { @@ -778,6 +832,7 @@ export class MetadataService { overview: tmdb.overview, posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, + logoUrl: tmdb.logoUrl, year: tmdb.year, }, }); diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index db3d949..2c2df31 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -318,48 +318,11 @@ async function getMovieMeta(id: string) { } async function getSeriesMeta(id: string, metadata: MetadataService) { - // IMPORTANT: use seasons → episodes (same path as catalog availability filter). - // Do not query series.episodes directly — seriesId can drift from season.seriesId. - // 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 }, - }, - }, - }, - }, - }, - }, - }); - + // seasons → episodes (same path as catalog). stillUrl/logoUrl optional. + const series = await loadSeriesMetaRow(id); 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); 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) ); - // Fallback: same season path, but ignore available flag (node blip / prune race). if (episodeRows.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: { - seasonNumber: true, - episodeNumber: true, - title: true, - overview: true, - airDate: true, - mediaFiles: { take: 1, select: { id: true } }, - }, - }, - }, - }, - }, - }); + const fallback = await loadSeriesMetaRow(id, false); episodeRows = fallback?.seasons.flatMap((season) => season.episodes.filter((ep) => ep.mediaFiles.length > 0) @@ -406,6 +348,7 @@ async function getSeriesMeta(id: string, metadata: MetadataService) { season: ep.seasonNumber, episode: ep.episodeNumber, overview: ep.overview ?? undefined, + thumbnail: ep.stillUrl || undefined, released: formatMetaDate(ep.airDate), })); @@ -415,6 +358,7 @@ async function getSeriesMeta(id: string, metadata: MetadataService) { name: series.title, poster: series.posterUrl ?? undefined, background: series.backdropUrl ?? undefined, + logo: series.logoUrl ?? undefined, description: series.overview ?? undefined, releaseInfo: series.year ? String(series.year) : undefined, 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 { + 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();