Improve Stremio logos and enrich movie/series meta from TMDB.

Prefer Metahub/medium logos, add cast, directors, rating, trailers and related fields.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 14:23:42 +02:00
parent 1da247d2fb
commit b78f7af24f
2 changed files with 267 additions and 19 deletions

View file

@ -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<string, TmdbMeta | null>();
private seriesSearchCache = new Map<string, TmdbMeta | null>();
private seasonCache = new Map<string, TmdbEpisodeMeta[]>();
private richExtrasCache = new Map<string, StremioMetaExtras>();
private refreshedMovieIds = new Set<string>();
private refreshedSeriesIds = new Set<string>();
private enrichedSeriesSeasons = new Set<string>();
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<StremioMetaExtras> {
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<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/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;
}

View file

@ -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<ReturnType<MetadataService["getStremioMetaExtras"]>> = {};
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<ReturnType<MetadataService["getStremioMetaExtras"]>> = {};
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<string>();
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 },