diff --git a/seerr-api.yml b/seerr-api.yml index ae1fdc254f..0482306713 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -5714,6 +5714,11 @@ paths: schema: type: string example: 8|9 + - in: query + name: excludeWatchProviders + schema: + type: string + example: 8|9 - in: query name: certification schema: @@ -6040,6 +6045,11 @@ paths: schema: type: string example: 8|9 + - in: query + name: excludeWatchProviders + schema: + type: string + example: 8|9 - in: query name: status schema: diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 4693b3e86e..58d3edc873 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -105,6 +105,7 @@ interface DiscoverMovieOptions { sortBy?: MovieSortOptions; watchRegion?: string; watchProviders?: string; + excludeWatchProviders?: string; certification?: string; certificationGte?: string; certificationLte?: string; @@ -131,6 +132,7 @@ interface DiscoverTvOptions { sortBy?: TvSortOptions; watchRegion?: string; watchProviders?: string; + excludeWatchProviders?: string; withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5 certification?: string; certificationGte?: string; @@ -697,6 +699,7 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { voteCountGte, voteCountLte, watchProviders, + excludeWatchProviders, watchRegion, certification, certificationGte, @@ -750,6 +753,7 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { 'vote_count.lte': voteCountLte, watch_region: watchRegion, with_watch_providers: watchProviders, + without_watch_providers: excludeWatchProviders, certification: certification, 'certification.gte': certificationGte, 'certification.lte': certificationLte, @@ -784,6 +788,7 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { voteCountGte, voteCountLte, watchProviders, + excludeWatchProviders, watchRegion, withStatus, certification, @@ -836,6 +841,7 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { 'vote_count.gte': voteCountGte, 'vote_count.lte': voteCountLte, with_watch_providers: watchProviders, + without_watch_providers: excludeWatchProviders, watch_region: watchRegion, with_status: withStatus, certification: certification, diff --git a/server/routes/discover.ts b/server/routes/discover.ts index a949764e6c..a67382201a 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -81,6 +81,7 @@ const QueryFilterOptions = z.object({ voteCountLte: z.coerce.string().optional(), network: z.coerce.string().optional(), watchProviders: z.coerce.string().optional(), + excludeWatchProviders: z.coerce.string().optional(), watchRegion: z.coerce.string().optional(), status: z.coerce.string().optional(), certification: z.coerce.string().optional(), @@ -132,6 +133,7 @@ discoverRoutes.get('/movies', async (req, res, next) => { voteCountGte: query.voteCountGte, voteCountLte: query.voteCountLte, watchProviders: query.watchProviders, + excludeWatchProviders: query.excludeWatchProviders, watchRegion: query.watchRegion, certification: query.certification, certificationGte: query.certificationGte, @@ -444,6 +446,7 @@ discoverRoutes.get('/tv', async (req, res, next) => { voteCountGte: query.voteCountGte, voteCountLte: query.voteCountLte, watchProviders: query.watchProviders, + excludeWatchProviders: query.excludeWatchProviders, watchRegion: query.watchRegion, withStatus: query.status, certification: query.certification, diff --git a/src/components/Discover/CreateSlider/index.tsx b/src/components/Discover/CreateSlider/index.tsx index db803518fa..fef2560663 100644 --- a/src/components/Discover/CreateSlider/index.tsx +++ b/src/components/Discover/CreateSlider/index.tsx @@ -285,14 +285,16 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { type: DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES, title: intl.formatMessage(sliderTitles.tmdbmoviestreamingservices), dataUrl: '/api/v1/discover/movies', - params: 'watchRegion=$regionValue&watchProviders=$providersValue', + params: + 'watchRegion=$regionValue&watchProviders=$providersValue&excludeWatchProviders=$excludeProvidersValue', titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder), }, { type: DiscoverSliderType.TMDB_TV_STREAMING_SERVICES, title: intl.formatMessage(sliderTitles.tmdbtvstreamingservices), dataUrl: '/api/v1/discover/tv', - params: 'watchRegion=$regionValue&watchProviders=$providersValue', + params: + 'watchRegion=$regionValue&watchProviders=$providersValue&excludeWatchProviders=$excludeProvidersValue', titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder), }, ]; @@ -444,11 +446,20 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { activeProviders={ slider?.data ?.split(',')[1] - .split('|') + ?.split('|') .map((v) => Number(v)) ?? [] } - onChange={(region, providers) => { - setFieldValue('data', `${region},${providers.join('|')}`); + excludeProviders={ + slider?.data + ?.split(',')[2] + ?.split('|') + .map((v) => Number(v)) ?? [] + } + onChange={(region, providers, excludes) => { + setFieldValue( + 'data', + `${region},${providers.join('|')},${excludes.join('|')}` + ); }} /> ); @@ -461,11 +472,20 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { activeProviders={ slider?.data ?.split(',')[1] - .split('|') + ?.split('|') + .map((v) => Number(v)) ?? [] + } + excludeProviders={ + slider?.data + ?.split(',')[2] + ?.split('|') .map((v) => Number(v)) ?? [] } - onChange={(region, providers) => { - setFieldValue('data', `${region},${providers.join('|')}`); + onChange={(region, providers, excludes) => { + setFieldValue( + 'data', + `${region},${providers.join('|')},${excludes.join('|')}` + ); }} /> ); @@ -555,6 +575,10 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { '$providersValue', encodeURIExtraParams(values?.data.split(',')[1]) ) + .replace( + '$excludeProvidersValue', + encodeURIExtraParams(values?.data.split(',')[2]) + ) : activeOption.params?.replace( '$value', encodeURIExtraParams(values.data) diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx index a20cb50142..93510ff3d9 100644 --- a/src/components/Discover/FilterSlideover/index.tsx +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -343,16 +343,23 @@ const FilterSlideover = ({ currentFilters.watchProviders?.split('|').map((v) => Number(v)) ?? [] } - onChange={(region, providers) => { - if (providers.length) { + excludeProviders={ + currentFilters.excludeWatchProviders + ?.split('|') + .map((v) => Number(v)) ?? [] + } + onChange={(region, providers, excludes) => { + if (providers.length || excludes.length) { batchUpdateQueryParams({ watchRegion: region, watchProviders: providers.join('|'), + excludeWatchProviders: excludes.join('|'), }); } else { batchUpdateQueryParams({ watchRegion: undefined, watchProviders: undefined, + excludeWatchProviders: undefined, }); } }} diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index 4ce5e34f66..38180e8e99 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -109,6 +109,7 @@ export const QueryFilterOptions = z.object({ voteCountGte: z.string().optional(), watchRegion: z.string().optional(), watchProviders: z.string().optional(), + excludeWatchProviders: z.string().optional(), status: z.string().optional(), certification: z.string().optional(), certificationGte: z.string().optional(), @@ -198,6 +199,10 @@ export const prepareFilterValues = ( filterValues.watchProviders = values.watchProviders; } + if (values.excludeWatchProviders) { + filterValues.excludeWatchProviders = values.excludeWatchProviders; + } + if (values.watchRegion) { filterValues.watchRegion = values.watchRegion; } @@ -251,9 +256,10 @@ export const countActiveFilters = (filterValues: FilterOptions): number => { delete clonedFilters.withRuntimeLte; } - if (clonedFilters.watchProviders) { + if (clonedFilters.watchProviders || clonedFilters.excludeWatchProviders) { totalCount += 1; delete clonedFilters.watchProviders; + delete clonedFilters.excludeWatchProviders; delete clonedFilters.watchRegion; } diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 2a6313bafb..6b713171f8 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -376,10 +376,14 @@ const Discover = () => { url="/api/v1/discover/movies" extraParams={`watchRegion=${ slider.data?.split(',')[0] - }&watchProviders=${slider.data?.split(',')[1]}`} + }&watchProviders=${ + slider.data?.split(',')[1] + }&excludeWatchProviders=${slider.data?.split(',')[2] ?? ''}`} linkUrl={`/discover/movies?watchRegion=${ slider.data?.split(',')[0] - }&watchProviders=${slider.data?.split(',')[1]}`} + }&watchProviders=${ + slider.data?.split(',')[1] + }&excludeWatchProviders=${slider.data?.split(',')[2] ?? ''}`} /> ); break; @@ -391,10 +395,14 @@ const Discover = () => { url="/api/v1/discover/tv" extraParams={`watchRegion=${ slider.data?.split(',')[0] - }&watchProviders=${slider.data?.split(',')[1]}`} + }&watchProviders=${ + slider.data?.split(',')[1] + }&excludeWatchProviders=${slider.data?.split(',')[2] ?? ''}`} linkUrl={`/discover/tv?watchRegion=${ slider.data?.split(',')[0] - }&watchProviders=${slider.data?.split(',')[1]}`} + }&watchProviders=${ + slider.data?.split(',')[1] + }&excludeWatchProviders=${slider.data?.split(',')[2] ?? ''}`} /> ); break; diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index b83009d95b..97f480bf80 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -6,7 +6,7 @@ import { encodeURIExtraParams } from '@app/hooks/useDiscover'; import useSettings from '@app/hooks/useSettings'; import defineMessages from '@app/utils/defineMessages'; import { ArrowDownIcon, ArrowUpIcon } from '@heroicons/react/20/solid'; -import { CheckCircleIcon } from '@heroicons/react/24/solid'; +import { CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/solid'; import type { TmdbCompanySearchResponse, TmdbGenre, @@ -373,7 +373,12 @@ type WatchProviderSelectorProps = { type: 'movie' | 'tv'; region?: string; activeProviders?: number[]; - onChange: (region: string, value: number[]) => void; + excludeProviders?: number[]; + onChange: ( + region: string, + activeValue: number[], + excludeValue: number[] + ) => void; }; export const WatchProviderSelector = ({ @@ -381,6 +386,7 @@ export const WatchProviderSelector = ({ onChange, region, activeProviders, + excludeProviders, }: WatchProviderSelectorProps) => { const intl = useIntl(); const { currentSettings } = useSettings(); @@ -395,6 +401,9 @@ export const WatchProviderSelector = ({ const [activeProvider, setActiveProvider] = useState( activeProviders ?? [] ); + const [excludeProvider, setExcludeProvider] = useState( + excludeProviders ?? [] + ); const { data, isLoading } = useSWR( `/api/v1/watchproviders/${ type === 'movie' ? 'movies' : 'tv' @@ -402,10 +411,10 @@ export const WatchProviderSelector = ({ ); useEffect(() => { - onChange(watchRegion, activeProvider); + onChange(watchRegion, activeProvider, excludeProvider); // removed onChange as a dependency as we only need to call it when the value(s) change // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeProvider, watchRegion]); + }, [activeProvider, excludeProvider, watchRegion]); const orderedData = useMemo(() => { if (!data) { @@ -416,8 +425,11 @@ export const WatchProviderSelector = ({ }, [data]); const toggleProvider = (id: number) => { - if (activeProvider.includes(id)) { + if (excludeProvider.includes(id)) { + setExcludeProvider(excludeProvider.filter((p) => p !== id)); + } else if (activeProvider.includes(id)) { setActiveProvider(activeProvider.filter((p) => p !== id)); + setExcludeProvider([...excludeProvider, id]); } else { setActiveProvider([...activeProvider, id]); } @@ -447,6 +459,7 @@ export const WatchProviderSelector = ({
{initialProviders.map((provider) => { const isActive = activeProvider.includes(provider.id); + const isExclude = excludeProvider.includes(provider.id); return ( toggleProvider(provider.id)} onKeyDown={(e) => { @@ -481,6 +496,11 @@ export const WatchProviderSelector = ({
)} + {isExclude && ( +
+ +
+ )} ); @@ -490,6 +510,7 @@ export const WatchProviderSelector = ({
{otherProviders.map((provider) => { const isActive = activeProvider.includes(provider.id); + const isExclude = excludeProvider.includes(provider.id); return ( toggleProvider(provider.id)} onKeyDown={(e) => { @@ -524,6 +547,11 @@ export const WatchProviderSelector = ({
)} + {isExclude && ( +
+ +
+ )} );