diff --git a/overseerr-api.yml b/overseerr-api.yml index c48b6575f7..02dd025ede 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4756,6 +4756,71 @@ paths: schema: type: string example: en + - in: query + name: genre + schema: + type: string + example: 18 + - in: query + name: keywords + schema: + type: string + example: 1,2 + - in: query + name: sortBy + schema: + type: string + example: popularity.desc + - in: query + name: firstAirDateGte + schema: + type: string + example: 2022-01-01 + - in: query + name: firstAirDateLte + schema: + type: string + example: 2023-01-01 + - in: query + name: withRuntimeGte + schema: + type: number + example: 60 + - in: query + name: withRuntimeLte + schema: + type: number + example: 120 + - in: query + name: voteAverageGte + schema: + type: number + example: 7 + - in: query + name: voteAverageLte + schema: + type: number + example: 10 + - in: query + name: voteCountGte + schema: + type: number + example: 7 + - in: query + name: voteCountLte + schema: + type: number + example: 10 + - in: query + name: watchRegion + schema: + type: string + example: US + - in: query + name: watchProviders + schema: + type: string + example: 8|9 responses: '200': description: Results @@ -4775,6 +4840,10 @@ paths: example: 200 network: $ref: '#/components/schemas/Network' + keywords: + type: array + items: + $ref: '#/components/schemas/Keyword' results: type: array items: diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 6bf090b0ff..a6149d7222 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -53,7 +53,9 @@ export type SortOptions = | 'vote_count.asc' | 'vote_count.desc' | 'first_air_date.asc' - | 'first_air_date.desc'; + | 'first_air_date.desc' + | 'name.asc' + | 'name.desc'; interface DiscoverMovieOptions { page?: number; @@ -512,7 +514,14 @@ class TheMovieDb extends ExternalAPI { 'with_runtime.lte': withRuntimeLte, 'vote_average.gte': voteAverageGte, 'vote_average.lte': voteAverageLte, - 'vote_count.gte': voteCountGte, + // Require minimum votes when filtering or sorting by rating to exclude unreliable ratings + 'vote_count.gte': + voteCountGte ?? + (sortBy?.startsWith('vote_average') + ? '50' + : voteAverageGte || voteAverageLte + ? '1' + : undefined), 'vote_count.lte': voteCountLte, watch_region: watchRegion, with_watch_providers: watchProviders, @@ -586,7 +595,14 @@ class TheMovieDb extends ExternalAPI { 'with_runtime.lte': withRuntimeLte, 'vote_average.gte': voteAverageGte, 'vote_average.lte': voteAverageLte, - 'vote_count.gte': voteCountGte, + // Require minimum votes when filtering or sorting by rating to exclude unreliable ratings + 'vote_count.gte': + voteCountGte ?? + (sortBy?.startsWith('vote_average') + ? '50' + : voteAverageGte || voteAverageLte + ? '1' + : undefined), 'vote_count.lte': voteCountLte, with_watch_providers: watchProviders, watch_region: watchRegion, diff --git a/server/routes/discover.ts b/server/routes/discover.ts index b35306446f..681abe7308 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -537,26 +537,60 @@ discoverRoutes.get<{ genreId: string }>( discoverRoutes.get<{ networkId: string }>( '/tv/network/:networkId', async (req, res, next) => { - const tmdb = new TheMovieDb(); + const tmdb = createTmdbWithRegionLanguage(req.user); try { + const query = QueryFilterOptions.parse(req.query); + const keywords = query.keywords; const network = await tmdb.getNetwork(Number(req.params.networkId)); const data = await tmdb.getDiscoverTv({ - page: Number(req.query.page), - language: (req.query.language as string) ?? req.locale, + page: Number(query.page) || 1, + sortBy: query.sortBy as SortOptions, + language: req.locale ?? query.language, + genre: query.genre, network: Number(req.params.networkId), + firstAirDateLte: query.firstAirDateLte + ? new Date(query.firstAirDateLte).toISOString().split('T')[0] + : undefined, + firstAirDateGte: query.firstAirDateGte + ? new Date(query.firstAirDateGte).toISOString().split('T')[0] + : undefined, + originalLanguage: query.language, + keywords, + withRuntimeGte: query.withRuntimeGte, + withRuntimeLte: query.withRuntimeLte, + voteAverageGte: query.voteAverageGte, + voteAverageLte: query.voteAverageLte, + voteCountGte: query.voteCountGte, + voteCountLte: query.voteCountLte, + watchProviders: query.watchProviders, + watchRegion: query.watchRegion, }); const media = await Media.getRelatedMedia( data.results.map((result) => result.id) ); + let keywordData: TmdbKeyword[] = []; + if (keywords) { + const splitKeywords = keywords.split(','); + + keywordData = await Promise.all( + splitKeywords.map(async (keywordId) => { + return await tmdb.getKeywordDetails({ + keywordId: Number(keywordId), + }); + }) + ); + } + return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, network: mapNetwork(network), + keywords: keywordData, results: data.results.map((result) => mapTvResult( result, diff --git a/src/components/Discover/DiscoverNetwork/index.tsx b/src/components/Discover/DiscoverNetwork/index.tsx index f09fef378c..ce87faed2a 100644 --- a/src/components/Discover/DiscoverNetwork/index.tsx +++ b/src/components/Discover/DiscoverNetwork/index.tsx @@ -1,21 +1,61 @@ +import Button from '@app/components/Common/Button'; import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; import PageTitle from '@app/components/Common/PageTitle'; +import type { FilterOptions } from '@app/components/Discover/constants'; +import { + countActiveFilters, + prepareFilterValues, +} from '@app/components/Discover/constants'; +import FilterSlideover from '@app/components/Discover/FilterSlideover'; import useDiscover from '@app/hooks/useDiscover'; +import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; import globalMessages from '@app/i18n/globalMessages'; import Error from '@app/pages/_error'; +import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid'; +import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb'; import type { TvNetwork } from '@server/models/common'; import type { TvResult } from '@server/models/Search'; import { useRouter } from 'next/router'; +import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import useSWR from 'swr'; const messages = defineMessages({ networkSeries: '{network} Series', + activefilters: + '{count, plural, one {# Active Filter} other {# Active Filters}}', + sortPopularityAsc: 'Popularity Ascending', + sortPopularityDesc: 'Popularity Descending', + sortFirstAirDateAsc: 'First Air Date Ascending', + sortFirstAirDateDesc: 'First Air Date Descending', + sortTmdbRatingAsc: 'TMDB Rating Ascending', + sortTmdbRatingDesc: 'TMDB Rating Descending', + sortTitleAsc: 'Title (A-Z) Ascending', + sortTitleDesc: 'Title (Z-A) Descending', }); +const SortOptions: Record = { + PopularityAsc: 'popularity.asc', + PopularityDesc: 'popularity.desc', + FirstAirDateAsc: 'first_air_date.asc', + FirstAirDateDesc: 'first_air_date.desc', + TmdbRatingAsc: 'vote_average.asc', + TmdbRatingDesc: 'vote_average.desc', + TitleAsc: 'name.asc', + TitleDesc: 'name.desc', +} as const; + const DiscoverTvNetwork = () => { const router = useRouter(); const intl = useIntl(); + const [showFilters, setShowFilters] = useState(false); + const preparedFilters = prepareFilterValues(router.query); + const updateQueryParams = useUpdateQueryParams({}); + + const { data: network } = useSWR( + router.query.networkId ? `/api/v1/network/${router.query.networkId}` : null + ); const { isLoadingInitialData, @@ -26,8 +66,11 @@ const DiscoverTvNetwork = () => { fetchMore, error, firstResultData, - } = useDiscover( - `/api/v1/discover/tv/network/${router.query.networkId}` + } = useDiscover( + `/api/v1/discover/tv/network/${router.query.networkId}`, + { + ...preparedFilters, + } ); if (error) { @@ -43,10 +86,10 @@ const DiscoverTvNetwork = () => { return ( <> -
+
{firstResultData?.network.logoPath ? ( -
+
{firstResultData.network.name} { title )}
+
+
+ + + + +
+ setShowFilters(false)} + show={showFilters} + lockedNetwork={network} + /> +
+ +
+
0) } - isReachingEnd={isReachingEnd} onScrollBottom={fetchMore} /> diff --git a/src/components/Discover/DiscoverTv/index.tsx b/src/components/Discover/DiscoverTv/index.tsx index 527393676b..dd187e0b5c 100644 --- a/src/components/Discover/DiscoverTv/index.tsx +++ b/src/components/Discover/DiscoverTv/index.tsx @@ -39,8 +39,8 @@ const SortOptions: Record = { FirstAirDateDesc: 'first_air_date.desc', TmdbRatingAsc: 'vote_average.asc', TmdbRatingDesc: 'vote_average.desc', - TitleAsc: 'original_title.asc', - TitleDesc: 'original_title.desc', + TitleAsc: 'name.asc', + TitleDesc: 'name.desc', } as const; const DiscoverTv = () => { diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx index 83d5a2e49a..88878c183e 100644 --- a/src/components/Discover/FilterSlideover/index.tsx +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -15,7 +15,8 @@ import { useBatchUpdateQueryParams, useUpdateQueryParams, } from '@app/hooks/useUpdateQueryParams'; -import { XCircleIcon } from '@heroicons/react/24/outline'; +import { LockClosedIcon, XCircleIcon } from '@heroicons/react/24/outline'; +import type { TvNetwork } from '@server/models/common'; import { defineMessages, useIntl } from 'react-intl'; import Datepicker from 'react-tailwindcss-datepicker-sct'; @@ -28,6 +29,7 @@ const messages = defineMessages({ from: 'From', to: 'To', studio: 'Studio', + network: 'Network', genres: 'Genres', keywords: 'Keywords', originalLanguage: 'Original Language', @@ -46,6 +48,7 @@ type FilterSlideoverProps = { onClose: () => void; type: 'movie' | 'tv'; currentFilters: FilterOptions; + lockedNetwork?: TvNetwork; }; const FilterSlideover = ({ @@ -53,6 +56,7 @@ const FilterSlideover = ({ onClose, type, currentFilters, + lockedNetwork, }: FilterSlideoverProps) => { const intl = useIntl(); const { currentSettings } = useSettings(); @@ -69,7 +73,7 @@ const FilterSlideover = ({ show={show} title={intl.formatMessage(messages.filters)} subText={intl.formatMessage(messages.activefilters, { - count: countActiveFilters(currentFilters), + count: countActiveFilters(currentFilters) + (lockedNetwork ? 1 : 0), })} onClose={() => onClose()} > @@ -125,6 +129,17 @@ const FilterSlideover = ({
+ {lockedNetwork && ( + <> + + {intl.formatMessage(messages.network)} + +
+ + {lockedNetwork.name} +
+ + )} {type === 'movie' && ( <> diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index e9f3b41187..57d59944ca 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -40,7 +40,16 @@ "components.Discover.DiscoverMovies.sortTitleDesc": "Title (Z-A) Descending", "components.Discover.DiscoverMovies.sortTmdbRatingAsc": "TMDB Rating Ascending", "components.Discover.DiscoverMovies.sortTmdbRatingDesc": "TMDB Rating Descending", + "components.Discover.DiscoverNetwork.activefilters": "{count, plural, one {# Active Filter} other {# Active Filters}}", "components.Discover.DiscoverNetwork.networkSeries": "{network} Series", + "components.Discover.DiscoverNetwork.sortFirstAirDateAsc": "First Air Date Ascending", + "components.Discover.DiscoverNetwork.sortFirstAirDateDesc": "First Air Date Descending", + "components.Discover.DiscoverNetwork.sortPopularityAsc": "Popularity Ascending", + "components.Discover.DiscoverNetwork.sortPopularityDesc": "Popularity Descending", + "components.Discover.DiscoverNetwork.sortTitleAsc": "Title (A-Z) Ascending", + "components.Discover.DiscoverNetwork.sortTitleDesc": "Title (Z-A) Descending", + "components.Discover.DiscoverNetwork.sortTmdbRatingAsc": "TMDB Rating Ascending", + "components.Discover.DiscoverNetwork.sortTmdbRatingDesc": "TMDB Rating Descending", "components.Discover.DiscoverSliderEdit.deletefail": "Failed to delete slider.", "components.Discover.DiscoverSliderEdit.deletesuccess": "Sucessfully deleted slider.", "components.Discover.DiscoverSliderEdit.enable": "Toggle Visibility", @@ -68,6 +77,7 @@ "components.Discover.FilterSlideover.from": "From", "components.Discover.FilterSlideover.genres": "Genres", "components.Discover.FilterSlideover.keywords": "Keywords", + "components.Discover.FilterSlideover.network": "Network", "components.Discover.FilterSlideover.originalLanguage": "Original Language", "components.Discover.FilterSlideover.ratingText": "Ratings between {minValue} and {maxValue}", "components.Discover.FilterSlideover.releaseDate": "Release Date",