diff --git a/overseerr-api.yml b/overseerr-api.yml index c4c1e97b74..6d25734ee6 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4463,6 +4463,11 @@ paths: schema: type: string example: en + - in: query + name: status + schema: + type: string + example: 2 - in: query name: genre schema: @@ -4885,6 +4890,28 @@ paths: name: type: string example: Genre Name + /discover/status/tv: + get: + summary: Get statuses for TV series + description: Returns a list of statuses + tags: + - search + responses: + '200': + description: Status data returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: number + example: 1 + name: + type: string + example: Status Name /discover/watchlist: get: summary: Get the Plex watchlist. diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index ef36fcd6dd..d1518763bc 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -79,6 +79,7 @@ interface DiscoverMovieOptions { interface DiscoverTvOptions { page?: number; language?: string; + status?: string; firstAirDateGte?: string; firstAirDateLte?: string; withRuntimeGte?: string; @@ -527,6 +528,7 @@ class TheMovieDb extends ExternalAPI { sortBy = 'popularity.desc', page = 1, language = 'en', + status, firstAirDateGte, firstAirDateLte, includeEmptyReleaseDate = false, @@ -559,6 +561,7 @@ class TheMovieDb extends ExternalAPI { sort_by: sortBy, page, language, + with_status: status, region: this.region, // Set our release date values, but check if one is set and not the other, // so we can force a past date or a future date. TMDB Requires both values if one is set! diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts index 89cb7426f3..ff66fc449c 100644 --- a/server/interfaces/api/discoverInterfaces.ts +++ b/server/interfaces/api/discoverInterfaces.ts @@ -4,6 +4,11 @@ export interface GenreSliderItem { backdrops: string[]; } +export interface StatusItem { + id: number; + name: string; +} + export interface WatchlistItem { ratingKey: string; tmdbId: number; diff --git a/server/routes/discover.ts b/server/routes/discover.ts index b35306446f..eb6124e326 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -8,6 +8,7 @@ import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import type { GenreSliderItem, + StatusItem, WatchlistResponse, } from '@server/interfaces/api/discoverInterfaces'; import { getSettings } from '@server/lib/settings'; @@ -61,6 +62,7 @@ const QueryFilterOptions = z.object({ genre: z.coerce.string().optional(), keywords: z.coerce.string().optional(), language: z.coerce.string().optional(), + status: z.coerce.string().optional(), withRuntimeGte: z.coerce.string().optional(), withRuntimeLte: z.coerce.string().optional(), voteAverageGte: z.coerce.string().optional(), @@ -361,6 +363,7 @@ discoverRoutes.get('/tv', async (req, res, next) => { page: Number(query.page), sortBy: query.sortBy as SortOptions, language: req.locale ?? query.language, + status: query.status, genre: query.genre, network: query.network ? Number(query.network) : undefined, firstAirDateLte: query.firstAirDateLte @@ -809,6 +812,42 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( } ); +enum ShowStatus { + 'Returning Series' = 0, + 'Planned' = 1, + 'In Production' = 2, + 'Ended' = 3, + 'Canceled' = 4, + 'Pilot' = 5, +} + +discoverRoutes.get<{ language: string }, StatusItem[]>( + '/status/tv', + async (req, res, next) => { + try { + const statuses = Object.entries(ShowStatus) + .filter(([, v]) => !isNaN(Number(v))) + .map(([k, v]) => ({ + id: Number(v), + name: k, + })); + + const sortedData = sortBy(statuses, 'id'); + + return res.status(200).json(sortedData); + } catch (e) { + logger.debug('Something went wrong retrieving the series status', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve series status.', + }); + } + } +); + discoverRoutes.get, WatchlistResponse>( '/watchlist', async (req, res) => { diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx index 83d5a2e49a..2ef8400426 100644 --- a/src/components/Discover/FilterSlideover/index.tsx +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -8,6 +8,7 @@ import { CompanySelector, GenreSelector, KeywordSelector, + StatusSelector, WatchProviderSelector, } from '@app/components/Selector'; import useSettings from '@app/hooks/useSettings'; @@ -37,6 +38,7 @@ const messages = defineMessages({ tmdbuserscore: 'TMDB User Score', tmdbuservotecount: 'TMDB User Vote Count', runtime: 'Runtime', + status: 'Status', streamingservices: 'Streaming Services', voteCount: 'Number of votes between {minValue} and {maxValue}', }); @@ -149,6 +151,20 @@ const FilterSlideover = ({ updateQueryParams('genre', value?.map((v) => v.value).join(',')); }} /> + {type === 'tv' && ( + <> + + {intl.formatMessage(messages.status)} + + { + updateQueryParams('status', value?.value.toString()); + }} + type={type} + defaultValue={currentFilters.status} + /> + + )} {intl.formatMessage(messages.keywords)} diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index 0571f1fc70..0239f8fc7c 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -100,6 +100,7 @@ export const QueryFilterOptions = z.object({ genre: z.string().optional(), keywords: z.string().optional(), language: z.string().optional(), + status: z.string().optional(), withRuntimeGte: z.string().optional(), withRuntimeLte: z.string().optional(), voteAverageGte: z.string().optional(), @@ -155,6 +156,10 @@ export const prepareFilterValues = ( filterValues.language = values.language; } + if (values.status) { + filterValues.status = values.status; + } + if (values.withRuntimeGte) { filterValues.withRuntimeGte = values.withRuntimeGte; } diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index 7b21658723..0ee4f256fd 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -11,7 +11,10 @@ import type { TmdbGenre, TmdbKeywordSearchResponse, } from '@server/api/themoviedb/interfaces'; -import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; +import type { + GenreSliderItem, + StatusItem, +} from '@server/interfaces/api/discoverInterfaces'; import type { Keyword, ProductionCompany, @@ -19,7 +22,7 @@ import type { } from '@server/models/common'; import axios from 'axios'; import { orderBy } from 'lodash'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import type { MultiValue, SingleValue } from 'react-select'; import AsyncSelect from 'react-select/async'; @@ -28,6 +31,7 @@ import useSWR from 'swr'; const messages = defineMessages({ searchKeywords: 'Search keywords…', searchGenres: 'Select genres…', + searchStatus: 'Select status...', searchStudios: 'Search studios…', starttyping: 'Starting typing to search.', nooptions: 'No results.', @@ -279,6 +283,77 @@ export const KeywordSelector = ({ ); }; +type StatusSelectorProps = BaseSelectorSingleProps & { + type: 'movie' | 'tv'; +}; + +export const StatusSelector = ({ + isMulti, + onChange, + defaultValue, + type, +}: StatusSelectorProps) => { + const intl = useIntl(); + const [defaultDataValue, setDefaultDataValue] = useState< + { label: string; value: number }[] | null + >(null); + + const loadStatusOptions = useCallback( + async (inputValue?: string) => { + const results = await axios.get( + `/api/v1/discover/status/${type}` + ); + + const res = results.data + .map((result) => ({ + label: result.name, + value: result.id, + })) + .filter(({ label }) => + inputValue + ? label.toLowerCase().includes(inputValue.toLowerCase()) + : true + ); + + return res; + }, + [type] + ); + + useEffect(() => { + const setDefault = () => { + loadStatusOptions().then((res) => { + const foundDefaultValue = res.find( + ({ value }) => value.toString() === defaultValue + ); + if (foundDefaultValue) { + setDefaultDataValue([foundDefaultValue]); + } + }); + }; + setDefault(); + }, [defaultValue, loadStatusOptions]); + + return ( + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onChange(value as any); + }} + isClearable + /> + ); +}; + type WatchProviderSelectorProps = { type: 'movie' | 'tv'; region?: string; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 60a2d46bc0..3fa622b694 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -73,6 +73,7 @@ "components.Discover.FilterSlideover.releaseDate": "Release Date", "components.Discover.FilterSlideover.runtime": "Runtime", "components.Discover.FilterSlideover.runtimeText": "{minValue}-{maxValue} minute runtime", + "components.Discover.FilterSlideover.status": "Status", "components.Discover.FilterSlideover.streamingservices": "Streaming Services", "components.Discover.FilterSlideover.studio": "Studio", "components.Discover.FilterSlideover.tmdbuserscore": "TMDB User Score", @@ -516,6 +517,7 @@ "components.Selector.nooptions": "No results.", "components.Selector.searchGenres": "Select genres…", "components.Selector.searchKeywords": "Search keywords…", + "components.Selector.searchStatus": "Select status...", "components.Selector.searchStudios": "Search studios…", "components.Selector.showless": "Show Less", "components.Selector.showmore": "Show More",