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_IMAGE_BASE = "https://image.tmdb.org/t/p/w500";
|
||||||
const TMDB_BACKDROP_BASE = "https://image.tmdb.org/t/p/original";
|
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 {
|
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) {}
|
constructor(private readonly tmdbApiKey?: string) {}
|
||||||
|
|
||||||
async matchMediaFile(
|
async matchMediaFile(
|
||||||
|
|
@ -51,9 +66,22 @@ export class MetadataService {
|
||||||
backdropUrl: tmdb.backdropUrl,
|
backdropUrl: tmdb.backdropUrl,
|
||||||
tmdbId: tmdb.id,
|
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) {
|
if (!movie) {
|
||||||
|
|
@ -88,9 +116,22 @@ export class MetadataService {
|
||||||
backdropUrl: tmdb.backdropUrl,
|
backdropUrl: tmdb.backdropUrl,
|
||||||
tmdbId: tmdb.id,
|
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) {
|
if (!series) {
|
||||||
|
|
@ -202,6 +243,8 @@ export class MetadataService {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
api_key: this.tmdbApiKey,
|
api_key: this.tmdbApiKey,
|
||||||
query: title,
|
query: title,
|
||||||
|
language: TMDB_LANG,
|
||||||
|
include_adult: "false",
|
||||||
...(year ? { year: String(year) } : {}),
|
...(year ? { year: String(year) } : {}),
|
||||||
});
|
});
|
||||||
const res = await fetch(`https://api.themoviedb.org/3/search/movie?${params}`);
|
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 data = (await res.json()) as TmdbSearchResult;
|
||||||
const item = data.results[0];
|
const item = data.results[0];
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
return {
|
return this.getTmdbMovieDetails(item.id, year);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async searchTmdbSeries(title: string) {
|
private async searchTmdbSeries(title: string) {
|
||||||
|
|
@ -224,19 +260,118 @@ export class MetadataService {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
api_key: this.tmdbApiKey,
|
api_key: this.tmdbApiKey,
|
||||||
query: title,
|
query: title,
|
||||||
|
language: TMDB_LANG,
|
||||||
|
include_adult: "false",
|
||||||
});
|
});
|
||||||
const res = await fetch(`https://api.themoviedb.org/3/search/tv?${params}`);
|
const res = await fetch(`https://api.themoviedb.org/3/search/tv?${params}`);
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const data = (await res.json()) as TmdbSearchResult;
|
const data = (await res.json()) as TmdbSearchResult;
|
||||||
const item = data.results[0];
|
const item = data.results[0];
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
return {
|
return this.getTmdbSeriesDetails(item.id);
|
||||||
id: item.id,
|
}
|
||||||
title: item.name ?? title,
|
|
||||||
year: item.first_air_date ? parseInt(item.first_air_date.slice(0, 4), 10) : undefined,
|
private async getTmdbMovieDetails(tmdbId: number, fallbackYear?: number): Promise<TmdbMeta | null> {
|
||||||
overview: item.overview,
|
const cached = this.movieCache.get(tmdbId);
|
||||||
posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined,
|
if (cached) return cached;
|
||||||
backdropUrl: item.backdrop_path ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined,
|
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);
|
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: {
|
formatStreamLabel(mediaFile: {
|
||||||
resolution?: string | null;
|
resolution?: string | null;
|
||||||
releaseName: string;
|
releaseName: string;
|
||||||
|
|
@ -105,11 +140,31 @@ export class PlaybackService {
|
||||||
audioCodec?: string | null;
|
audioCodec?: string | null;
|
||||||
videoCodec?: string | null;
|
videoCodec?: string | null;
|
||||||
}): string {
|
}): string {
|
||||||
const parts = ["Media Cluster"];
|
return this.formatStreamName(mediaFile);
|
||||||
if (mediaFile.resolution) parts.push(mediaFile.resolution);
|
}
|
||||||
if (mediaFile.videoCodec) parts.push(mediaFile.videoCodec);
|
|
||||||
if (mediaFile.hdrType) parts.push(mediaFile.hdrType);
|
private resolveQuality(mediaFile: {
|
||||||
if (mediaFile.audioCodec) parts.push(mediaFile.audioCodec);
|
resolution?: string | null;
|
||||||
return parts.join(" • ");
|
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",
|
name: "Media Cluster",
|
||||||
description: "Private distributed media library",
|
description: "Private distributed media library",
|
||||||
catalogs: [
|
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: "series", id: "series", name: "Series", extra: [{ name: "search", isRequired: false }] },
|
||||||
{ type: "movie", id: "recent-movies", name: "Recently Added Movies" },
|
{ type: "movie", id: "recent-movies", name: "Recent toegevoegd" },
|
||||||
{ type: "series", id: "recent-series", name: "Recently Added Series" },
|
{ type: "series", id: "recent-series", name: "Recent toegevoegde series" },
|
||||||
{ type: "movie", id: "4k-movies", name: "4K Movies" },
|
{ type: "movie", id: "4k-movies", name: "4K films" },
|
||||||
],
|
],
|
||||||
resources: ["catalog", "meta", "stream"],
|
resources: ["catalog", "meta", "stream"],
|
||||||
types: ["movie", "series"],
|
types: ["movie", "series"],
|
||||||
|
|
@ -366,8 +366,8 @@ async function getStreams(
|
||||||
"stream delegated to node"
|
"stream delegated to node"
|
||||||
);
|
);
|
||||||
streams.push({
|
streams.push({
|
||||||
name: playback.formatStreamLabel(file),
|
name: playback.formatStreamName(file),
|
||||||
title: playback.formatStreamLabel(file),
|
title: playback.formatStreamTitle(file, file.node.name),
|
||||||
url: session.streamUrl,
|
url: session.streamUrl,
|
||||||
behaviorHints: {
|
behaviorHints: {
|
||||||
notWebReady: true,
|
notWebReady: true,
|
||||||
|
|
@ -421,8 +421,8 @@ async function getStreams(
|
||||||
"stream delegated to node"
|
"stream delegated to node"
|
||||||
);
|
);
|
||||||
streams.push({
|
streams.push({
|
||||||
name: playback.formatStreamLabel(file),
|
name: playback.formatStreamName(file),
|
||||||
title: playback.formatStreamLabel(file),
|
title: playback.formatStreamTitle(file, file.node.name),
|
||||||
url: session.streamUrl,
|
url: session.streamUrl,
|
||||||
behaviorHints: {
|
behaviorHints: {
|
||||||
notWebReady: true,
|
notWebReady: true,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue