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:
parent
196e78d1f0
commit
cc7b7c8505
3 changed files with 221 additions and 31 deletions
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue