From 20289b5960a93545cdff9331a1a7b613f382e702 Mon Sep 17 00:00:00 2001 From: sct Date: Sun, 31 Jan 2021 17:24:45 +0900 Subject: [PATCH] feat(cache): external API cache (#786) --- package.json | 1 + server/api/externalapi.ts | 106 ++ server/api/radarr.ts | 33 +- server/api/rottentomatoes.ts | 57 +- server/api/sonarr.ts | 33 +- server/api/themoviedb.ts | 945 ------------------ server/api/themoviedb/constants.ts | 1 + server/api/themoviedb/index.ts | 599 +++++++++++ server/api/themoviedb/interfaces.ts | 346 +++++++ server/entity/MediaRequest.ts | 3 +- server/job/plexsync/index.ts | 5 +- server/job/sonarrsync/index.ts | 3 +- server/lib/cache.ts | 56 ++ server/models/Collection.ts | 2 +- server/models/Movie.ts | 2 +- server/models/Person.ts | 4 +- server/models/Search.ts | 2 +- server/models/Tv.ts | 4 +- server/models/common.ts | 4 +- server/utils/typeHelpers.ts | 2 +- .../RequestModal/TvRequestModal.tsx | 2 +- src/components/TvDetails/index.tsx | 2 +- yarn.lock | 12 + 23 files changed, 1210 insertions(+), 1014 deletions(-) create mode 100644 server/api/externalapi.ts delete mode 100644 server/api/themoviedb.ts create mode 100644 server/api/themoviedb/constants.ts create mode 100644 server/api/themoviedb/index.ts create mode 100644 server/api/themoviedb/interfaces.ts create mode 100644 server/lib/cache.ts diff --git a/package.json b/package.json index 1f9acfe9..47e4455d 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "intl": "^1.2.5", "lodash": "^4.17.20", "next": "10.0.3", + "node-cache": "^5.1.2", "node-schedule": "^1.3.3", "nodemailer": "^6.4.17", "nookies": "^2.5.2", diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts new file mode 100644 index 00000000..6ca4c1fd --- /dev/null +++ b/server/api/externalapi.ts @@ -0,0 +1,106 @@ +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import NodeCache from 'node-cache'; +import logger from '../logger'; + +// 5 minute default TTL (in seconds) +const DEFAULT_TTL = 300; + +// 10 seconds default rolling buffer (in ms) +const DEFAULT_ROLLING_BUFFER = 10000; + +interface ExternalAPIOptions { + nodeCache?: NodeCache; + headers?: Record; +} + +class ExternalAPI { + protected axios: AxiosInstance; + private baseUrl: string; + private cache?: NodeCache; + + constructor( + baseUrl: string, + params: Record, + options: ExternalAPIOptions = {} + ) { + this.axios = axios.create({ + baseURL: baseUrl, + params, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...options.headers, + }, + }); + this.baseUrl = baseUrl; + this.cache = options.nodeCache; + } + + protected async get( + endpoint: string, + config?: AxiosRequestConfig, + ttl?: number + ): Promise { + const cacheKey = this.serializeCacheKey(endpoint, config?.params); + const cachedItem = this.cache?.get(cacheKey); + if (cachedItem) { + return cachedItem; + } + + const response = await this.axios.get(endpoint, config); + + if (this.cache) { + this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); + } + + return response.data; + } + + protected async getRolling( + endpoint: string, + config?: AxiosRequestConfig, + ttl?: number + ): Promise { + const cacheKey = this.serializeCacheKey(endpoint, config?.params); + const cachedItem = this.cache?.get(cacheKey); + + if (cachedItem) { + const keyTtl = this.cache?.getTtl(cacheKey) ?? 0; + logger.debug(`Loaded item from cache: ${cacheKey}`, { + keyTtl, + }); + + // If the item has passed our rolling check, fetch again in background + if ( + keyTtl - (ttl ?? DEFAULT_TTL) * 1000 < + Date.now() - DEFAULT_ROLLING_BUFFER + ) { + this.axios.get(endpoint, config).then((response) => { + this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); + }); + } + return cachedItem; + } + + const response = await this.axios.get(endpoint, config); + + if (this.cache) { + this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); + } + + return response.data; + } + + private serializeCacheKey( + endpoint: string, + params?: Record + ) { + if (!params) { + return `${this.baseUrl}${endpoint}`; + } + + return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`; + } +} + +export default ExternalAPI; diff --git a/server/api/radarr.ts b/server/api/radarr.ts index ec0c7956..8e8488d0 100644 --- a/server/api/radarr.ts +++ b/server/api/radarr.ts @@ -1,6 +1,7 @@ -import Axios, { AxiosInstance } from 'axios'; +import cacheManager from '../lib/cache'; import { RadarrSettings } from '../lib/settings'; import logger from '../logger'; +import ExternalAPI from './externalapi'; interface RadarrMovieOptions { title: string; @@ -73,21 +74,23 @@ interface QueueResponse { records: QueueItem[]; } -class RadarrAPI { +class RadarrAPI extends ExternalAPI { static buildRadarrUrl(radarrSettings: RadarrSettings, path?: string): string { return `${radarrSettings.useSsl ? 'https' : 'http'}://${ radarrSettings.hostname }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}${path}`; } - private axios: AxiosInstance; constructor({ url, apiKey }: { url: string; apiKey: string }) { - this.axios = Axios.create({ - baseURL: url, - params: { + super( + url, + { apikey: apiKey, }, - }); + { + nodeCache: cacheManager.getCache('radarr').data, + } + ); } public getMovies = async (): Promise => { @@ -238,9 +241,13 @@ class RadarrAPI { public getProfiles = async (): Promise => { try { - const response = await this.axios.get(`/profile`); + const data = await this.getRolling( + `/profile`, + undefined, + 3600 + ); - return response.data; + return data; } catch (e) { throw new Error(`[Radarr] Failed to retrieve profiles: ${e.message}`); } @@ -248,9 +255,13 @@ class RadarrAPI { public getRootFolders = async (): Promise => { try { - const response = await this.axios.get(`/rootfolder`); + const data = await this.getRolling( + `/rootfolder`, + undefined, + 3600 + ); - return response.data; + return data; } catch (e) { throw new Error(`[Radarr] Failed to retrieve root folders: ${e.message}`); } diff --git a/server/api/rottentomatoes.ts b/server/api/rottentomatoes.ts index cc3a562a..e83d5572 100644 --- a/server/api/rottentomatoes.ts +++ b/server/api/rottentomatoes.ts @@ -1,4 +1,5 @@ -import axios, { AxiosInstance } from 'axios'; +import cacheManager from '../lib/cache'; +import ExternalAPI from './externalapi'; interface RTMovieOldSearchResult { id: number; @@ -55,17 +56,19 @@ export interface RTRating { * Unfortunately, we need to do it by searching for the movie name, so it's * not always accurate. */ -class RottenTomatoes { - private axios: AxiosInstance; - +class RottenTomatoes extends ExternalAPI { constructor() { - this.axios = axios.create({ - baseURL: 'https://www.rottentomatoes.com/api/private', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - }); + super( + 'https://www.rottentomatoes.com/api/private', + {}, + { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + nodeCache: cacheManager.getCache('rt').data, + } + ); } /** @@ -85,33 +88,30 @@ class RottenTomatoes { year: number ): Promise { try { - const response = await this.axios.get( - '/v1.0/movies', - { - params: { q: name }, - } - ); + const data = await this.get('/v1.0/movies', { + params: { q: name }, + }); // First, attempt to match exact name and year - let movie = response.data.movies.find( + let movie = data.movies.find( (movie) => movie.year === year && movie.title === name ); // If we don't find a movie, try to match partial name and year if (!movie) { - movie = response.data.movies.find( + movie = data.movies.find( (movie) => movie.year === year && movie.title.includes(name) ); } // If we still dont find a movie, try to match just on year if (!movie) { - movie = response.data.movies.find((movie) => movie.year === year); + movie = data.movies.find((movie) => movie.year === year); } // One last try, try exact name match only if (!movie) { - movie = response.data.movies.find((movie) => movie.title === name); + movie = data.movies.find((movie) => movie.title === name); } if (!movie) { @@ -139,19 +139,14 @@ class RottenTomatoes { year?: number ): Promise { try { - const response = await this.axios.get( - '/v2.0/search/', - { - params: { q: name, limit: 10 }, - } - ); + const data = await this.get('/v2.0/search/', { + params: { q: name, limit: 10 }, + }); - let tvshow: RTTvSearchResult | undefined = response.data.tvSeries[0]; + let tvshow: RTTvSearchResult | undefined = data.tvSeries[0]; if (year) { - tvshow = response.data.tvSeries.find( - (series) => series.startYear === year - ); + tvshow = data.tvSeries.find((series) => series.startYear === year); } if (!tvshow) { diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index 29c6b431..681cb1f3 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -1,6 +1,7 @@ -import Axios, { AxiosInstance } from 'axios'; +import cacheManager from '../lib/cache'; import { SonarrSettings } from '../lib/settings'; import logger from '../logger'; +import ExternalAPI from './externalapi'; interface SonarrSeason { seasonNumber: number; @@ -119,21 +120,23 @@ interface AddSeriesOptions { searchNow?: boolean; } -class SonarrAPI { +class SonarrAPI extends ExternalAPI { static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string { return `${sonarrSettings.useSsl ? 'https' : 'http'}://${ sonarrSettings.hostname }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`; } - private axios: AxiosInstance; constructor({ url, apiKey }: { url: string; apiKey: string }) { - this.axios = Axios.create({ - baseURL: url, - params: { + super( + url, + { apikey: apiKey, }, - }); + { + nodeCache: cacheManager.getCache('sonarr').data, + } + ); } public async getSeries(): Promise { @@ -280,9 +283,13 @@ class SonarrAPI { public async getProfiles(): Promise { try { - const response = await this.axios.get('/profile'); + const data = await this.getRolling( + '/profile', + undefined, + 3600 + ); - return response.data; + return data; } catch (e) { logger.error('Something went wrong while retrieving Sonarr profiles.', { label: 'Sonarr API', @@ -294,9 +301,13 @@ class SonarrAPI { public async getRootFolders(): Promise { try { - const response = await this.axios.get('/rootfolder'); + const data = await this.getRolling( + '/rootfolder', + undefined, + 3600 + ); - return response.data; + return data; } catch (e) { logger.error( 'Something went wrong while retrieving Sonarr root folders.', diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts deleted file mode 100644 index 47dca67a..00000000 --- a/server/api/themoviedb.ts +++ /dev/null @@ -1,945 +0,0 @@ -import axios, { AxiosInstance } from 'axios'; - -export const ANIME_KEYWORD_ID = 210024; - -interface SearchOptions { - query: string; - page?: number; - includeAdult?: boolean; - language?: string; -} - -interface DiscoverMovieOptions { - page?: number; - includeAdult?: boolean; - language?: string; - sortBy?: - | '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'; -} - -interface DiscoverTvOptions { - page?: number; - language?: string; - sortBy?: - | 'popularity.asc' - | 'popularity.desc' - | 'vote_average.asc' - | 'vote_average.desc' - | 'vote_count.asc' - | 'vote_count.desc' - | 'first_air_date.asc' - | 'first_air_date.desc'; -} - -interface TmdbMediaResult { - id: number; - media_type: string; - popularity: number; - poster_path?: string; - backdrop_path?: string; - vote_count: number; - vote_average: number; - genre_ids: number[]; - overview: string; - original_language: string; -} - -export interface TmdbMovieResult extends TmdbMediaResult { - media_type: 'movie'; - title: string; - original_title: string; - release_date: string; - adult: boolean; - video: boolean; -} - -export interface TmdbTvResult extends TmdbMediaResult { - media_type: 'tv'; - name: string; - original_name: string; - origin_country: string[]; - first_air_date: string; -} - -export interface TmdbPersonResult { - id: number; - name: string; - popularity: number; - profile_path?: string; - adult: boolean; - media_type: 'person'; - known_for: (TmdbMovieResult | TmdbTvResult)[]; -} - -interface TmdbPaginatedResponse { - page: number; - total_results: number; - total_pages: number; -} - -interface TmdbSearchMultiResponse extends TmdbPaginatedResponse { - results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[]; -} - -interface TmdbSearchMovieResponse extends TmdbPaginatedResponse { - results: TmdbMovieResult[]; -} - -interface TmdbSearchTvResponse extends TmdbPaginatedResponse { - results: TmdbTvResult[]; -} - -interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse { - dates: { - maximum: string; - minimum: string; - }; - results: TmdbMovieResult[]; -} - -interface TmdbExternalIdResponse { - movie_results: TmdbMovieResult[]; - tv_results: TmdbTvResult[]; -} - -export interface TmdbCreditCast { - cast_id: number; - character: string; - credit_id: string; - gender?: number; - id: number; - name: string; - order: number; - profile_path?: string; -} - -export interface TmdbAggregateCreditCast extends TmdbCreditCast { - roles: { - credit_id: string; - character: string; - episode_count: number; - }[]; -} - -export interface TmdbCreditCrew { - credit_id: string; - gender?: number; - id: number; - name: string; - profile_path?: string; - job: string; - department: string; -} - -export interface TmdbExternalIds { - imdb_id?: string; - freebase_mid?: string; - freebase_id?: string; - tvdb_id?: number; - tvrage_id?: string; - facebook_id?: string; - instagram_id?: string; - twitter_id?: string; -} - -export interface TmdbMovieDetails { - id: number; - imdb_id?: string; - adult: boolean; - backdrop_path?: string; - poster_path?: string; - budget: number; - genres: { - id: number; - name: string; - }[]; - homepage?: string; - original_language: string; - original_title: string; - overview?: string; - popularity: number; - production_companies: { - id: number; - name: string; - logo_path?: string; - origin_country: string; - }[]; - production_countries: { - iso_3166_1: string; - name: string; - }[]; - release_date: string; - revenue: number; - runtime?: number; - spoken_languages: { - iso_639_1: string; - name: string; - }[]; - status: string; - tagline?: string; - title: string; - video: boolean; - vote_average: number; - vote_count: number; - credits: { - cast: TmdbCreditCast[]; - crew: TmdbCreditCrew[]; - }; - belongs_to_collection?: { - id: number; - name: string; - poster_path?: string; - backdrop_path?: string; - }; - external_ids: TmdbExternalIds; - videos: TmdbVideoResult; -} - -export interface TmdbVideo { - id: string; - key: string; - name: string; - site: 'YouTube'; - size: number; - type: - | 'Clip' - | 'Teaser' - | 'Trailer' - | 'Featurette' - | 'Opening Credits' - | 'Behind the Scenes' - | 'Bloopers'; -} - -export interface TmdbTvEpisodeResult { - id: number; - air_date: string; - episode_number: number; - name: string; - overview: string; - production_code: string; - season_number: number; - show_id: number; - still_path: string; - vote_average: number; - vote_cuont: number; -} - -export interface TmdbTvSeasonResult { - id: number; - air_date: string; - episode_count: number; - name: string; - overview: string; - poster_path?: string; - season_number: number; -} - -export interface TmdbTvDetails { - id: number; - backdrop_path?: string; - created_by: { - id: number; - credit_id: string; - name: string; - gender: number; - profile_path?: string; - }[]; - episode_run_time: number[]; - first_air_date: string; - genres: { - id: number; - name: string; - }[]; - homepage: string; - in_production: boolean; - languages: string[]; - last_air_date: string; - last_episode_to_air?: TmdbTvEpisodeResult; - name: string; - next_episode_to_air?: TmdbTvEpisodeResult; - networks: { - id: number; - name: string; - logo_path: string; - origin_country: string; - }[]; - number_of_episodes: number; - number_of_seasons: number; - origin_country: string[]; - original_language: string; - original_name: string; - overview: string; - popularity: number; - poster_path?: string; - production_companies: { - id: number; - logo_path?: string; - name: string; - origin_country: string; - }[]; - spoken_languages: { - english_name: string; - iso_639_1: string; - name: string; - }[]; - seasons: TmdbTvSeasonResult[]; - status: string; - type: string; - vote_average: number; - vote_count: number; - aggregate_credits: { - cast: TmdbAggregateCreditCast[]; - }; - credits: { - crew: TmdbCreditCrew[]; - }; - external_ids: TmdbExternalIds; - keywords: { - results: TmdbKeyword[]; - }; - videos: TmdbVideoResult; -} - -export interface TmdbVideoResult { - results: TmdbVideo[]; -} - -export interface TmdbKeyword { - id: number; - name: string; -} - -export interface TmdbPersonDetail { - id: number; - name: string; - deathday: string; - known_for_department: string; - also_known_as?: string[]; - gender: number; - biography: string; - popularity: string; - place_of_birth?: string; - profile_path?: string; - adult: boolean; - imdb_id?: string; - homepage?: string; -} - -export interface TmdbPersonCredit { - id: number; - original_language: string; - episode_count: number; - overview: string; - origin_country: string[]; - original_name: string; - vote_count: number; - name: string; - media_type?: string; - popularity: number; - credit_id: string; - backdrop_path?: string; - first_air_date: string; - vote_average: number; - genre_ids?: number[]; - poster_path?: string; - original_title: string; - video?: boolean; - title: string; - adult: boolean; - release_date: string; -} -export interface TmdbPersonCreditCast extends TmdbPersonCredit { - character: string; -} - -export interface TmdbPersonCreditCrew extends TmdbPersonCredit { - department: string; - job: string; -} - -export interface TmdbPersonCombinedCredits { - id: number; - cast: TmdbPersonCreditCast[]; - crew: TmdbPersonCreditCrew[]; -} - -export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult { - episodes: TmdbTvEpisodeResult[]; - external_ids: TmdbExternalIds; -} - -export interface TmdbCollection { - id: number; - name: string; - overview?: string; - poster_path?: string; - backdrop_path?: string; - parts: TmdbMovieResult[]; -} - -class TheMovieDb { - private apiKey = 'db55323b8d3e4154498498a75642b381'; - private axios: AxiosInstance; - - constructor() { - this.axios = axios.create({ - baseURL: 'https://api.themoviedb.org/3', - params: { - api_key: this.apiKey, - }, - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - }); - } - - public searchMulti = async ({ - query, - page = 1, - includeAdult = false, - language = 'en-US', - }: SearchOptions): Promise => { - try { - const response = await this.axios.get('/search/multi', { - params: { query, page, include_adult: includeAdult, language }, - }); - - return response.data; - } catch (e) { - return { - page: 1, - results: [], - total_pages: 1, - total_results: 0, - }; - } - }; - - public getPerson = async ({ - personId, - language = 'en-US', - }: { - personId: number; - language?: string; - }): Promise => { - try { - const response = await this.axios.get( - `/person/${personId}`, - { - params: { language }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`); - } - }; - - public getPersonCombinedCredits = async ({ - personId, - language = 'en-US', - }: { - personId: number; - language?: string; - }): Promise => { - try { - const response = await this.axios.get( - `/person/${personId}/combined_credits`, - { - params: { language }, - } - ); - - return response.data; - } catch (e) { - throw new Error( - `[TMDB] Failed to fetch person combined credits: ${e.message}` - ); - } - }; - - public getMovie = async ({ - movieId, - language = 'en-US', - }: { - movieId: number; - language?: string; - }): Promise => { - try { - const response = await this.axios.get( - `/movie/${movieId}`, - { - params: { - language, - append_to_response: 'credits,external_ids,videos', - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`); - } - }; - - public getTvShow = async ({ - tvId, - language = 'en-US', - }: { - tvId: number; - language?: string; - }): Promise => { - try { - const response = await this.axios.get(`/tv/${tvId}`, { - params: { - language, - append_to_response: - 'aggregate_credits,credits,external_ids,keywords,videos', - }, - }); - - return response.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 response = await this.axios.get( - `/tv/${tvId}/season/${seasonNumber}`, - { - params: { - language, - append_to_response: 'external_ids', - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`); - } - }; - - public async getMovieRecommendations({ - movieId, - page = 1, - language = 'en-US', - }: { - movieId: number; - page?: number; - language?: string; - }): Promise { - try { - const response = await this.axios.get( - `/movie/${movieId}/recommendations`, - { - params: { - page, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); - } - } - - public async getMovieSimilar({ - movieId, - page = 1, - language = 'en-US', - }: { - movieId: number; - page?: number; - language?: string; - }): Promise { - try { - const response = await this.axios.get( - `/movie/${movieId}/similar`, - { - params: { - page, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); - } - } - - public async getMoviesByKeyword({ - keywordId, - page = 1, - language = 'en-US', - }: { - keywordId: number; - page?: number; - language?: string; - }): Promise { - try { - const response = await this.axios.get( - `/keyword/${keywordId}/movies`, - { - params: { - page, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`); - } - } - - public async getTvRecommendations({ - tvId, - page = 1, - language = 'en-US', - }: { - tvId: number; - page?: number; - language?: string; - }): Promise { - try { - const response = await this.axios.get( - `/tv/${tvId}/recommendations`, - { - params: { - page, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error( - `[TMDB] Failed to fetch tv recommendations: ${e.message}` - ); - } - } - - public async getTvSimilar({ - tvId, - page = 1, - language = 'en-US', - }: { - tvId: number; - page?: number; - language?: string; - }): Promise { - try { - const response = await this.axios.get( - `/tv/${tvId}/similar`, - { - params: { - page, - language, - }, - } - ); - - return response.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-US', - }: DiscoverMovieOptions = {}): Promise => { - try { - const response = await this.axios.get( - '/discover/movie', - { - params: { - sort_by: sortBy, - page, - include_adult: includeAdult, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); - } - }; - - public getDiscoverTv = async ({ - sortBy = 'popularity.desc', - page = 1, - language = 'en-US', - }: DiscoverTvOptions = {}): Promise => { - try { - const response = await this.axios.get( - '/discover/tv', - { - params: { - sort_by: sortBy, - page, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover tv: ${e.message}`); - } - }; - - public getUpcomingMovies = async ({ - page = 1, - language = 'en-US', - }: { - page: number; - language: string; - }): Promise => { - try { - const response = await this.axios.get( - '/movie/upcoming', - { - params: { - page, - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`); - } - }; - - public getAllTrending = async ({ - page = 1, - timeWindow = 'day', - language = 'en-US', - }: { - page?: number; - timeWindow?: 'day' | 'week'; - language?: string; - } = {}): Promise => { - try { - const response = await this.axios.get( - `/trending/all/${timeWindow}`, - { - params: { - page, - language, - }, - } - ); - - return response.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 response = await this.axios.get( - `/trending/movie/${timeWindow}`, - { - params: { - page, - }, - } - ); - - return response.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 response = await this.axios.get( - `/trending/tv/${timeWindow}`, - { - params: { - page, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); - } - }; - - public async getByExternalId({ - externalId, - type, - language = 'en-US', - }: - | { - externalId: string; - type: 'imdb'; - language?: string; - } - | { - externalId: number; - type: 'tvdb'; - language?: string; - }): Promise { - try { - const response = await this.axios.get( - `/find/${externalId}`, - { - params: { - external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id', - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`); - } - } - - public async getMovieByImdbId({ - imdbId, - language = 'en-US', - }: { - 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; - } - - throw new Error( - '[TMDB] Failed to find a title with the provided IMDB id' - ); - } catch (e) { - throw new Error( - `[TMDB] Failed to get movie by external imdb ID: ${e.message}` - ); - } - } - - public async getShowByTvdbId({ - tvdbId, - language = 'en-US', - }: { - 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( - `[TMDB] Failed to find a TV show with the provided TVDB 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-US', - }: { - collectionId: number; - language?: string; - }): Promise { - try { - const response = await this.axios.get( - `/collection/${collectionId}`, - { - params: { - language, - }, - } - ); - - return response.data; - } catch (e) { - throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`); - } - } -} - -export default TheMovieDb; diff --git a/server/api/themoviedb/constants.ts b/server/api/themoviedb/constants.ts new file mode 100644 index 00000000..be475f72 --- /dev/null +++ b/server/api/themoviedb/constants.ts @@ -0,0 +1 @@ +export const ANIME_KEYWORD_ID = 210024; diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts new file mode 100644 index 00000000..bf716385 --- /dev/null +++ b/server/api/themoviedb/index.ts @@ -0,0 +1,599 @@ +import cacheManager from '../../lib/cache'; +import ExternalAPI from '../externalapi'; +import { + TmdbCollection, + TmdbExternalIdResponse, + TmdbMovieDetails, + TmdbPersonCombinedCredits, + TmdbPersonDetail, + TmdbSearchMovieResponse, + TmdbSearchMultiResponse, + TmdbSearchTvResponse, + TmdbSeasonWithEpisodes, + TmdbTvDetails, + TmdbUpcomingMoviesResponse, +} from './interfaces'; + +interface SearchOptions { + query: string; + page?: number; + includeAdult?: boolean; + language?: string; +} + +interface DiscoverMovieOptions { + page?: number; + includeAdult?: boolean; + language?: string; + sortBy?: + | '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'; +} + +interface DiscoverTvOptions { + page?: number; + language?: string; + sortBy?: + | 'popularity.asc' + | 'popularity.desc' + | 'vote_average.asc' + | 'vote_average.desc' + | 'vote_count.asc' + | 'vote_count.desc' + | 'first_air_date.asc' + | 'first_air_date.desc'; +} + +class TheMovieDb extends ExternalAPI { + constructor() { + super( + 'https://api.themoviedb.org/3', + { + api_key: 'db55323b8d3e4154498498a75642b381', + }, + { + nodeCache: cacheManager.getCache('tmdb').data, + } + ); + } + + 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 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', + }, + }, + 900 + ); + + 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', + }, + }, + 900 + ); + + 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', + }: DiscoverMovieOptions = {}): Promise => { + try { + const data = await this.get('/discover/movie', { + params: { + sort_by: sortBy, + page, + include_adult: includeAdult, + language, + }, + }); + + 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', + }: DiscoverTvOptions = {}): Promise => { + try { + const data = await this.get('/discover/tv', { + params: { + sort_by: sortBy, + page, + language, + }, + }); + + 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, + }, + } + ); + + 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, + }, + } + ); + + 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 getMovieByImdbId({ + 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; + } + + throw new Error( + '[TMDB] Failed to find a title with the provided IMDB id' + ); + } catch (e) { + throw new Error( + `[TMDB] Failed to get movie by 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( + `[TMDB] Failed to find a TV show with the provided TVDB 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}`); + } + } +} + +export default TheMovieDb; diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts new file mode 100644 index 00000000..63b0ba9a --- /dev/null +++ b/server/api/themoviedb/interfaces.ts @@ -0,0 +1,346 @@ +interface TmdbMediaResult { + id: number; + media_type: string; + popularity: number; + poster_path?: string; + backdrop_path?: string; + vote_count: number; + vote_average: number; + genre_ids: number[]; + overview: string; + original_language: string; +} + +export interface TmdbMovieResult extends TmdbMediaResult { + media_type: 'movie'; + title: string; + original_title: string; + release_date: string; + adult: boolean; + video: boolean; +} + +export interface TmdbTvResult extends TmdbMediaResult { + media_type: 'tv'; + name: string; + original_name: string; + origin_country: string[]; + first_air_date: string; +} + +export interface TmdbPersonResult { + id: number; + name: string; + popularity: number; + profile_path?: string; + adult: boolean; + media_type: 'person'; + known_for: (TmdbMovieResult | TmdbTvResult)[]; +} + +interface TmdbPaginatedResponse { + page: number; + total_results: number; + total_pages: number; +} + +export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse { + results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[]; +} + +export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse { + results: TmdbMovieResult[]; +} + +export interface TmdbSearchTvResponse extends TmdbPaginatedResponse { + results: TmdbTvResult[]; +} + +export interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse { + dates: { + maximum: string; + minimum: string; + }; + results: TmdbMovieResult[]; +} + +export interface TmdbExternalIdResponse { + movie_results: TmdbMovieResult[]; + tv_results: TmdbTvResult[]; +} + +export interface TmdbCreditCast { + cast_id: number; + character: string; + credit_id: string; + gender?: number; + id: number; + name: string; + order: number; + profile_path?: string; +} + +export interface TmdbAggregateCreditCast extends TmdbCreditCast { + roles: { + credit_id: string; + character: string; + episode_count: number; + }[]; +} + +export interface TmdbCreditCrew { + credit_id: string; + gender?: number; + id: number; + name: string; + profile_path?: string; + job: string; + department: string; +} + +export interface TmdbExternalIds { + imdb_id?: string; + freebase_mid?: string; + freebase_id?: string; + tvdb_id?: number; + tvrage_id?: string; + facebook_id?: string; + instagram_id?: string; + twitter_id?: string; +} + +export interface TmdbMovieDetails { + id: number; + imdb_id?: string; + adult: boolean; + backdrop_path?: string; + poster_path?: string; + budget: number; + genres: { + id: number; + name: string; + }[]; + homepage?: string; + original_language: string; + original_title: string; + overview?: string; + popularity: number; + production_companies: { + id: number; + name: string; + logo_path?: string; + origin_country: string; + }[]; + production_countries: { + iso_3166_1: string; + name: string; + }[]; + release_date: string; + revenue: number; + runtime?: number; + spoken_languages: { + iso_639_1: string; + name: string; + }[]; + status: string; + tagline?: string; + title: string; + video: boolean; + vote_average: number; + vote_count: number; + credits: { + cast: TmdbCreditCast[]; + crew: TmdbCreditCrew[]; + }; + belongs_to_collection?: { + id: number; + name: string; + poster_path?: string; + backdrop_path?: string; + }; + external_ids: TmdbExternalIds; + videos: TmdbVideoResult; +} + +export interface TmdbVideo { + id: string; + key: string; + name: string; + site: 'YouTube'; + size: number; + type: + | 'Clip' + | 'Teaser' + | 'Trailer' + | 'Featurette' + | 'Opening Credits' + | 'Behind the Scenes' + | 'Bloopers'; +} + +export interface TmdbTvEpisodeResult { + id: number; + air_date: string; + episode_number: number; + name: string; + overview: string; + production_code: string; + season_number: number; + show_id: number; + still_path: string; + vote_average: number; + vote_cuont: number; +} + +export interface TmdbTvSeasonResult { + id: number; + air_date: string; + episode_count: number; + name: string; + overview: string; + poster_path?: string; + season_number: number; +} + +export interface TmdbTvDetails { + id: number; + backdrop_path?: string; + created_by: { + id: number; + credit_id: string; + name: string; + gender: number; + profile_path?: string; + }[]; + episode_run_time: number[]; + first_air_date: string; + genres: { + id: number; + name: string; + }[]; + homepage: string; + in_production: boolean; + languages: string[]; + last_air_date: string; + last_episode_to_air?: TmdbTvEpisodeResult; + name: string; + next_episode_to_air?: TmdbTvEpisodeResult; + networks: { + id: number; + name: string; + logo_path: string; + origin_country: string; + }[]; + number_of_episodes: number; + number_of_seasons: number; + origin_country: string[]; + original_language: string; + original_name: string; + overview: string; + popularity: number; + poster_path?: string; + production_companies: { + id: number; + logo_path?: string; + name: string; + origin_country: string; + }[]; + spoken_languages: { + english_name: string; + iso_639_1: string; + name: string; + }[]; + seasons: TmdbTvSeasonResult[]; + status: string; + type: string; + vote_average: number; + vote_count: number; + aggregate_credits: { + cast: TmdbAggregateCreditCast[]; + }; + credits: { + crew: TmdbCreditCrew[]; + }; + external_ids: TmdbExternalIds; + keywords: { + results: TmdbKeyword[]; + }; + videos: TmdbVideoResult; +} + +export interface TmdbVideoResult { + results: TmdbVideo[]; +} + +export interface TmdbKeyword { + id: number; + name: string; +} + +export interface TmdbPersonDetail { + id: number; + name: string; + deathday: string; + known_for_department: string; + also_known_as?: string[]; + gender: number; + biography: string; + popularity: string; + place_of_birth?: string; + profile_path?: string; + adult: boolean; + imdb_id?: string; + homepage?: string; +} + +export interface TmdbPersonCredit { + id: number; + original_language: string; + episode_count: number; + overview: string; + origin_country: string[]; + original_name: string; + vote_count: number; + name: string; + media_type?: string; + popularity: number; + credit_id: string; + backdrop_path?: string; + first_air_date: string; + vote_average: number; + genre_ids?: number[]; + poster_path?: string; + original_title: string; + video?: boolean; + title: string; + adult: boolean; + release_date: string; +} +export interface TmdbPersonCreditCast extends TmdbPersonCredit { + character: string; +} + +export interface TmdbPersonCreditCrew extends TmdbPersonCredit { + department: string; + job: string; +} + +export interface TmdbPersonCombinedCredits { + id: number; + cast: TmdbPersonCreditCast[]; + crew: TmdbPersonCreditCrew[]; +} + +export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult { + episodes: TmdbTvEpisodeResult[]; + external_ids: TmdbExternalIds; +} + +export interface TmdbCollection { + id: number; + name: string; + overview?: string; + poster_path?: string; + backdrop_path?: string; + parts: TmdbMovieResult[]; +} diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 1ba74961..3c090035 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -15,7 +15,8 @@ import { User } from './User'; import Media from './Media'; import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media'; import { getSettings } from '../lib/settings'; -import TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb'; +import TheMovieDb from '../api/themoviedb'; +import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants'; import RadarrAPI from '../api/radarr'; import logger from '../logger'; import SeasonRequest from './SeasonRequest'; diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index cab4f5ef..3ed5870d 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -1,10 +1,11 @@ import { getRepository } from 'typeorm'; import { User } from '../../entity/User'; import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../api/plexapi'; -import TheMovieDb, { +import TheMovieDb from '../../api/themoviedb'; +import { TmdbMovieDetails, TmdbTvDetails, -} from '../../api/themoviedb'; +} from '../../api/themoviedb/interfaces'; import Media from '../../entity/Media'; import { MediaStatus, MediaType } from '../../constants/media'; import logger from '../../logger'; diff --git a/server/job/sonarrsync/index.ts b/server/job/sonarrsync/index.ts index 6ef45254..1fdbac0a 100644 --- a/server/job/sonarrsync/index.ts +++ b/server/job/sonarrsync/index.ts @@ -2,7 +2,8 @@ import { uniqWith } from 'lodash'; import { getRepository } from 'typeorm'; import { v4 as uuid } from 'uuid'; import SonarrAPI, { SonarrSeries } from '../../api/sonarr'; -import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb'; +import TheMovieDb from '../../api/themoviedb'; +import { TmdbTvDetails } from '../../api/themoviedb/interfaces'; import { MediaStatus, MediaType } from '../../constants/media'; import Media from '../../entity/Media'; import Season from '../../entity/Season'; diff --git a/server/lib/cache.ts b/server/lib/cache.ts new file mode 100644 index 00000000..5624527f --- /dev/null +++ b/server/lib/cache.ts @@ -0,0 +1,56 @@ +import NodeCache from 'node-cache'; + +type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt'; + +interface Cache { + id: AvailableCacheIds; + data: NodeCache; +} + +const DEFAULT_TTL = 300; +const DEFAULT_CHECK_PERIOD = 120; + +class CacheManager { + private availableCaches: Record = { + tmdb: { + id: 'tmdb', + data: new NodeCache({ + stdTTL: DEFAULT_TTL, + checkperiod: DEFAULT_CHECK_PERIOD, + }), + }, + radarr: { + id: 'radarr', + data: new NodeCache({ + stdTTL: DEFAULT_TTL, + checkperiod: DEFAULT_CHECK_PERIOD, + }), + }, + sonarr: { + id: 'sonarr', + data: new NodeCache({ + stdTTL: DEFAULT_TTL, + checkperiod: DEFAULT_CHECK_PERIOD, + }), + }, + rt: { + id: 'rt', + data: new NodeCache({ + stdTTL: 21600, // 12 hours TTL + checkperiod: 60 * 30, // 30 minutes check period + }), + }, + }; + + public getCache(id: AvailableCacheIds): Cache { + return this.availableCaches[id]; + } + + public getAllCaches(): Record { + return this.availableCaches; + } +} + +const cacheManager = new CacheManager(); + +export default cacheManager; diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 64709502..48112849 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -1,4 +1,4 @@ -import { TmdbCollection } from '../api/themoviedb'; +import type { TmdbCollection } from '../api/themoviedb/interfaces'; import { MediaType } from '../constants/media'; import Media from '../entity/Media'; import { mapMovieResult, MovieResult } from './Search'; diff --git a/server/models/Movie.ts b/server/models/Movie.ts index c8639613..bfeb95ac 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -1,4 +1,4 @@ -import { TmdbMovieDetails } from '../api/themoviedb'; +import type { TmdbMovieDetails } from '../api/themoviedb/interfaces'; import { ProductionCompany, Genre, diff --git a/server/models/Person.ts b/server/models/Person.ts index 575e40cc..522a8e5e 100644 --- a/server/models/Person.ts +++ b/server/models/Person.ts @@ -1,8 +1,8 @@ -import { +import type { TmdbPersonCreditCast, TmdbPersonCreditCrew, TmdbPersonDetail, -} from '../api/themoviedb'; +} from '../api/themoviedb/interfaces'; import Media from '../entity/Media'; export interface PersonDetail { diff --git a/server/models/Search.ts b/server/models/Search.ts index 7d347207..0dab4e58 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -2,7 +2,7 @@ import type { TmdbMovieResult, TmdbPersonResult, TmdbTvResult, -} from '../api/themoviedb'; +} from '../api/themoviedb/interfaces'; import { MediaType as MainMediaType } from '../constants/media'; import Media from '../entity/Media'; diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 5f84315f..420dca28 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -10,12 +10,12 @@ import { Keyword, mapVideos, } from './common'; -import { +import type { TmdbTvEpisodeResult, TmdbTvSeasonResult, TmdbTvDetails, TmdbSeasonWithEpisodes, -} from '../api/themoviedb'; +} from '../api/themoviedb/interfaces'; import type Media from '../entity/Media'; import { Video } from './Movie'; diff --git a/server/models/common.ts b/server/models/common.ts index dcd81934..d26cf637 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -1,11 +1,11 @@ -import { +import type { TmdbCreditCast, TmdbAggregateCreditCast, TmdbCreditCrew, TmdbExternalIds, TmdbVideo, TmdbVideoResult, -} from '../api/themoviedb'; +} from '../api/themoviedb/interfaces'; import { Video } from '../models/Movie'; diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index e7d76d78..ca12ddf4 100644 --- a/server/utils/typeHelpers.ts +++ b/server/utils/typeHelpers.ts @@ -2,7 +2,7 @@ import type { TmdbMovieResult, TmdbTvResult, TmdbPersonResult, -} from '../api/themoviedb'; +} from '../api/themoviedb/interfaces'; export const isMovie = ( movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 3a331d89..172abdd9 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -6,7 +6,7 @@ import { defineMessages, useIntl } from 'react-intl'; import { MediaRequest } from '../../../server/entity/MediaRequest'; import useSWR from 'swr'; import { useToasts } from 'react-toast-notifications'; -import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb'; +import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants'; import axios from 'axios'; import { MediaStatus, diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index c998e7b4..16977793 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -28,7 +28,7 @@ import RTAudFresh from '../../assets/rt_aud_fresh.svg'; import RTAudRotten from '../../assets/rt_aud_rotten.svg'; import type { RTRating } from '../../../server/api/rottentomatoes'; import Head from 'next/head'; -import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb'; +import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants'; import ExternalLinkBlock from '../ExternalLinkBlock'; import { sortCrewPriority } from '../../utils/creditHelpers'; import { Crew } from '../../../server/models/common'; diff --git a/yarn.lock b/yarn.lock index d269381c..445f1d33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4015,6 +4015,11 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" +clone@2.x: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= + clone@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" @@ -9469,6 +9474,13 @@ node-addon-api@^3.0.2: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.0.2.tgz#04bc7b83fd845ba785bb6eae25bc857e1ef75681" integrity sha512-+D4s2HCnxPd5PjjI0STKwncjXTUKKqm74MDMz9OPXavjsGmjkvwgLtA5yoxJUdmpj52+2u+RrXgPipahKczMKg== +node-cache@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" + integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== + dependencies: + clone "2.x" + node-emoji@^1.10.0, node-emoji@^1.8.1: version "1.10.0" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.10.0.tgz#8886abd25d9c7bb61802a658523d1f8d2a89b2da"