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:
parent
1da247d2fb
commit
b78f7af24f
2 changed files with 267 additions and 19 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in a new issue