489 lines
18 KiB
JavaScript
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();
|