import ExternalAPI from '@server/api/externalapi'; import cacheManager from '@server/lib/cache'; import { sortBy } from 'lodash'; import type { TmdbCollection, TmdbCompanySearchResponse, TmdbExternalIdResponse, TmdbGenre, TmdbGenresResult, TmdbKeyword, TmdbKeywordSearchResponse, TmdbLanguage, TmdbMovieDetails, TmdbNetwork, TmdbPersonCombinedCredits, TmdbPersonDetails, TmdbProductionCompany, TmdbRegion, TmdbSearchMovieResponse, TmdbSearchMultiResponse, TmdbSearchTvResponse, TmdbSeasonWithEpisodes, TmdbTvDetails, TmdbUpcomingMoviesResponse, TmdbWatchProviderDetails, TmdbWatchProviderRegion, } from './interfaces'; interface SearchOptions { query: string; page?: number; includeAdult?: boolean; language?: string; } interface SingleSearchOptions extends SearchOptions { year?: number; } export type SortOptions = | 'popularity.asc' | 'popularity.desc' | 'release_date.asc' | 'release_date.desc' | 'revenue.asc' | 'revenue.desc' | 'primary_release_date.asc' | 'primary_release_date.desc' | 'original_title.asc' | 'original_title.desc' | 'vote_average.asc' | 'vote_average.desc' | 'vote_count.asc' | 'vote_count.desc' | 'first_air_date.asc' | 'first_air_date.desc'; interface DiscoverMovieOptions { page?: number; includeAdult?: boolean; language?: string; primaryReleaseDateGte?: string; primaryReleaseDateLte?: string; withRuntimeGte?: string; withRuntimeLte?: string; voteAverageGte?: string; voteAverageLte?: string; voteCountGte?: string; voteCountLte?: string; originalLanguage?: string; genre?: string; studio?: string; keywords?: string; sortBy?: SortOptions; watchRegion?: string; watchProviders?: string; } interface DiscoverTvOptions { page?: number; language?: string; firstAirDateGte?: string; firstAirDateLte?: string; withRuntimeGte?: string; withRuntimeLte?: string; voteAverageGte?: string; voteAverageLte?: string; voteCountGte?: string; voteCountLte?: string; includeEmptyReleaseDate?: boolean; originalLanguage?: string; genre?: string; network?: number; keywords?: string; sortBy?: SortOptions; watchRegion?: string; watchProviders?: string; } class TheMovieDb extends ExternalAPI { private region?: string; private originalLanguage?: string; constructor({ region, originalLanguage, }: { region?: string; originalLanguage?: string } = {}) { super( 'https://api.themoviedb.org/3', { api_key: 'db55323b8d3e4154498498a75642b381', }, { nodeCache: cacheManager.getCache('tmdb').data, rateLimit: { maxRequests: 20, maxRPS: 50, }, } ); this.region = region; this.originalLanguage = originalLanguage; } public searchMulti = async ({ query, page = 1, includeAdult = false, language = 'en', }: SearchOptions): Promise => { try { const data = await this.get('/search/multi', { params: { query, page, include_adult: includeAdult, language }, }); return data; } catch (e) { return { page: 1, results: [], total_pages: 1, total_results: 0, }; } }; public searchMovies = async ({ query, page = 1, includeAdult = false, language = 'en', year, }: SingleSearchOptions): Promise => { try { const data = await this.get('/search/movie', { params: { query, page, include_adult: includeAdult, language, primary_release_year: year, }, }); return data; } catch (e) { return { page: 1, results: [], total_pages: 1, total_results: 0, }; } }; public searchTvShows = async ({ query, page = 1, includeAdult = false, language = 'en', year, }: SingleSearchOptions): Promise => { try { const data = await this.get('/search/tv', { params: { query, page, include_adult: includeAdult, language, first_air_date_year: year, }, }); return data; } catch (e) { return { page: 1, results: [], total_pages: 1, total_results: 0, }; } }; public getPerson = async ({ personId, language = 'en', }: { personId: number; language?: string; }): Promise => { try { const data = await this.get(`/person/${personId}`, { params: { language }, }); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`); } }; public getPersonCombinedCredits = async ({ personId, language = 'en', }: { personId: number; language?: string; }): Promise => { try { const data = await this.get( `/person/${personId}/combined_credits`, { params: { language }, } ); return data; } catch (e) { throw new Error( `[TMDB] Failed to fetch person combined credits: ${e.message}` ); } }; public getMovie = async ({ movieId, language = 'en', }: { movieId: number; language?: string; }): Promise => { try { const data = await this.get( `/movie/${movieId}`, { params: { language, append_to_response: 'credits,external_ids,videos,keywords,release_dates,watch/providers', }, }, 43200 ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`); } }; public getTvShow = async ({ tvId, language = 'en', }: { tvId: number; language?: string; }): Promise => { try { const data = await this.get( `/tv/${tvId}`, { params: { language, append_to_response: 'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers', }, }, 43200 ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`); } }; public getTvSeason = async ({ tvId, seasonNumber, language, }: { tvId: number; seasonNumber: number; language?: string; }): Promise => { try { const data = await this.get( `/tv/${tvId}/season/${seasonNumber}`, { params: { language, append_to_response: 'external_ids', }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`); } }; public async getMovieRecommendations({ movieId, page = 1, language = 'en', }: { movieId: number; page?: number; language?: string; }): Promise { try { const data = await this.get( `/movie/${movieId}/recommendations`, { params: { page, language, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); } } public async getMovieSimilar({ movieId, page = 1, language = 'en', }: { movieId: number; page?: number; language?: string; }): Promise { try { const data = await this.get( `/movie/${movieId}/similar`, { params: { page, language, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); } } public async getMoviesByKeyword({ keywordId, page = 1, language = 'en', }: { keywordId: number; page?: number; language?: string; }): Promise { try { const data = await this.get( `/keyword/${keywordId}/movies`, { params: { page, language, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`); } } public async getTvRecommendations({ tvId, page = 1, language = 'en', }: { tvId: number; page?: number; language?: string; }): Promise { try { const data = await this.get( `/tv/${tvId}/recommendations`, { params: { page, language, }, } ); return data; } catch (e) { throw new Error( `[TMDB] Failed to fetch TV recommendations: ${e.message}` ); } } public async getTvSimilar({ tvId, page = 1, language = 'en', }: { tvId: number; page?: number; language?: string; }): Promise { try { const data = await this.get(`/tv/${tvId}/similar`, { params: { page, language, }, }); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch TV similar: ${e.message}`); } } public getDiscoverMovies = async ({ sortBy = 'popularity.desc', page = 1, includeAdult = false, language = 'en', primaryReleaseDateGte, primaryReleaseDateLte, originalLanguage, genre, studio, keywords, withRuntimeGte, withRuntimeLte, voteAverageGte, voteAverageLte, voteCountGte, voteCountLte, watchProviders, watchRegion, }: DiscoverMovieOptions = {}): Promise => { try { const defaultFutureDate = new Date( Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5) ) .toISOString() .split('T')[0]; const defaultPastDate = new Date('1900-01-01') .toISOString() .split('T')[0]; const data = await this.get('/discover/movie', { params: { sort_by: sortBy, page, include_adult: includeAdult, language, region: this.region, with_original_language: originalLanguage && originalLanguage !== 'all' ? originalLanguage : originalLanguage === 'all' ? undefined : this.originalLanguage, // Set our release date values, but check if one is set and not the other, // so we can force a past date or a future date. TMDB Requires both values if one is set! 'primary_release_date.gte': !primaryReleaseDateGte && primaryReleaseDateLte ? defaultPastDate : primaryReleaseDateGte, 'primary_release_date.lte': !primaryReleaseDateLte && primaryReleaseDateGte ? defaultFutureDate : primaryReleaseDateLte, with_genres: genre, with_companies: studio, with_keywords: keywords, 'with_runtime.gte': withRuntimeGte, 'with_runtime.lte': withRuntimeLte, 'vote_average.gte': voteAverageGte, 'vote_average.lte': voteAverageLte, 'vote_count.gte': voteCountGte, 'vote_count.lte': voteCountLte, watch_region: watchRegion, with_watch_providers: watchProviders, }, }); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); } }; public getDiscoverTv = async ({ sortBy = 'popularity.desc', page = 1, language = 'en', firstAirDateGte, firstAirDateLte, includeEmptyReleaseDate = false, originalLanguage, genre, network, keywords, withRuntimeGte, withRuntimeLte, voteAverageGte, voteAverageLte, voteCountGte, voteCountLte, watchProviders, watchRegion, }: DiscoverTvOptions = {}): Promise => { try { const defaultFutureDate = new Date( Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5) ) .toISOString() .split('T')[0]; const defaultPastDate = new Date('1900-01-01') .toISOString() .split('T')[0]; const data = await this.get('/discover/tv', { params: { sort_by: sortBy, page, language, region: this.region, // Set our release date values, but check if one is set and not the other, // so we can force a past date or a future date. TMDB Requires both values if one is set! 'first_air_date.gte': !firstAirDateGte && firstAirDateLte ? defaultPastDate : firstAirDateGte, 'first_air_date.lte': !firstAirDateLte && firstAirDateGte ? defaultFutureDate : firstAirDateLte, with_original_language: originalLanguage && originalLanguage !== 'all' ? originalLanguage : originalLanguage === 'all' ? undefined : this.originalLanguage, include_null_first_air_dates: includeEmptyReleaseDate, with_genres: genre, with_networks: network, with_keywords: keywords, 'with_runtime.gte': withRuntimeGte, 'with_runtime.lte': withRuntimeLte, 'vote_average.gte': voteAverageGte, 'vote_average.lte': voteAverageLte, 'vote_count.gte': voteCountGte, 'vote_count.lte': voteCountLte, with_watch_providers: watchProviders, watch_region: watchRegion, }, }); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch discover TV: ${e.message}`); } }; public getUpcomingMovies = async ({ page = 1, language = 'en', }: { page: number; language: string; }): Promise => { try { const data = await this.get( '/movie/upcoming', { params: { page, language, region: this.region, originalLanguage: this.originalLanguage, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`); } }; public getAllTrending = async ({ page = 1, timeWindow = 'day', language = 'en', }: { page?: number; timeWindow?: 'day' | 'week'; language?: string; } = {}): Promise => { try { const data = await this.get( `/trending/all/${timeWindow}`, { params: { page, language, region: this.region, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); } }; public getMovieTrending = async ({ page = 1, timeWindow = 'day', }: { page?: number; timeWindow?: 'day' | 'week'; } = {}): Promise => { try { const data = await this.get( `/trending/movie/${timeWindow}`, { params: { page, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); } }; public getTvTrending = async ({ page = 1, timeWindow = 'day', }: { page?: number; timeWindow?: 'day' | 'week'; } = {}): Promise => { try { const data = await this.get( `/trending/tv/${timeWindow}`, { params: { page, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); } }; public async getByExternalId({ externalId, type, language = 'en', }: | { externalId: string; type: 'imdb'; language?: string; } | { externalId: number; type: 'tvdb'; language?: string; }): Promise { try { const data = await this.get( `/find/${externalId}`, { params: { external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id', language, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`); } } public async getMediaByImdbId({ imdbId, language = 'en', }: { imdbId: string; language?: string; }): Promise { try { const extResponse = await this.getByExternalId({ externalId: imdbId, type: 'imdb', }); if (extResponse.movie_results[0]) { const movie = await this.getMovie({ movieId: extResponse.movie_results[0].id, language, }); return movie; } if (extResponse.tv_results[0]) { const tvshow = await this.getTvShow({ tvId: extResponse.tv_results[0].id, language, }); return tvshow; } throw new Error(`No movie or show returned from API for ID ${imdbId}`); } catch (e) { throw new Error( `[TMDB] Failed to find media using external IMDb ID: ${e.message}` ); } } public async getShowByTvdbId({ tvdbId, language = 'en', }: { tvdbId: number; language?: string; }): Promise { try { const extResponse = await this.getByExternalId({ externalId: tvdbId, type: 'tvdb', }); if (extResponse.tv_results[0]) { const tvshow = await this.getTvShow({ tvId: extResponse.tv_results[0].id, language, }); return tvshow; } throw new Error(`No show returned from API for ID ${tvdbId}`); } catch (e) { throw new Error( `[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}` ); } } public async getCollection({ collectionId, language = 'en', }: { collectionId: number; language?: string; }): Promise { try { const data = await this.get( `/collection/${collectionId}`, { params: { language, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`); } } public async getRegions(): Promise { try { const data = await this.get( '/configuration/countries', {}, 86400 // 24 hours ); const regions = sortBy(data, 'english_name'); return regions; } catch (e) { throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`); } } public async getLanguages(): Promise { try { const data = await this.get( '/configuration/languages', {}, 86400 // 24 hours ); const languages = sortBy(data, 'english_name'); return languages; } catch (e) { throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`); } } public async getStudio(studioId: number): Promise { try { const data = await this.get( `/company/${studioId}` ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch movie studio: ${e.message}`); } } public async getNetwork(networkId: number): Promise { try { const data = await this.get(`/network/${networkId}`); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch TV network: ${e.message}`); } } public async getMovieGenres({ language = 'en', }: { language?: string; } = {}): Promise { try { const data = await this.get( '/genre/movie/list', { params: { language, }, }, 86400 // 24 hours ); if ( !language.startsWith('en') && data.genres.some((genre) => !genre.name) ) { const englishData = await this.get( '/genre/movie/list', { params: { language: 'en', }, }, 86400 // 24 hours ); data.genres .filter((genre) => !genre.name) .forEach((genre) => { genre.name = englishData.genres.find( (englishGenre) => englishGenre.id === genre.id )?.name ?? ''; }); } const movieGenres = sortBy( data.genres.filter((genre) => genre.name), 'name' ); return movieGenres; } catch (e) { throw new Error(`[TMDB] Failed to fetch movie genres: ${e.message}`); } } public async getTvGenres({ language = 'en', }: { language?: string; } = {}): Promise { try { const data = await this.get( '/genre/tv/list', { params: { language, }, }, 86400 // 24 hours ); if ( !language.startsWith('en') && data.genres.some((genre) => !genre.name) ) { const englishData = await this.get( '/genre/tv/list', { params: { language: 'en', }, }, 86400 // 24 hours ); data.genres .filter((genre) => !genre.name) .forEach((genre) => { genre.name = englishData.genres.find( (englishGenre) => englishGenre.id === genre.id )?.name ?? ''; }); } const tvGenres = sortBy( data.genres.filter((genre) => genre.name), 'name' ); return tvGenres; } catch (e) { throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`); } } public async getKeywordDetails({ keywordId, }: { keywordId: number; }): Promise { try { const data = await this.get( `/keyword/${keywordId}`, undefined, 604800 // 7 days ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`); } } public async searchKeyword({ query, page = 1, }: { query: string; page?: number; }): Promise { try { const data = await this.get( '/search/keyword', { params: { query, page, }, }, 86400 // 24 hours ); return data; } catch (e) { throw new Error(`[TMDB] Failed to search keyword: ${e.message}`); } } public async searchCompany({ query, page = 1, }: { query: string; page?: number; }): Promise { try { const data = await this.get( '/search/company', { params: { query, page, }, }, 86400 // 24 hours ); return data; } catch (e) { throw new Error(`[TMDB] Failed to search companies: ${e.message}`); } } public async getAvailableWatchProviderRegions({ language, }: { language?: string; }) { try { const data = await this.get<{ results: TmdbWatchProviderRegion[] }>( '/watch/providers/regions', { params: { language: language ?? this.originalLanguage, }, }, 86400 // 24 hours ); return data.results; } catch (e) { throw new Error( `[TMDB] Failed to fetch available watch regions: ${e.message}` ); } } public async getMovieWatchProviders({ language, watchRegion, }: { language?: string; watchRegion: string; }) { try { const data = await this.get<{ results: TmdbWatchProviderDetails[] }>( '/watch/providers/movie', { params: { language: language ?? this.originalLanguage, watch_region: watchRegion, }, }, 86400 // 24 hours ); return data.results; } catch (e) { throw new Error( `[TMDB] Failed to fetch movie watch providers: ${e.message}` ); } } public async getTvWatchProviders({ language, watchRegion, }: { language?: string; watchRegion: string; }) { try { const data = await this.get<{ results: TmdbWatchProviderDetails[] }>( '/watch/providers/tv', { params: { language: language ?? this.originalLanguage, watch_region: watchRegion, }, }, 86400 // 24 hours ); return data.results; } catch (e) { throw new Error( `[TMDB] Failed to fetch TV watch providers: ${e.message}` ); } } } export default TheMovieDb;