Fetch TMDB metadata in Dutch and improve Stremio stream labels.

Show quality (1080p/4K) as the stream badge with codec and filename details underneath.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 03:25:38 +02:00
parent 196e78d1f0
commit cc7b7c8505
3 changed files with 221 additions and 31 deletions

View file

@ -17,8 +17,23 @@ interface TmdbSearchResult {
const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500";
const TMDB_BACKDROP_BASE = "https://image.tmdb.org/t/p/original";
const TMDB_LANG = "nl-NL";
type TmdbMeta = {
id: number;
title: string;
year?: number;
overview?: string;
posterUrl?: string;
backdropUrl?: string;
};
export class MetadataService {
private movieCache = new Map<number, TmdbMeta>();
private seriesCache = new Map<number, TmdbMeta>();
private refreshedMovieIds = new Set<string>();
private refreshedSeriesIds = new Set<string>();
constructor(private readonly tmdbApiKey?: string) {}
async matchMediaFile(
@ -51,9 +66,22 @@ export class MetadataService {
backdropUrl: tmdb.backdropUrl,
tmdbId: tmdb.id,
},
update: {},
update: {
title: tmdb.title,
overview: tmdb.overview,
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
year: tmdb.year,
},
});
}
} else if (
movie?.tmdbId &&
this.tmdbApiKey &&
!this.refreshedMovieIds.has(movie.id)
) {
movie = await this.refreshMovieNl(movie.id, movie.tmdbId);
this.refreshedMovieIds.add(movie.id);
}
if (!movie) {
@ -88,9 +116,22 @@ export class MetadataService {
backdropUrl: tmdb.backdropUrl,
tmdbId: tmdb.id,
},
update: {},
update: {
title: tmdb.title,
overview: tmdb.overview,
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
year: tmdb.year,
},
});
}
} else if (
series?.tmdbId &&
this.tmdbApiKey &&
!this.refreshedSeriesIds.has(series.id)
) {
series = await this.refreshSeriesNl(series.id, series.tmdbId);
this.refreshedSeriesIds.add(series.id);
}
if (!series) {
@ -202,6 +243,8 @@ export class MetadataService {
const params = new URLSearchParams({
api_key: this.tmdbApiKey,
query: title,
language: TMDB_LANG,
include_adult: "false",
...(year ? { year: String(year) } : {}),
});
const res = await fetch(`https://api.themoviedb.org/3/search/movie?${params}`);
@ -209,14 +252,7 @@ export class MetadataService {
const data = (await res.json()) as TmdbSearchResult;
const item = data.results[0];
if (!item) return null;
return {
id: item.id,
title: item.title ?? title,
year: item.release_date ? parseInt(item.release_date.slice(0, 4), 10) : year,
overview: item.overview,
posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined,
backdropUrl: item.backdrop_path ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined,
};
return this.getTmdbMovieDetails(item.id, year);
}
private async searchTmdbSeries(title: string) {
@ -224,19 +260,118 @@ export class MetadataService {
const params = new URLSearchParams({
api_key: this.tmdbApiKey,
query: title,
language: TMDB_LANG,
include_adult: "false",
});
const res = await fetch(`https://api.themoviedb.org/3/search/tv?${params}`);
if (!res.ok) return null;
const data = (await res.json()) as TmdbSearchResult;
const item = data.results[0];
if (!item) return null;
return {
id: item.id,
title: item.name ?? title,
year: item.first_air_date ? parseInt(item.first_air_date.slice(0, 4), 10) : undefined,
overview: item.overview,
posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined,
backdropUrl: item.backdrop_path ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined,
return this.getTmdbSeriesDetails(item.id);
}
private async getTmdbMovieDetails(tmdbId: number, fallbackYear?: number): Promise<TmdbMeta | null> {
const cached = this.movieCache.get(tmdbId);
if (cached) return cached;
if (!this.tmdbApiKey) return null;
const params = new URLSearchParams({
api_key: this.tmdbApiKey,
language: TMDB_LANG,
});
const res = await fetch(`https://api.themoviedb.org/3/movie/${tmdbId}?${params}`);
if (!res.ok) return null;
const item = (await res.json()) as {
id: number;
title?: string;
overview?: string;
release_date?: string;
poster_path?: string;
backdrop_path?: string;
};
const meta: TmdbMeta = {
id: item.id,
title: item.title || "Onbekend",
year: item.release_date
? parseInt(item.release_date.slice(0, 4), 10)
: fallbackYear,
overview: item.overview || undefined,
posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined,
backdropUrl: item.backdrop_path
? `${TMDB_BACKDROP_BASE}${item.backdrop_path}`
: undefined,
};
this.movieCache.set(tmdbId, meta);
return meta;
}
private async getTmdbSeriesDetails(tmdbId: number): Promise<TmdbMeta | null> {
const cached = this.seriesCache.get(tmdbId);
if (cached) return cached;
if (!this.tmdbApiKey) return null;
const params = new URLSearchParams({
api_key: this.tmdbApiKey,
language: TMDB_LANG,
});
const res = await fetch(`https://api.themoviedb.org/3/tv/${tmdbId}?${params}`);
if (!res.ok) return null;
const item = (await res.json()) as {
id: number;
name?: string;
overview?: string;
first_air_date?: string;
poster_path?: string;
backdrop_path?: string;
};
const meta: TmdbMeta = {
id: item.id,
title: item.name || "Onbekend",
year: item.first_air_date
? parseInt(item.first_air_date.slice(0, 4), 10)
: undefined,
overview: item.overview || undefined,
posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined,
backdropUrl: item.backdrop_path
? `${TMDB_BACKDROP_BASE}${item.backdrop_path}`
: undefined,
};
this.seriesCache.set(tmdbId, meta);
return meta;
}
private async refreshMovieNl(movieId: string, tmdbId: number) {
const tmdb = await this.getTmdbMovieDetails(tmdbId);
if (!tmdb) {
return prisma.movie.findUniqueOrThrow({ where: { id: movieId } });
}
return prisma.movie.update({
where: { id: movieId },
data: {
title: tmdb.title,
overview: tmdb.overview,
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
year: tmdb.year,
},
});
}
private async refreshSeriesNl(seriesId: string, tmdbId: number) {
const tmdb = await this.getTmdbSeriesDetails(tmdbId);
if (!tmdb) {
return prisma.series.findUniqueOrThrow({ where: { id: seriesId } });
}
return prisma.series.update({
where: { id: seriesId },
data: {
title: tmdb.title,
overview: tmdb.overview,
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
year: tmdb.year,
},
});
}
}

View file

@ -98,6 +98,41 @@ export class PlaybackService {
nodeConnectionManager.revokePlaybackSession(session.nodeId, sessionId);
}
/** Short badge in Stremio stream list (quality). */
formatStreamName(mediaFile: {
resolution?: string | null;
releaseName: string;
hdrType?: string | null;
}): string {
const quality = this.resolveQuality(mediaFile);
const hdr = mediaFile.hdrType?.trim();
return hdr ? `${quality}\n${hdr}` : quality;
}
/** Detail line under the stream badge. */
formatStreamTitle(
mediaFile: {
resolution?: string | null;
releaseName: string;
hdrType?: string | null;
audioCodec?: string | null;
videoCodec?: string | null;
container?: string | null;
},
nodeName?: string | null
): string {
const bits: string[] = [];
if (mediaFile.videoCodec) bits.push(this.cleanCodec(mediaFile.videoCodec));
if (mediaFile.audioCodec) bits.push(this.cleanCodec(mediaFile.audioCodec));
if (mediaFile.container) bits.push(mediaFile.container.toUpperCase());
if (nodeName) bits.push(nodeName);
const release = mediaFile.releaseName.replace(/\.[^.]+$/, "").trim();
const top = bits.length ? bits.join(" • ") : "Media Cluster";
return release ? `${top}\n${release}` : top;
}
/** @deprecated use formatStreamName / formatStreamTitle */
formatStreamLabel(mediaFile: {
resolution?: string | null;
releaseName: string;
@ -105,11 +140,31 @@ export class PlaybackService {
audioCodec?: string | null;
videoCodec?: string | null;
}): string {
const parts = ["Media Cluster"];
if (mediaFile.resolution) parts.push(mediaFile.resolution);
if (mediaFile.videoCodec) parts.push(mediaFile.videoCodec);
if (mediaFile.hdrType) parts.push(mediaFile.hdrType);
if (mediaFile.audioCodec) parts.push(mediaFile.audioCodec);
return parts.join(" • ");
return this.formatStreamName(mediaFile);
}
private resolveQuality(mediaFile: {
resolution?: string | null;
releaseName: string;
}): string {
const fromField = mediaFile.resolution?.trim();
if (fromField) {
const lower = fromField.toLowerCase();
if (lower.includes("2160") || lower.includes("4k") || lower.includes("uhd")) return "4K";
if (lower.includes("1080")) return "1080p";
if (lower.includes("720")) return "720p";
if (lower.includes("480")) return "480p";
return fromField;
}
const name = mediaFile.releaseName;
if (/\b(2160p|4k|uhd)\b/i.test(name)) return "4K";
if (/\b1080p\b/i.test(name)) return "1080p";
if (/\b720p\b/i.test(name)) return "720p";
if (/\b480p\b/i.test(name)) return "480p";
return "Video";
}
private cleanCodec(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
}

View file

@ -12,11 +12,11 @@ const MANIFEST = {
name: "Media Cluster",
description: "Private distributed media library",
catalogs: [
{ type: "movie", id: "movies", name: "Movies", extra: [{ name: "search", isRequired: false }] },
{ type: "movie", id: "movies", name: "Films", extra: [{ name: "search", isRequired: false }] },
{ type: "series", id: "series", name: "Series", extra: [{ name: "search", isRequired: false }] },
{ type: "movie", id: "recent-movies", name: "Recently Added Movies" },
{ type: "series", id: "recent-series", name: "Recently Added Series" },
{ type: "movie", id: "4k-movies", name: "4K Movies" },
{ type: "movie", id: "recent-movies", name: "Recent toegevoegd" },
{ type: "series", id: "recent-series", name: "Recent toegevoegde series" },
{ type: "movie", id: "4k-movies", name: "4K films" },
],
resources: ["catalog", "meta", "stream"],
types: ["movie", "series"],
@ -366,8 +366,8 @@ async function getStreams(
"stream delegated to node"
);
streams.push({
name: playback.formatStreamLabel(file),
title: playback.formatStreamLabel(file),
name: playback.formatStreamName(file),
title: playback.formatStreamTitle(file, file.node.name),
url: session.streamUrl,
behaviorHints: {
notWebReady: true,
@ -421,8 +421,8 @@ async function getStreams(
"stream delegated to node"
);
streams.push({
name: playback.formatStreamLabel(file),
title: playback.formatStreamLabel(file),
name: playback.formatStreamName(file),
title: playback.formatStreamTitle(file, file.node.name),
url: session.streamUrl,
behaviorHints: {
notWebReady: true,