diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index 3d4fc45..96910a4 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -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(); + private seriesCache = new Map(); + private refreshedMovieIds = new Set(); + private refreshedSeriesIds = new Set(); + 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 { + 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 { + 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, + }, + }); } } diff --git a/apps/master-api/src/playback/service.ts b/apps/master-api/src/playback/service.ts index d17194e..4537afb 100644 --- a/apps/master-api/src/playback/service.ts +++ b/apps/master-api/src/playback/service.ts @@ -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(); } } diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index dc11135..330ad1a 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -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,