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 <ryan@sct.dev>
pull/2419/head
Danshil Kokil Mungur 3 years ago committed by GitHub
parent e0b6abe479
commit b31cdbf074
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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:

@ -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<TmdbPersonDetail> => {
}): Promise<TmdbPersonDetails> => {
try {
const data = await this.get<TmdbPersonDetail>(`/person/${personId}`, {
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
params: { language },
});

@ -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;

@ -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<TmdbSearchMultiResponse>;
}
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<TmdbSearchMultiResponse> => {
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<TmdbMovieDetails>
| PromiseFulfilledResult<TmdbTvDetails>
| PromiseFulfilledResult<TmdbPersonDetails>
)[];
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<TmdbSearchMultiResponse> => {
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<TmdbSearchMultiResponse> => {
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,
};
},
});

@ -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,

@ -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: [],
});

@ -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 queryString = req.query.query as string;
const searchProvider = findSearchProvider(queryString.toLowerCase());
let results: TmdbSearchMultiResponse;
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();
const results = await tmdb.searchMulti({
query: req.query.query as string,
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)

@ -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;
};

@ -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<PersonDetail>(
const { data, error } = useSWR<PersonDetailsType>(
`/api/v1/person/${router.query.personId}`
);
const [showBio, setShowBio] = useState(false);

Loading…
Cancel
Save