mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
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
This commit is contained in:
242
server/api/themoviedb.ts
Normal file
242
server/api/themoviedb.ts
Normal file
@@ -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<TmdbSearchMultiResponse> => {
|
||||||
|
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<TmdbMovieDetails> => {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get<TmdbMovieDetails>(
|
||||||
|
`/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<TmdbTvDetails> => {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
|
||||||
|
params: { language },
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TheMovieDb;
|
116
server/models/Search.ts
Normal file
116
server/models/Search.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
@@ -213,6 +213,114 @@ components:
|
|||||||
- radarr
|
- radarr
|
||||||
- sonarr
|
- sonarr
|
||||||
- public
|
- 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:
|
securitySchemes:
|
||||||
cookieAuth:
|
cookieAuth:
|
||||||
@@ -611,6 +719,43 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/User'
|
$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:
|
security:
|
||||||
- cookieAuth: []
|
- cookieAuth: []
|
||||||
|
@@ -5,6 +5,7 @@ import { checkUser, isAuthenticated } from '../middleware/auth';
|
|||||||
import settingsRoutes from './settings';
|
import settingsRoutes from './settings';
|
||||||
import { Permission } from '../lib/permissions';
|
import { Permission } from '../lib/permissions';
|
||||||
import { getSettings } from '../lib/settings';
|
import { getSettings } from '../lib/settings';
|
||||||
|
import searchRoutes from './search';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ router.use(
|
|||||||
isAuthenticated(Permission.MANAGE_SETTINGS),
|
isAuthenticated(Permission.MANAGE_SETTINGS),
|
||||||
settingsRoutes
|
settingsRoutes
|
||||||
);
|
);
|
||||||
|
router.use('/search', isAuthenticated(), searchRoutes);
|
||||||
router.use('/auth', authRoutes);
|
router.use('/auth', authRoutes);
|
||||||
|
|
||||||
router.get('/settings/public', (_req, res) => {
|
router.get('/settings/public', (_req, res) => {
|
||||||
|
21
server/routes/search.ts
Normal file
21
server/routes/search.ts
Normal file
@@ -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;
|
Reference in New Issue
Block a user