Files
ripster/backend/src/services/tmdbService.js
T
2026-04-21 08:20:40 +00:00

489 lines
18 KiB
JavaScript

'use strict';
const settingsService = require('./settingsService');
const logger = require('./logger').child('TMDB');
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
const TMDB_TIMEOUT_MS = 15000;
class TmdbService {
isAbortError(error) {
const name = String(error?.name || '').trim().toLowerCase();
const message = String(error?.message || '').trim().toLowerCase();
return name === 'aborterror' || message.includes('aborted');
}
classifyRequestError(error) {
if (this.isAbortError(error)) {
return 'timeout';
}
const statusCode = Number(error?.statusCode || 0) || null;
if (statusCode === 401 || statusCode === 403) {
return 'auth';
}
if (statusCode >= 500) {
return 'upstream';
}
if (statusCode >= 400) {
return 'request_failed';
}
return 'network';
}
readFailureCode(rows) {
const value = rows && typeof rows.tmdbFailureCode === 'string'
? rows.tmdbFailureCode
: '';
const normalized = String(value || '').trim().toLowerCase();
return normalized || null;
}
attachFailureCode(rows, failureCode = null) {
const output = Array.isArray(rows) ? rows : [];
const normalized = String(failureCode || '').trim().toLowerCase();
if (!normalized) {
return output;
}
try {
Object.defineProperty(output, 'tmdbFailureCode', {
value: normalized,
enumerable: false,
configurable: true
});
} catch (_error) {
output.tmdbFailureCode = normalized;
}
return output;
}
normalizeNameList(values = [], options = {}) {
const maxItems = Math.max(1, Number(options.maxItems || 10));
const source = Array.isArray(values) ? values : [];
const output = [];
const seen = new Set();
for (const item of source) {
const name = String(item?.name || item || '').trim();
if (!name) {
continue;
}
const key = name.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(name);
if (output.length >= maxItems) {
break;
}
}
return output;
}
formatRuntimeLabel(value) {
if (Array.isArray(value)) {
const values = value
.map((entry) => Number(entry))
.filter((entry) => Number.isFinite(entry) && entry > 0)
.map((entry) => Math.trunc(entry));
if (values.length === 0) {
return null;
}
const min = Math.min(...values);
const max = Math.max(...values);
return min === max ? `${min} min` : `${min}-${max} min`;
}
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
return `${Math.trunc(numeric)} min`;
}
const text = String(value || '').trim();
return text || null;
}
async getConfig() {
const settings = await settingsService.getSettingsMap();
return {
readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null,
language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE'
};
}
async isConfigured() {
const config = await this.getConfig();
return Boolean(config.readAccessToken);
}
async resolveLanguage(explicitLanguage = null) {
const config = await this.getConfig();
return String(explicitLanguage || config.language || 'de-DE').trim() || 'de-DE';
}
async request(pathName, options = {}) {
const config = await this.getConfig();
if (!config.readAccessToken) {
return null;
}
const timeoutMs = Math.max(1000, Number(options.timeoutMs || TMDB_TIMEOUT_MS));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const normalizedPath = String(pathName || '').replace(/^\/+/, '');
const url = new URL(normalizedPath, `${TMDB_BASE_URL}/`);
if (options.query && typeof options.query === 'object') {
for (const [key, value] of Object.entries(options.query)) {
if (value === undefined || value === null || value === '') {
continue;
}
url.searchParams.set(key, String(value));
}
}
const response = await fetch(url, {
method: options.method || 'GET',
headers: {
'accept': 'application/json',
'Authorization': `Bearer ${config.readAccessToken}`,
'User-Agent': 'Ripster/1.0'
},
signal: controller.signal
});
if (!response.ok) {
const error = new Error(`TMDb request failed (${response.status})`);
error.statusCode = response.status;
error.url = url.toString();
throw error;
}
return response.json();
} finally {
clearTimeout(timer);
}
}
buildImageUrl(imagePath, size = 'w342') {
const normalizedPath = String(imagePath || '').trim();
if (!normalizedPath) {
return null;
}
return `${TMDB_IMAGE_BASE_URL}/${String(size || 'w342').trim()}/${normalizedPath.replace(/^\/+/, '')}`;
}
async searchSeries(query, options = {}) {
const normalizedQuery = String(query || '').trim();
if (!normalizedQuery || !(await this.isConfigured())) {
return [];
}
const language = await this.resolveLanguage(options.language);
let data = null;
try {
data = await this.request('/search/tv', {
query: {
query: normalizedQuery,
first_air_date_year: options.year || undefined,
language,
page: options.page || 1,
include_adult: false
}
});
} catch (error) {
const failureCode = this.classifyRequestError(error);
logger.warn('search:failed', {
query: normalizedQuery,
error: error?.message || String(error),
failureCode,
statusCode: Number(error?.statusCode || 0) || null
});
return this.attachFailureCode([], failureCode);
}
const rows = Array.isArray(data?.results) ? data.results : [];
const normalizedRows = rows
.map((row) => ({
id: Number(row?.id || 0) || null,
title: String(row?.name || row?.original_name || '').trim() || null,
originalTitle: String(row?.original_name || '').trim() || null,
year: Number(String(row?.first_air_date || '').slice(0, 4)) || null,
overview: String(row?.overview || '').trim() || null,
posterPath: String(row?.poster_path || '').trim() || null,
poster: this.buildImageUrl(row?.poster_path, 'w342'),
backdropPath: String(row?.backdrop_path || '').trim() || null,
backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'),
originalLanguage: String(row?.original_language || '').trim() || null,
popularity: Number(row?.popularity || 0) || 0
}))
.filter((row) => row.id && row.title);
return this.attachFailureCode(normalizedRows, null);
}
async getSeriesDetails(seriesId, options = {}) {
const id = Number(seriesId);
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
const appendToResponse = Array.isArray(options.appendToResponse)
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
: [];
return this.request(`/tv/${Math.trunc(id)}`, {
query: {
language,
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
}
}).catch((error) => {
logger.warn('series:details:failed', {
seriesId: Math.trunc(id),
error: error?.message || String(error)
});
return null;
});
}
async getEpisodeGroups(seriesId) {
const id = Number(seriesId);
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
return [];
}
const response = await this.request(`/tv/${Math.trunc(id)}/episode_groups`).catch((error) => {
logger.warn('series:episode-groups:failed', {
seriesId: Math.trunc(id),
error: error?.message || String(error)
});
return null;
});
return Array.isArray(response?.results) ? response.results : [];
}
async getEpisodeGroupDetails(groupId, options = {}) {
const normalizedId = String(groupId || '').trim();
if (!normalizedId || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
return this.request(`/tv/episode_group/${normalizedId}`, {
query: {
language
}
}).catch((error) => {
logger.warn('episode-group:details:failed', {
groupId: normalizedId,
error: error?.message || String(error)
});
return null;
});
}
async getSeasonDetails(seriesId, seasonNumber, options = {}) {
const id = Number(seriesId);
const season = Number(seasonNumber);
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, {
query: {
language
}
}).catch(() => null);
}
async getSeasonCredits(seriesId, seasonNumber, options = {}) {
const id = Number(seriesId);
const season = Number(seasonNumber);
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}/credits`, {
query: {
language
}
}).catch((error) => {
logger.warn('season:credits:failed', {
seriesId: Math.trunc(id),
seasonNumber: Math.trunc(season),
error: error?.message || String(error)
});
return null;
});
}
buildSeasonSummary(seasonDetails = null) {
const details = seasonDetails && typeof seasonDetails === 'object' ? seasonDetails : {};
const episodes = Array.isArray(details.episodes) ? details.episodes : [];
return {
seasonNumber: Number(details.season_number || details.seasonNumber || 0) || null,
name: String(details.name || '').trim() || null,
overview: String(details.overview || '').trim() || null,
posterPath: String(details.poster_path || '').trim() || null,
poster: this.buildImageUrl(details.poster_path, 'w342'),
episodeCount: episodes.length,
episodes: episodes.map((episode) => ({
id: Number(episode?.id || 0) || null,
number: Number(episode?.episode_number || episode?.episodeNumber || 0) || null,
seasonNumber: Number(episode?.season_number || details.season_number || 0) || null,
name: String(episode?.name || '').trim() || null,
overview: String(episode?.overview || '').trim() || null,
runtime: Number(episode?.runtime || 0) || null,
airDate: String(episode?.air_date || '').trim() || null,
stillPath: String(episode?.still_path || '').trim() || null,
still: this.buildImageUrl(episode?.still_path, 'w300')
})).filter((episode) => episode.id && episode.number)
};
}
buildSeriesDetailsSummary(seriesDetails = null) {
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : [];
const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : [];
const creators = Array.isArray(details?.created_by) ? details.created_by : [];
const genres = Array.isArray(details?.genres) ? details.genres : [];
const runtimeLabel = this.formatRuntimeLabel(details?.episode_run_time);
const directorNames = this.normalizeNameList(
crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'),
{ maxItems: 5 }
);
const creatorNames = this.normalizeNameList(creators, { maxItems: 5 });
const actorNames = this.normalizeNameList(cast, { maxItems: 10 });
const genreNames = this.normalizeNameList(genres, { maxItems: 10 });
const voteAverageRaw = Number(details?.vote_average || 0);
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
? Number(voteAverageRaw.toFixed(1))
: null;
const voteCount = Number(details?.vote_count || 0);
const imdbId = String(details?.external_ids?.imdb_id || '').trim() || null;
return {
director: directorNames.length > 0
? directorNames.join(', ')
: (creatorNames.length > 0 ? creatorNames.join(', ') : null),
actors: actorNames.length > 0 ? actorNames.join(', ') : null,
runtime: runtimeLabel,
runtimeLabel,
genre: genreNames.length > 0 ? genreNames.join(', ') : null,
imdbRating: voteAverage !== null ? voteAverage.toFixed(1) : null,
voteAverage,
voteCount: Number.isFinite(voteCount) && voteCount > 0 ? Math.trunc(voteCount) : null,
rottenTomatoes: null,
imdbId,
tmdbId: Number(details?.id || 0) || null,
firstAirDate: String(details?.first_air_date || '').trim() || null
};
}
buildSeriesMetadataCandidate(series = {}, options = {}) {
const season = series?.season && typeof series.season === 'object'
? series.season
: null;
const seasonNumber = Number(options.seasonNumber || season?.seasonNumber || 0) || null;
const providerId = seasonNumber
? `tmdb:${series.id}:season:${seasonNumber}`
: `tmdb:${series.id}`;
return {
provider: 'tmdb',
providerId,
metadataKind: seasonNumber ? 'season' : 'series',
tmdbId: Number(series?.id || 0) || null,
title: String(series?.title || '').trim() || null,
originalTitle: String(series?.originalTitle || '').trim() || null,
year: Number(series?.year || 0) || null,
overview: String(series?.overview || '').trim() || null,
poster: series?.poster || this.buildImageUrl(series?.posterPath, 'w342'),
backdrop: series?.backdrop || this.buildImageUrl(series?.backdropPath, 'w780'),
seasonNumber,
seasonName: String(season?.name || '').trim() || null,
seasonOverview: String(season?.overview || '').trim() || null,
seasonPoster: season?.poster || this.buildImageUrl(season?.posterPath, 'w342'),
episodeCount: Number(season?.episodeCount || 0) || 0,
episodes: Array.isArray(season?.episodes) ? season.episodes : []
};
}
buildSeasonSummariesFromSeriesDetails(seriesDetails = null) {
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
const seasons = Array.isArray(details.seasons) ? details.seasons : [];
return seasons
.map((season) => ({
seasonNumber: Number(season?.season_number || season?.seasonNumber || 0) || null,
name: String(season?.name || '').trim() || null,
overview: String(season?.overview || '').trim() || null,
posterPath: String(season?.poster_path || '').trim() || null,
poster: this.buildImageUrl(season?.poster_path, 'w342'),
episodeCount: Number(season?.episode_count || season?.episodeCount || 0) || 0,
episodes: []
}))
.filter((season) => season.seasonNumber !== null && season.episodeCount > 0);
}
async searchSeriesWithSeasons(query, options = {}) {
const candidates = await this.searchSeries(query, options);
const failureCode = this.readFailureCode(candidates);
if (candidates.length === 0) {
return this.attachFailureCode([], failureCode);
}
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
const selectedCandidates = candidates.slice(0, limit);
const expanded = await Promise.all(selectedCandidates.map(async (candidate) => {
const details = await this.getSeriesDetails(candidate.id, options);
const seasons = this.buildSeasonSummariesFromSeriesDetails(details);
if (seasons.length === 0) {
return [this.buildSeriesMetadataCandidate(candidate)];
}
return seasons.map((season) => this.buildSeriesMetadataCandidate({
...candidate,
season
}, {
seasonNumber: season.seasonNumber
}));
}));
const normalized = expanded.flat().filter((item) => item?.tmdbId && item?.title);
return this.attachFailureCode(normalized, failureCode);
}
async searchSeriesWithSeason(query, seasonNumber, options = {}) {
const normalizedSeason = Number(seasonNumber);
if (!Number.isFinite(normalizedSeason) || normalizedSeason < 0) {
return [];
}
const candidates = await this.searchSeries(query, options);
const failureCode = this.readFailureCode(candidates);
if (candidates.length === 0) {
return this.attachFailureCode([], failureCode);
}
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
const selectedCandidates = candidates.slice(0, limit);
const withSeasons = await Promise.all(selectedCandidates.map(async (candidate) => {
const seasonDetails = await this.getSeasonDetails(candidate.id, normalizedSeason, options);
if (!seasonDetails) {
return {
...candidate,
season: null
};
}
return {
...candidate,
season: this.buildSeasonSummary(seasonDetails)
};
}));
const normalized = withSeasons
.filter((candidate) => candidate.season && candidate.season.episodeCount > 0)
.map((candidate) => this.buildSeriesMetadataCandidate(candidate, {
seasonNumber: normalizedSeason
}));
return this.attachFailureCode(normalized, failureCode);
}
}
module.exports = new TmdbService();