diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index aa851e2..02cf5f4 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -17,9 +17,12 @@ 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"; +/** Smaller logos render closer to Cinemeta/Metahub size in Stremio desktop. */ +const TMDB_LOGO_BASE = "https://image.tmdb.org/t/p/w300"; const TMDB_BACKDROP_BASE = "https://image.tmdb.org/t/p/original"; const TMDB_LANG = "nl-NL"; +const METAHUB_LOGO = (imdbId: string) => + `https://images.metahub.space/logo/medium/${imdbId}/img`; type TmdbMeta = { id: number; @@ -32,6 +35,20 @@ type TmdbMeta = { imdbId?: string; }; +/** Extra fields for Stremio meta detail pages. */ +export type StremioMetaExtras = { + logo?: string; + cast?: string[]; + director?: string[]; + imdbRating?: string; + trailers?: Array<{ source: string; type: string }>; + language?: string; + country?: string; + genres?: string[]; + runtimeMinutes?: number; + released?: string; +}; + type TmdbEpisodeMeta = { episodeNumber: number; title: string; @@ -56,6 +73,20 @@ function needsEpisodeEnrichment(ep: { return false; } +/** Prefer Metahub (Stremio-sized) when IMDb is known; else shrink TMDB clearlogo. */ +export function resolveStremioLogo( + imdbId?: string | null, + tmdbOrStoredLogo?: string | null +): string | undefined { + if (imdbId && /^tt\d+$/i.test(imdbId)) { + return METAHUB_LOGO(imdbId); + } + if (!tmdbOrStoredLogo) return undefined; + return tmdbOrStoredLogo + .replace(/\/t\/p\/(?:original|w\d+)\//, "/t/p/w300/") + .replace(/\/logo\/(?:large|huge)\//, "/logo/medium/"); +} + function parseAirDate(raw?: string): Date | undefined { if (!raw || !/^\d{4}-\d{2}-\d{2}/.test(raw)) return undefined; const d = new Date(raw); @@ -79,12 +110,130 @@ export class MetadataService { private movieSearchCache = new Map(); private seriesSearchCache = new Map(); private seasonCache = new Map(); + private richExtrasCache = new Map(); private refreshedMovieIds = new Set(); private refreshedSeriesIds = new Set(); private enrichedSeriesSeasons = new Set(); constructor(private readonly tmdbApiKey?: string) {} + /** + * Cast, directors, rating, trailers, sized logo for Stremio detail pages. + * Cached; never throws — empty object on failure. + */ + async getStremioMetaExtras( + kind: "movie" | "series", + tmdbId: number | null | undefined, + imdbId?: string | null, + storedLogo?: string | null + ): Promise { + const logo = resolveStremioLogo(imdbId, storedLogo); + if (!this.tmdbApiKey || tmdbId == null) { + return logo ? { logo } : {}; + } + + const cacheKey = `${kind}:${tmdbId}`; + const cached = this.richExtrasCache.get(cacheKey); + if (cached) { + return { ...cached, logo: resolveStremioLogo(imdbId, cached.logo ?? storedLogo) }; + } + + try { + const path = kind === "movie" ? `movie/${tmdbId}` : `tv/${tmdbId}`; + const params = new URLSearchParams({ + api_key: this.tmdbApiKey, + language: TMDB_LANG, + append_to_response: "credits,videos,external_ids", + }); + const res = await fetch(`https://api.themoviedb.org/3/${path}?${params}`); + if (!res.ok) { + return logo ? { logo } : {}; + } + const data = (await res.json()) as { + runtime?: number; + episode_run_time?: number[]; + release_date?: string; + first_air_date?: string; + vote_average?: number; + original_language?: string; + origin_country?: string[]; + genres?: Array<{ name?: string }>; + credits?: { + cast?: Array<{ name?: string; order?: number }>; + crew?: Array<{ name?: string; job?: string }>; + }; + videos?: { + results?: Array<{ key?: string; site?: string; type?: string; official?: boolean }>; + }; + external_ids?: { imdb_id?: string | null }; + }; + + const cast = (data.credits?.cast ?? []) + .slice() + .sort((a, b) => (a.order ?? 99) - (b.order ?? 99)) + .map((c) => c.name?.trim()) + .filter((n): n is string => !!n) + .slice(0, 8); + + const directorJobs = + kind === "movie" ? ["Director"] : ["Director", "Series Director", "Executive Producer"]; + const director = (data.credits?.crew ?? []) + .filter((c) => c.job && directorJobs.includes(c.job)) + .map((c) => c.name?.trim()) + .filter((n): n is string => !!n) + .filter((n, i, arr) => arr.indexOf(n) === i) + .slice(0, 4); + + const trailers = (data.videos?.results ?? []) + .filter((v) => v.site === "YouTube" && v.key && (v.type === "Trailer" || v.type === "Teaser")) + .slice(0, 3) + .map((v) => ({ source: v.key!, type: "Trailer" as const })); + + const imdbFromExt = data.external_ids?.imdb_id?.trim(); + const resolvedImdb = + imdbId && /^tt\d+$/i.test(imdbId) + ? imdbId + : imdbFromExt && /^tt\d+$/i.test(imdbFromExt) + ? imdbFromExt + : null; + + let tmdbLogo: string | undefined; + try { + tmdbLogo = + kind === "movie" + ? await this.fetchTmdbMovieLogo(tmdbId) + : await this.fetchTmdbSeriesLogo(tmdbId); + } catch { + tmdbLogo = storedLogo ?? undefined; + } + + const extras: StremioMetaExtras = { + logo: resolveStremioLogo(resolvedImdb, tmdbLogo ?? storedLogo), + cast: cast.length ? cast : undefined, + director: director.length ? director : undefined, + imdbRating: + typeof data.vote_average === "number" && data.vote_average > 0 + ? data.vote_average.toFixed(1) + : undefined, + trailers: trailers.length ? trailers : undefined, + language: data.original_language || undefined, + country: data.origin_country?.[0] || undefined, + genres: (data.genres ?? []) + .map((g) => g.name?.trim()) + .filter((n): n is string => !!n), + runtimeMinutes: + kind === "movie" + ? data.runtime || undefined + : data.episode_run_time?.[0] || undefined, + released: data.release_date || data.first_air_date || undefined, + }; + this.richExtrasCache.set(cacheKey, extras); + return extras; + } catch { + return logo ? { logo } : {}; + } + } + async matchMediaFile( nodeId: string, file: MediaFileInfo @@ -436,12 +585,28 @@ export class MetadataService { try { const series = await prisma.series.findUnique({ where: { id: seriesId } }); if (!series?.tmdbId) return; - if (series.logoUrl && series.posterUrl && series.backdropUrl) return; + + // Prefer Metahub-sized logo when IMDb is known (fixes oversized TMDB clearlogos). + if (series.imdbId) { + const hub = resolveStremioLogo(series.imdbId, series.logoUrl); + if (hub && hub !== series.logoUrl) { + await prisma.series + .update({ where: { id: seriesId }, data: { logoUrl: hub } }) + .catch(() => undefined); + } + } + + if (series.logoUrl && series.posterUrl && series.backdropUrl && series.imdbId) return; this.seriesCache.delete(series.tmdbId); const tmdb = await this.getTmdbSeriesDetails(series.tmdbId); if (!tmdb) return; + const logo = resolveStremioLogo( + tmdb.imdbId ?? series.imdbId, + tmdb.logoUrl ?? series.logoUrl + ); + await prisma.series.update({ where: { id: seriesId }, data: { @@ -451,13 +616,9 @@ export class MetadataService { title: tmdb.title || series.title, year: tmdb.year ?? series.year, ...(tmdb.imdbId && !series.imdbId ? { imdbId: tmdb.imdbId } : {}), + ...(logo ? { logoUrl: logo } : {}), }, }); - 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 } @@ -1076,14 +1237,47 @@ export class MetadataService { 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 }>; + logos?: Array<{ + file_path?: string; + iso_639_1?: string | null; + aspect_ratio?: number; + }>; }; const logos = data.logos ?? []; + // Prefer wide clearlogos (aspect > 1.5) — tall/square logos blow up in Stremio desktop. + const wide = logos.filter((l) => (l.aspect_ratio ?? 0) >= 1.5); + const pool = wide.length ? wide : 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]; + pool.find((l) => l.iso_639_1 === "nl") || + pool.find((l) => l.iso_639_1 === "en") || + pool.find((l) => !l.iso_639_1) || + pool[0]; + return preferred?.file_path ? `${TMDB_LOGO_BASE}${preferred.file_path}` : undefined; + } + + private async fetchTmdbMovieLogo(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/movie/${tmdbId}/images?${params}`); + if (!res.ok) return undefined; + const data = (await res.json()) as { + logos?: Array<{ + file_path?: string; + iso_639_1?: string | null; + aspect_ratio?: number; + }>; + }; + const logos = data.logos ?? []; + const wide = logos.filter((l) => (l.aspect_ratio ?? 0) >= 1.5); + const pool = wide.length ? wide : logos; + const preferred = + pool.find((l) => l.iso_639_1 === "nl") || + pool.find((l) => l.iso_639_1 === "en") || + pool.find((l) => !l.iso_639_1) || + pool[0]; return preferred?.file_path ? `${TMDB_LOGO_BASE}${preferred.file_path}` : undefined; } diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index c8142a4..c086ee5 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -18,7 +18,7 @@ const SKIP_EXTRA = [{ name: "skip", isRequired: false }]; const MANIFEST = { id: "com.mediacluster.library", - version: "1.4.4", + version: "1.4.5", name: "Media Cluster", description: "Private distributed media library", catalogs: [ @@ -364,22 +364,50 @@ async function getMovieMeta( log?.warn?.({ err, movieId: movie.id }, "movie meta: consolidate failed"); } - // Refresh IMDb id in background so next catalog page can use tt + Cinemeta. void metadata.ensureMovieImdbId(movie.id).catch(() => undefined); const fresh = await prisma.movie.findUnique({ where: { id: movie.id } }); const row = fresh ?? movie; + let extras: Awaited> = {}; + try { + extras = await metadata.getStremioMetaExtras("movie", row.tmdbId, row.imdbId, null); + } catch (err) { + log?.warn?.({ err, movieId: row.id }, "movie meta: rich extras failed"); + } + + if (extras.genres?.length && row.genres.length === 0) { + void prisma.movie + .update({ where: { id: row.id }, data: { genres: extras.genres } }) + .catch(() => undefined); + } + + const releasedIso = extras.released + ? new Date(extras.released).toISOString() + : undefined; + return { id: movieId(row.imdbId, row.id), type: "movie" as const, name: row.title, poster: row.posterUrl ?? undefined, background: row.backdropUrl ?? undefined, + logo: extras.logo, description: row.overview ?? undefined, releaseInfo: row.year ? String(row.year) : undefined, - runtime: row.runtime ? `${row.runtime} min` : undefined, - genres: row.genres, + runtime: row.runtime + ? `${row.runtime} min` + : extras.runtimeMinutes + ? `${extras.runtimeMinutes} min` + : undefined, + genres: row.genres.length ? row.genres : extras.genres, + cast: extras.cast, + director: extras.director, + imdbRating: extras.imdbRating, + trailers: extras.trailers, + language: extras.language, + country: extras.country, + released: releasedIso && !Number.isNaN(Date.parse(releasedIso)) ? releasedIso : undefined, }; } @@ -404,6 +432,7 @@ async function getSeriesMeta( backdropUrl: true, genres: true, imdbId: true, + tmdbId: true, }, }); @@ -502,16 +531,34 @@ async function getSeriesMeta( } let logo: string | undefined; + let extras: Awaited> = {}; try { const art = await prisma.series.findFirst({ where: { id: seriesDbId }, - select: { logoUrl: true }, + select: { logoUrl: true, tmdbId: true, imdbId: true }, }); - logo = art?.logoUrl ?? undefined; + extras = await metadata.getStremioMetaExtras( + "series", + art?.tmdbId ?? series.tmdbId, + art?.imdbId ?? series.imdbId, + art?.logoUrl + ); + logo = extras.logo; + if (logo && art && art.logoUrl !== logo) { + void prisma.series + .update({ where: { id: seriesDbId }, data: { logoUrl: logo } }) + .catch(() => undefined); + } } catch { // optional } + if (extras.genres?.length && series.genres.length === 0) { + void prisma.series + .update({ where: { id: seriesDbId }, data: { genres: extras.genres } }) + .catch(() => undefined); + } + const seen = new Set(); const videos = []; // Sort by S/E for stable Stremio season UI @@ -560,7 +607,14 @@ async function getSeriesMeta( logo, description: series.overview ?? undefined, releaseInfo: series.year ? `${series.year}-` : undefined, - genres: series.genres, + genres: series.genres.length ? series.genres : extras.genres, + cast: extras.cast, + director: extras.director, + imdbRating: extras.imdbRating, + trailers: extras.trailers, + language: extras.language, + country: extras.country, + runtime: extras.runtimeMinutes ? `${extras.runtimeMinutes} min` : undefined, videos, // Do NOT set defaultVideoId — that jumps Stremio straight into one episode's streams. behaviorHints: { hasScheduledVideos: false },