From c702c17cee00a52b23f685206e2d5d0c2eddf5a2 Mon Sep 17 00:00:00 2001 From: sct Date: Tue, 8 Sep 2020 19:05:55 +0900 Subject: [PATCH] feat(api): tmdb api wrapper / multi search route (#62) Adds a "The Movie DB" api wrapper for some basic requests (search/get movie details/get tv details). Also adds a search endpoint to our api and mappers to convert the tmdb results --- server/api/themoviedb.ts | 242 +++++++++++++++++++++++++++++++++++++++ server/models/Search.ts | 116 +++++++++++++++++++ server/overseerr-api.yml | 145 +++++++++++++++++++++++ server/routes/index.ts | 2 + server/routes/search.ts | 21 ++++ 5 files changed, 526 insertions(+) create mode 100644 server/api/themoviedb.ts create mode 100644 server/models/Search.ts create mode 100644 server/routes/search.ts diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts new file mode 100644 index 000000000..4002d1f5a --- /dev/null +++ b/server/api/themoviedb.ts @@ -0,0 +1,242 @@ +import axios, { AxiosInstance } from 'axios'; + +interface SearchOptions { + query: string; + page?: number; + includeAdult?: boolean; +} + +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 TmdbSearchMultiResponse { + page: number; + total_results: number; + total_pages: number; + results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[]; +} + +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; +} + +interface TmdbTvEpisodeDetails { + 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; +} + +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?: TmdbTvEpisodeDetails; + name: string; + next_episode_to_air?: TmdbTvEpisodeDetails; + 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; + }[]; + seasons: { + id: number; + air_date: string; + episode_count: number; + name: string; + overview: string; + poster_path: string; + season_number: number; + }[]; + status: string; + type: string; + vote_average: number; + vote_count: number; +} + +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, + }: SearchOptions): Promise => { + try { + const response = await this.axios.get('/search/multi', { + params: { query, page, include_adult: includeAdult }, + }); + + return response.data; + } catch (e) { + throw new Error(`[TMDB] Failed to search multi: ${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 }, + } + ); + + 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 }, + }); + + return response.data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`); + } + }; +} + +export default TheMovieDb; diff --git a/server/models/Search.ts b/server/models/Search.ts new file mode 100644 index 000000000..8a4b18f61 --- /dev/null +++ b/server/models/Search.ts @@ -0,0 +1,116 @@ +import type { + TmdbMovieResult, + TmdbPersonResult, + TmdbTvResult, +} from '../api/themoviedb'; + +export type MediaType = 'tv' | 'movie' | 'person'; + +interface SearchResult { + id: number; + mediaType: MediaType; + popularity: number; + posterPath?: string; + backdropPath?: string; + voteCount: number; + voteAverage: number; + genreIds: number[]; + overview: string; + originalLanguage: string; +} + +export interface MovieResult extends SearchResult { + mediaType: 'movie'; + title: string; + originalTitle: string; + releaseDate: string; + adult: boolean; + video: boolean; +} + +export interface TvResult extends SearchResult { + mediaType: 'tv'; + name: string; + originalName: string; + originCountry: string[]; + firstAirDate: string; +} + +export interface PersonResult { + id: number; + name: string; + popularity: number; + profilePath?: string; + adult: boolean; + mediaType: 'person'; + knownFor: (MovieResult | TvResult)[]; +} + +export type Results = MovieResult | TvResult | PersonResult; + +export const mapMovieResult = (movieResult: TmdbMovieResult): MovieResult => ({ + id: movieResult.id, + mediaType: 'movie', + adult: movieResult.adult, + genreIds: movieResult.genre_ids, + originalLanguage: movieResult.original_language, + originalTitle: movieResult.original_title, + overview: movieResult.overview, + popularity: movieResult.popularity, + releaseDate: movieResult.release_date, + title: movieResult.title, + video: movieResult.video, + voteAverage: movieResult.vote_average, + voteCount: movieResult.vote_count, + backdropPath: movieResult.backdrop_path, + posterPath: movieResult.poster_path, +}); + +export const mapTvResult = (tvResult: TmdbTvResult): TvResult => ({ + id: tvResult.id, + firstAirDate: tvResult.first_air_Date, + genreIds: tvResult.genre_ids, + mediaType: tvResult.media_type, + name: tvResult.name, + originCountry: tvResult.origin_country, + originalLanguage: tvResult.original_language, + originalName: tvResult.original_name, + overview: tvResult.overview, + popularity: tvResult.popularity, + voteAverage: tvResult.vote_average, + voteCount: tvResult.vote_count, + backdropPath: tvResult.backdrop_path, + posterPath: tvResult.poster_path, +}); + +export const mapPersonResult = ( + personResult: TmdbPersonResult +): PersonResult => ({ + id: personResult.id, + name: personResult.name, + popularity: personResult.popularity, + adult: personResult.adult, + mediaType: personResult.media_type, + profilePath: personResult.profile_path, + knownFor: personResult.known_for.map((result) => { + if (result.media_type === 'movie') { + return mapMovieResult(result); + } + + return mapTvResult(result); + }), +}); + +export const mapSearchResults = ( + results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] +): Results[] => + results.map((result) => { + switch (result.media_type) { + case 'movie': + return mapMovieResult(result); + case 'tv': + return mapTvResult(result); + default: + return mapPersonResult(result); + } + }); diff --git a/server/overseerr-api.yml b/server/overseerr-api.yml index 14b699b8a..3982ad21b 100644 --- a/server/overseerr-api.yml +++ b/server/overseerr-api.yml @@ -213,6 +213,114 @@ components: - radarr - sonarr - public + MovieResult: + type: object + required: + - id + - mediaType + - title + properties: + id: + type: number + example: 1234 + mediaType: + type: string + popularity: + type: number + example: 10 + posterPath: + type: string + backdropPath: + type: string + voteCount: + type: number + voteAverage: + type: number + genreIds: + type: array + items: + type: number + overview: + type: string + example: 'Overview of the movie' + originalLanguage: + type: string + example: 'en' + title: + type: string + example: Movie Title + originalTitle: + type: string + example: Original Movie Title + releaseDate: + type: string + adult: + type: boolean + example: false + video: + type: boolean + example: false + TvResult: + type: object + properties: + id: + type: number + example: 1234 + mediaType: + type: string + popularity: + type: number + example: 10 + posterPath: + type: string + backdropPath: + type: string + voteCount: + type: number + voteAverage: + type: number + genreIds: + type: array + items: + type: number + overview: + type: string + example: 'Overview of the movie' + originalLanguage: + type: string + example: 'en' + name: + type: string + example: TV Show Name + originalName: + type: string + example: Original TV Show Name + originCountry: + type: array + items: + type: string + firstAirDate: + type: string + PersonResult: + type: object + properties: + id: + type: number + example: 12345 + profilePath: + type: string + adult: + type: boolean + example: false + mediaType: + type: string + default: 'person' + knownFor: + type: array + items: + oneOf: + - $ref: '#/components/schemas/MovieResult' + - $ref: '#/components/schemas/TvResult' securitySchemes: cookieAuth: @@ -611,6 +719,43 @@ paths: application/json: schema: $ref: '#/components/schemas/User' + /search: + get: + summary: Search for movies/tv shows/people + description: Returns a list of movies/tv shows/people in JSON format + tags: + - search + parameters: + - in: query + name: query + required: true + schema: + type: string + example: 'Mulan' + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + results: + type: array + items: + anyOf: + - $ref: '#/components/schemas/MovieResult' + - $ref: '#/components/schemas/TvResult' + - $ref: '#/components/schemas/PersonResult' security: - cookieAuth: [] diff --git a/server/routes/index.ts b/server/routes/index.ts index 4d786e3eb..8c5f088ff 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -5,6 +5,7 @@ import { checkUser, isAuthenticated } from '../middleware/auth'; import settingsRoutes from './settings'; import { Permission } from '../lib/permissions'; import { getSettings } from '../lib/settings'; +import searchRoutes from './search'; const router = Router(); @@ -15,6 +16,7 @@ router.use( isAuthenticated(Permission.MANAGE_SETTINGS), settingsRoutes ); +router.use('/search', isAuthenticated(), searchRoutes); router.use('/auth', authRoutes); router.get('/settings/public', (_req, res) => { diff --git a/server/routes/search.ts b/server/routes/search.ts new file mode 100644 index 000000000..416a3a083 --- /dev/null +++ b/server/routes/search.ts @@ -0,0 +1,21 @@ +import { Router } from 'express'; +import TheMovieDb from '../api/themoviedb'; +import { mapSearchResults } from '../models/Search'; + +const searchRoutes = Router(); + +searchRoutes.get('/', async (req, res) => { + const tmdb = new TheMovieDb(); + + const results = await tmdb.searchMulti({ query: req.query.query as string }); + const megaResults = mapSearchResults(results.results); + console.log(megaResults); + return res.status(200).json({ + page: results.page, + totalPages: results.total_pages, + totalResults: results.total_results, + results: mapSearchResults(results.results), + }); +}); + +export default searchRoutes;