From b31cdbf074d5dbecbbf6da135a9b686aea9e3c0e Mon Sep 17 00:00:00 2001 From: Danshil Kokil Mungur Date: Fri, 14 Jan 2022 11:52:10 +0400 Subject: [PATCH] feat(search): search by id (#2082) * feat(search): search by id This adds the ability to search by ID (starting with TMDb ID). Since there doesn't seem to be way of searching across movies, tv and persons, I have to search through all 3 and use the first one in the order: movie -> tv -> person Searching by ID is triggered using a 'prefix' just like in the *arrs. * fix: missed some refactoring * feat(search): use locale language * feat(search): search using imdb id * feat(search): search using tvdb id * fix: alias type import * fix: missed some refactoring * fix(search): account for id being a string * feat(search): account for movies/tvs/persons with the same id * feat(search): remove non-null assertion Co-authored-by: Ryan Cohen --- overseerr-api.yml | 4 +- server/api/themoviedb/index.ts | 6 +- server/api/themoviedb/interfaces.ts | 5 +- server/lib/search.ts | 169 +++++++++++++++++++++++++ server/models/Person.ts | 8 +- server/models/Search.ts | 54 ++++++++ server/routes/search.ts | 28 +++- server/utils/typeHelpers.ts | 17 ++- src/components/PersonDetails/index.tsx | 4 +- 9 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 server/lib/search.ts diff --git a/overseerr-api.yml b/overseerr-api.yml index 211f029c..e3fc90e3 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1326,7 +1326,7 @@ components: running: type: boolean example: false - PersonDetail: + PersonDetails: type: object properties: id: @@ -4871,7 +4871,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PersonDetail' + $ref: '#/components/schemas/PersonDetails' /person/{personId}/combined_credits: get: diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index ddc18059..d565c35a 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -10,7 +10,7 @@ import { TmdbMovieDetails, TmdbNetwork, TmdbPersonCombinedCredits, - TmdbPersonDetail, + TmdbPersonDetails, TmdbProductionCompany, TmdbRegion, TmdbSearchMovieResponse, @@ -122,9 +122,9 @@ class TheMovieDb extends ExternalAPI { }: { personId: number; language?: string; - }): Promise => { + }): Promise => { try { - const data = await this.get(`/person/${personId}`, { + const data = await this.get(`/person/${personId}`, { params: { language }, }); diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index 7892fe46..2282fe05 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -67,6 +67,7 @@ export interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse { export interface TmdbExternalIdResponse { movie_results: TmdbMovieResult[]; tv_results: TmdbTvResult[]; + person_results: TmdbPersonResult[]; } export interface TmdbCreditCast { @@ -315,7 +316,7 @@ export interface TmdbKeyword { name: string; } -export interface TmdbPersonDetail { +export interface TmdbPersonDetails { id: number; name: string; birthday: string; @@ -324,7 +325,7 @@ export interface TmdbPersonDetail { also_known_as?: string[]; gender: number; biography: string; - popularity: string; + popularity: number; place_of_birth?: string; profile_path?: string; adult: boolean; diff --git a/server/lib/search.ts b/server/lib/search.ts new file mode 100644 index 00000000..b66079c8 --- /dev/null +++ b/server/lib/search.ts @@ -0,0 +1,169 @@ +import TheMovieDb from '../api/themoviedb'; +import { + TmdbMovieDetails, + TmdbMovieResult, + TmdbPersonDetails, + TmdbPersonResult, + TmdbSearchMultiResponse, + TmdbTvDetails, + TmdbTvResult, +} from '../api/themoviedb/interfaces'; +import { + mapMovieDetailsToResult, + mapPersonDetailsToResult, + mapTvDetailsToResult, +} from '../models/Search'; +import { isMovieDetails, isTvDetails } from '../utils/typeHelpers'; + +type SearchProviderId = 'TMDb' | 'IMDb' | 'TVDB'; + +interface SearchProvider { + id: SearchProviderId; + pattern: RegExp; + search: (id: string, language?: string) => Promise; +} + +const searchProviders: SearchProvider[] = []; + +export const findSearchProvider = ( + query: string +): SearchProvider | undefined => { + return searchProviders.find((provider) => provider.pattern.test(query)); +}; + +searchProviders.push({ + id: 'TMDb', + pattern: new RegExp(/(?<=tmdb:)\d+/), + search: async ( + id: string, + language?: string + ): Promise => { + const tmdb = new TheMovieDb(); + + const moviePromise = tmdb.getMovie({ movieId: parseInt(id), language }); + const tvShowPromise = tmdb.getTvShow({ tvId: parseInt(id), language }); + const personPromise = tmdb.getPerson({ personId: parseInt(id), language }); + + const responses = await Promise.allSettled([ + moviePromise, + tvShowPromise, + personPromise, + ]); + + const successfulResponses = responses.filter( + (r) => r.status === 'fulfilled' + ) as + | ( + | PromiseFulfilledResult + | PromiseFulfilledResult + | PromiseFulfilledResult + )[]; + + const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = []; + + if (successfulResponses.length) { + results.push( + ...successfulResponses.map((r) => { + if (isMovieDetails(r.value)) { + return mapMovieDetailsToResult(r.value); + } else if (isTvDetails(r.value)) { + return mapTvDetailsToResult(r.value); + } else { + return mapPersonDetailsToResult(r.value); + } + }) + ); + } + + return { + page: 1, + total_pages: 1, + total_results: results.length, + results, + }; + }, +}); + +searchProviders.push({ + id: 'IMDb', + pattern: new RegExp(/(?<=imdb:)(tt|nm)\d+/), + search: async ( + id: string, + language?: string + ): Promise => { + const tmdb = new TheMovieDb(); + + const responses = await tmdb.getByExternalId({ + externalId: id, + type: 'imdb', + language, + }); + + const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = []; + + // set the media_type here since searching by external id doesn't return it + results.push( + ...(responses.movie_results.map((movie) => ({ + ...movie, + media_type: 'movie', + })) as TmdbMovieResult[]), + ...(responses.tv_results.map((tv) => ({ + ...tv, + media_type: 'tv', + })) as TmdbTvResult[]), + ...(responses.person_results.map((person) => ({ + ...person, + media_type: 'person', + })) as TmdbPersonResult[]) + ); + + return { + page: 1, + total_pages: 1, + total_results: results.length, + results, + }; + }, +}); + +searchProviders.push({ + id: 'TVDB', + pattern: new RegExp(/(?<=tvdb:)\d+/), + search: async ( + id: string, + language?: string + ): Promise => { + const tmdb = new TheMovieDb(); + + const responses = await tmdb.getByExternalId({ + externalId: parseInt(id), + type: 'tvdb', + language, + }); + + const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = []; + + // set the media_type here since searching by external id doesn't return it + results.push( + ...(responses.movie_results.map((movie) => ({ + ...movie, + media_type: 'movie', + })) as TmdbMovieResult[]), + ...(responses.tv_results.map((tv) => ({ + ...tv, + media_type: 'tv', + })) as TmdbTvResult[]), + ...(responses.person_results.map((person) => ({ + ...person, + media_type: 'person', + })) as TmdbPersonResult[]) + ); + + return { + page: 1, + total_pages: 1, + total_results: results.length, + results, + }; + }, +}); diff --git a/server/models/Person.ts b/server/models/Person.ts index 14925edb..087ab1c7 100644 --- a/server/models/Person.ts +++ b/server/models/Person.ts @@ -1,11 +1,11 @@ import type { TmdbPersonCreditCast, TmdbPersonCreditCrew, - TmdbPersonDetail, + TmdbPersonDetails, } from '../api/themoviedb/interfaces'; import Media from '../entity/Media'; -export interface PersonDetail { +export interface PersonDetails { id: number; name: string; birthday: string; @@ -14,7 +14,7 @@ export interface PersonDetail { alsoKnownAs?: string[]; gender: number; biography: string; - popularity: string; + popularity: number; placeOfBirth?: string; profilePath?: string; adult: boolean; @@ -62,7 +62,7 @@ export interface CombinedCredit { crew: PersonCreditCrew[]; } -export const mapPersonDetails = (person: TmdbPersonDetail): PersonDetail => ({ +export const mapPersonDetails = (person: TmdbPersonDetails): PersonDetails => ({ id: person.id, name: person.name, birthday: person.birthday, diff --git a/server/models/Search.ts b/server/models/Search.ts index 0dab4e58..73427a37 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -1,6 +1,9 @@ import type { + TmdbMovieDetails, TmdbMovieResult, + TmdbPersonDetails, TmdbPersonResult, + TmdbTvDetails, TmdbTvResult, } from '../api/themoviedb/interfaces'; import { MediaType as MainMediaType } from '../constants/media'; @@ -140,3 +143,54 @@ export const mapSearchResults = ( return mapPersonResult(result); } }); + +export const mapMovieDetailsToResult = ( + movieDetails: TmdbMovieDetails +): TmdbMovieResult => ({ + id: movieDetails.id, + media_type: 'movie', + adult: movieDetails.adult, + genre_ids: movieDetails.genres.map((genre) => genre.id), + original_language: movieDetails.original_language, + original_title: movieDetails.original_title, + overview: movieDetails.overview ?? '', + popularity: movieDetails.popularity, + release_date: movieDetails.release_date, + title: movieDetails.title, + video: movieDetails.video, + vote_average: movieDetails.vote_average, + vote_count: movieDetails.vote_count, + backdrop_path: movieDetails.backdrop_path, + poster_path: movieDetails.poster_path, +}); + +export const mapTvDetailsToResult = ( + tvDetails: TmdbTvDetails +): TmdbTvResult => ({ + id: tvDetails.id, + media_type: 'tv', + first_air_date: tvDetails.first_air_date, + genre_ids: tvDetails.genres.map((genre) => genre.id), + name: tvDetails.name, + origin_country: tvDetails.origin_country, + original_language: tvDetails.original_language, + original_name: tvDetails.original_name, + overview: tvDetails.overview, + popularity: tvDetails.popularity, + vote_average: tvDetails.vote_average, + vote_count: tvDetails.vote_count, + backdrop_path: tvDetails.backdrop_path, + poster_path: tvDetails.poster_path, +}); + +export const mapPersonDetailsToResult = ( + personDetails: TmdbPersonDetails +): TmdbPersonResult => ({ + id: personDetails.id, + media_type: 'person', + name: personDetails.name, + popularity: personDetails.popularity, + adult: personDetails.adult, + profile_path: personDetails.profile_path, + known_for: [], +}); diff --git a/server/routes/search.ts b/server/routes/search.ts index c843e78c..466045d0 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,18 +1,34 @@ import { Router } from 'express'; import TheMovieDb from '../api/themoviedb'; +import { TmdbSearchMultiResponse } from '../api/themoviedb/interfaces'; import Media from '../entity/Media'; +import { findSearchProvider } from '../lib/search'; import { mapSearchResults } from '../models/Search'; const searchRoutes = Router(); searchRoutes.get('/', async (req, res) => { - const tmdb = new TheMovieDb(); + const queryString = req.query.query as string; + const searchProvider = findSearchProvider(queryString.toLowerCase()); + let results: TmdbSearchMultiResponse; - const results = await tmdb.searchMulti({ - query: req.query.query as string, - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - }); + if (searchProvider) { + const [id] = queryString + .toLowerCase() + .match(searchProvider.pattern) as RegExpMatchArray; + results = await searchProvider.search( + id, + req.locale ?? (req.query.language as string) + ); + } else { + const tmdb = new TheMovieDb(); + + results = await tmdb.searchMulti({ + query: queryString, + page: Number(req.query.page), + language: req.locale ?? (req.query.language as string), + }); + } const media = await Media.getRelatedMedia( results.results.map((result) => result.id) diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index ca12ddf4..04070244 100644 --- a/server/utils/typeHelpers.ts +++ b/server/utils/typeHelpers.ts @@ -1,7 +1,10 @@ import type { + TmdbMovieDetails, TmdbMovieResult, - TmdbTvResult, + TmdbPersonDetails, TmdbPersonResult, + TmdbTvDetails, + TmdbTvResult, } from '../api/themoviedb/interfaces'; export const isMovie = ( @@ -15,3 +18,15 @@ export const isPerson = ( ): person is TmdbPersonResult => { return (person as TmdbPersonResult).known_for !== undefined; }; + +export const isMovieDetails = ( + movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails +): movie is TmdbMovieDetails => { + return (movie as TmdbMovieDetails).title !== undefined; +}; + +export const isTvDetails = ( + tv: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails +): tv is TmdbTvDetails => { + return (tv as TmdbTvDetails).number_of_seasons !== undefined; +}; diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index 82391445..28aa0550 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -5,7 +5,7 @@ import { defineMessages, useIntl } from 'react-intl'; import TruncateMarkup from 'react-truncate-markup'; import useSWR from 'swr'; import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces'; -import type { PersonDetail } from '../../../server/models/Person'; +import type { PersonDetails as PersonDetailsType } from '../../../server/models/Person'; import Ellipsis from '../../assets/ellipsis.svg'; import globalMessages from '../../i18n/globalMessages'; import Error from '../../pages/_error'; @@ -27,7 +27,7 @@ const messages = defineMessages({ const PersonDetails: React.FC = () => { const intl = useIntl(); const router = useRouter(); - const { data, error } = useSWR( + const { data, error } = useSWR( `/api/v1/person/${router.query.personId}` ); const [showBio, setShowBio] = useState(false);