From f24d48fd66217cdd95888d588d36c88b24b97c88 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Wed, 9 Oct 2024 14:18:04 -0400 Subject: [PATCH 1/6] feat(discover tv): adds status filter to discover tv --- overseerr-api.yml | 57 ++++++++++++++ server/api/themoviedb/index.ts | 3 + server/interfaces/api/discoverInterfaces.ts | 5 ++ server/routes/discover.ts | 75 +++++++++++++++++++ .../Discover/FilterSlideover/index.tsx | 12 +++ src/components/Discover/constants.ts | 5 ++ src/components/Selector/index.tsx | 74 +++++++++++++++++- 7 files changed, 229 insertions(+), 2 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index c4c1e97b74..7857a21a8e 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,58 @@ paths: name: type: string example: Genre Name + /discover/status/movie: + get: + summary: Get statuses for movies + description: Returns a list of statuses + tags: + - search + responses: + '200': + description: Genre slider data returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: number + example: 1 + backdrops: + type: array + items: + type: string + 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: Genre slider data returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: number + example: 1 + backdrops: + type: array + items: + type: string + name: + type: string + example: Genre 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..7efe2dc6ea 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,78 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( } ); +enum MovieStatus { + 'Rumored', + 'Planned', + 'In Production', + 'Post Production', + 'Released', + 'Canceled', +} + +enum ShowStatus { + 'Returning Series' = 0, + 'Planned' = 1, + 'In Production' = 2, + 'Ended' = 3, + 'Canceled' = 4, + 'Pilot' = 5, +} + +discoverRoutes.get<{ language: string }, StatusItem[]>( + '/status/movie', + async (req, res, next) => { + try { + const statuses = Object.entries(MovieStatus) + .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 movie genre slider', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve movie genre slider.', + }); + } + } +); + +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 genre slider', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve series genre slider.', + }); + } + } +); + 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..45ec349cf0 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,16 @@ const FilterSlideover = ({ updateQueryParams('genre', value?.map((v) => v.value).join(',')); }} /> + + {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..38178ed967 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'; @@ -279,6 +282,73 @@ export const KeywordSelector = ({ ); }; +type StatusSelectorProps = BaseSelectorSingleProps & { + type: 'movie' | 'tv'; + // defaultValue: MovieStatus | ShowStatus; +}; + +export const StatusSelector = ({ + isMulti, + onChange, + defaultValue, + type, +}: StatusSelectorProps) => { + const intl = useIntl(); + const [defaultDataValue, setDefaultDataValue] = useState< + { label: string; value: number }[] | null + >(null); + const [options, setOptions] = useState<{ label: string; value: number }[]>( + [] + ); + + const loadGenreOptions = useCallback(async () => { + const results = await axios.get( + `/api/v1/discover/status/${type}` + ); + + const res = results.data.map((result) => ({ + label: result.name, + value: result.id, + })); + + return res; + }, [type]); + + useEffect(() => { + loadGenreOptions().then((res) => { + setOptions(res); + }); + }, [loadGenreOptions]); + + useEffect(() => { + const foundDefaultValue = options.find( + ({ value }) => value.toString() === defaultValue + ); + if (foundDefaultValue) { + setDefaultDataValue([foundDefaultValue]); + } + }, [defaultValue, options]); + + return ( + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onChange(value as any); + }} + isClearable + /> + ); +}; + type WatchProviderSelectorProps = { type: 'movie' | 'tv'; region?: string; From 88d66174eb11a405b07195f26035b12b5bb6e6f6 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Thu, 10 Oct 2024 10:24:56 -0400 Subject: [PATCH 2/6] fix(remove status filtering from movies): hides status filter in movies TMDB doesn't support status filtering, status input field is hidden until supported in TMDB --- .../Discover/FilterSlideover/index.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx index 45ec349cf0..2ef8400426 100644 --- a/src/components/Discover/FilterSlideover/index.tsx +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -151,16 +151,20 @@ const FilterSlideover = ({ updateQueryParams('genre', value?.map((v) => v.value).join(',')); }} /> - - {intl.formatMessage(messages.status)} - - { - updateQueryParams('status', value?.value.toString()); - }} - type={type} - defaultValue={currentFilters.status} - /> + {type === 'tv' && ( + <> + + {intl.formatMessage(messages.status)} + + { + updateQueryParams('status', value?.value.toString()); + }} + type={type} + defaultValue={currentFilters.status} + /> + + )} {intl.formatMessage(messages.keywords)} From 5ef051344184cbe67af87643ad0e86e95785cfdf Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Thu, 10 Oct 2024 10:38:50 -0400 Subject: [PATCH 3/6] fix(remove movie status. fix language): removes movie status endpoints. fixes language Fully disables the ability to filter by movies status and removes endpoints. Updates language leftover from copying genre selector for use in status selector --- overseerr-api.yml | 34 ++------------------------ server/routes/discover.ts | 40 ++----------------------------- src/components/Selector/index.tsx | 9 ++++--- 3 files changed, 8 insertions(+), 75 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 7857a21a8e..6d25734ee6 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4890,32 +4890,6 @@ paths: name: type: string example: Genre Name - /discover/status/movie: - get: - summary: Get statuses for movies - description: Returns a list of statuses - tags: - - search - responses: - '200': - description: Genre slider data returned - content: - application/json: - schema: - type: array - items: - type: object - properties: - id: - type: number - example: 1 - backdrops: - type: array - items: - type: string - name: - type: string - example: Genre Name /discover/status/tv: get: summary: Get statuses for TV series @@ -4924,7 +4898,7 @@ paths: - search responses: '200': - description: Genre slider data returned + description: Status data returned content: application/json: schema: @@ -4935,13 +4909,9 @@ paths: id: type: number example: 1 - backdrops: - type: array - items: - type: string name: type: string - example: Genre Name + example: Status Name /discover/watchlist: get: summary: Get the Plex watchlist. diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 7efe2dc6ea..eb6124e326 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -812,15 +812,6 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( } ); -enum MovieStatus { - 'Rumored', - 'Planned', - 'In Production', - 'Post Production', - 'Released', - 'Canceled', -} - enum ShowStatus { 'Returning Series' = 0, 'Planned' = 1, @@ -830,33 +821,6 @@ enum ShowStatus { 'Pilot' = 5, } -discoverRoutes.get<{ language: string }, StatusItem[]>( - '/status/movie', - async (req, res, next) => { - try { - const statuses = Object.entries(MovieStatus) - .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 movie genre slider', { - label: 'API', - errorMessage: e.message, - }); - return next({ - status: 500, - message: 'Unable to retrieve movie genre slider.', - }); - } - } -); - discoverRoutes.get<{ language: string }, StatusItem[]>( '/status/tv', async (req, res, next) => { @@ -872,13 +836,13 @@ discoverRoutes.get<{ language: string }, StatusItem[]>( return res.status(200).json(sortedData); } catch (e) { - logger.debug('Something went wrong retrieving the series genre slider', { + logger.debug('Something went wrong retrieving the series status', { label: 'API', errorMessage: e.message, }); return next({ status: 500, - message: 'Unable to retrieve series genre slider.', + message: 'Unable to retrieve series status.', }); } } diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index 38178ed967..cb42f93f94 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -284,7 +284,6 @@ export const KeywordSelector = ({ type StatusSelectorProps = BaseSelectorSingleProps & { type: 'movie' | 'tv'; - // defaultValue: MovieStatus | ShowStatus; }; export const StatusSelector = ({ @@ -301,7 +300,7 @@ export const StatusSelector = ({ [] ); - const loadGenreOptions = useCallback(async () => { + const loadStatusOptions = useCallback(async () => { const results = await axios.get( `/api/v1/discover/status/${type}` ); @@ -315,10 +314,10 @@ export const StatusSelector = ({ }, [type]); useEffect(() => { - loadGenreOptions().then((res) => { + loadStatusOptions().then((res) => { setOptions(res); }); - }, [loadGenreOptions]); + }, [loadStatusOptions]); useEffect(() => { const foundDefaultValue = options.find( @@ -338,7 +337,7 @@ export const StatusSelector = ({ defaultOptions cacheOptions isMulti={isMulti} - loadOptions={loadGenreOptions} + loadOptions={loadStatusOptions} placeholder={intl.formatMessage(messages.searchGenres)} onChange={(value) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any From f83d1abb60454b959a575c31e34d8f8eb7da6552 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Thu, 10 Oct 2024 10:43:24 -0400 Subject: [PATCH 4/6] fix(fix language): fix placeholder language update placeholder language to Select status... --- src/components/Selector/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index cb42f93f94..54086e02fe 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -31,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.', @@ -338,7 +339,7 @@ export const StatusSelector = ({ cacheOptions isMulti={isMulti} loadOptions={loadStatusOptions} - placeholder={intl.formatMessage(messages.searchGenres)} + placeholder={intl.formatMessage(messages.searchStatus)} onChange={(value) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any onChange(value as any); From ad69a40b54973a1b7f7d6c9e19371e7a11aad2c5 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Thu, 10 Oct 2024 10:56:09 -0400 Subject: [PATCH 5/6] refactor(removes options state): removes state previously only used for setting the default value --- src/components/Selector/index.tsx | 59 +++++++++++++++++-------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index 54086e02fe..0ee4f256fd 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -297,37 +297,42 @@ export const StatusSelector = ({ const [defaultDataValue, setDefaultDataValue] = useState< { label: string; value: number }[] | null >(null); - const [options, setOptions] = useState<{ label: string; value: number }[]>( - [] - ); - - const loadStatusOptions = useCallback(async () => { - const results = await axios.get( - `/api/v1/discover/status/${type}` - ); - - const res = results.data.map((result) => ({ - label: result.name, - value: result.id, - })); - return res; - }, [type]); + const loadStatusOptions = useCallback( + async (inputValue?: string) => { + const results = await axios.get( + `/api/v1/discover/status/${type}` + ); - useEffect(() => { - loadStatusOptions().then((res) => { - setOptions(res); - }); - }, [loadStatusOptions]); + 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 foundDefaultValue = options.find( - ({ value }) => value.toString() === defaultValue - ); - if (foundDefaultValue) { - setDefaultDataValue([foundDefaultValue]); - } - }, [defaultValue, options]); + const setDefault = () => { + loadStatusOptions().then((res) => { + const foundDefaultValue = res.find( + ({ value }) => value.toString() === defaultValue + ); + if (foundDefaultValue) { + setDefaultDataValue([foundDefaultValue]); + } + }); + }; + setDefault(); + }, [defaultValue, loadStatusOptions]); return ( Date: Thu, 10 Oct 2024 11:04:09 -0400 Subject: [PATCH 6/6] refactor(add i18n keys): adds translation keys --- src/i18n/locale/en.json | 2 ++ 1 file changed, 2 insertions(+) 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",