mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
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>
This commit is contained in:

committed by
GitHub

parent
e0b6abe479
commit
b31cdbf074
@@ -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;
|
||||
|
169
server/lib/search.ts
Normal file
169
server/lib/search.ts
Normal file
@@ -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 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)
|
||||
|
@@ -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);
|
||||
|
Reference in New Issue
Block a user