mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat: add streaming services filter (#3247)
* feat: add streaming services filter * fix: count watch region/provider as one filter
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
CompanySelector,
|
||||
GenreSelector,
|
||||
KeywordSelector,
|
||||
WatchProviderSelector,
|
||||
} from '@app/components/Selector';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import {
|
||||
@@ -35,6 +36,7 @@ const messages = defineMessages({
|
||||
clearfilters: 'Clear Active Filters',
|
||||
tmdbuserscore: 'TMDB User Score',
|
||||
runtime: 'Runtime',
|
||||
streamingservices: 'Streaming Services',
|
||||
});
|
||||
|
||||
type FilterSlideoverProps = {
|
||||
@@ -244,6 +246,30 @@ const FilterSlideover = ({
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.streamingservices)}
|
||||
</span>
|
||||
<WatchProviderSelector
|
||||
type={type}
|
||||
region={currentFilters.watchRegion}
|
||||
activeProviders={
|
||||
currentFilters.watchProviders?.split('|').map((v) => Number(v)) ??
|
||||
[]
|
||||
}
|
||||
onChange={(region, providers) => {
|
||||
if (providers.length) {
|
||||
batchUpdateQueryParams({
|
||||
watchRegion: region,
|
||||
watchProviders: providers.join('|'),
|
||||
});
|
||||
} else {
|
||||
batchUpdateQueryParams({
|
||||
watchRegion: undefined,
|
||||
watchProviders: undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
|
@@ -102,6 +102,8 @@ export const QueryFilterOptions = z.object({
|
||||
withRuntimeLte: z.string().optional(),
|
||||
voteAverageGte: z.string().optional(),
|
||||
voteAverageLte: z.string().optional(),
|
||||
watchRegion: z.string().optional(),
|
||||
watchProviders: z.string().optional(),
|
||||
});
|
||||
|
||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||
@@ -165,6 +167,14 @@ export const prepareFilterValues = (
|
||||
filterValues.voteAverageLte = values.voteAverageLte;
|
||||
}
|
||||
|
||||
if (values.watchProviders) {
|
||||
filterValues.watchProviders = values.watchProviders;
|
||||
}
|
||||
|
||||
if (values.watchRegion) {
|
||||
filterValues.watchRegion = values.watchRegion;
|
||||
}
|
||||
|
||||
return filterValues;
|
||||
};
|
||||
|
||||
@@ -184,6 +194,12 @@ export const countActiveFilters = (filterValues: FilterOptions): number => {
|
||||
delete clonedFilters.withRuntimeLte;
|
||||
}
|
||||
|
||||
if (clonedFilters.watchProviders) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.watchProviders;
|
||||
delete clonedFilters.watchRegion;
|
||||
}
|
||||
|
||||
totalCount += Object.keys(clonedFilters).length;
|
||||
|
||||
return totalCount;
|
||||
|
@@ -18,6 +18,8 @@ interface RegionSelectorProps {
|
||||
value: string;
|
||||
name: string;
|
||||
isUserSetting?: boolean;
|
||||
disableAll?: boolean;
|
||||
watchProviders?: boolean;
|
||||
onChange?: (fieldName: string, region: string) => void;
|
||||
}
|
||||
|
||||
@@ -25,11 +27,15 @@ const RegionSelector = ({
|
||||
name,
|
||||
value,
|
||||
isUserSetting = false,
|
||||
disableAll = false,
|
||||
watchProviders = false,
|
||||
onChange,
|
||||
}: RegionSelectorProps) => {
|
||||
const { currentSettings } = useSettings();
|
||||
const intl = useIntl();
|
||||
const { data: regions } = useSWR<Region[]>('/api/v1/regions');
|
||||
const { data: regions } = useSWR<Region[]>(
|
||||
watchProviders ? '/api/v1/watchproviders/regions' : '/api/v1/regions'
|
||||
);
|
||||
const [selectedRegion, setSelectedRegion] = useState<Region | null>(null);
|
||||
|
||||
const allRegion: Region = useMemo(
|
||||
@@ -166,32 +172,34 @@ const RegionSelector = ({
|
||||
)}
|
||||
</Listbox.Option>
|
||||
)}
|
||||
<Listbox.Option value={isUserSetting ? allRegion : null}>
|
||||
{({ selected, active }) => (
|
||||
<div
|
||||
className={`${
|
||||
active ? 'bg-indigo-600 text-white' : 'text-gray-300'
|
||||
} relative cursor-default select-none py-2 pl-8 pr-4`}
|
||||
>
|
||||
<span
|
||||
{!disableAll && (
|
||||
<Listbox.Option value={isUserSetting ? allRegion : null}>
|
||||
{({ selected, active }) => (
|
||||
<div
|
||||
className={`${
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} block truncate pl-8`}
|
||||
active ? 'bg-indigo-600 text-white' : 'text-gray-300'
|
||||
} relative cursor-default select-none py-2 pl-8 pr-4`}
|
||||
>
|
||||
{intl.formatMessage(messages.regionDefault)}
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={`${
|
||||
active ? 'text-white' : 'text-indigo-600'
|
||||
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} block truncate pl-8`}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
{intl.formatMessage(messages.regionDefault)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
{selected && (
|
||||
<span
|
||||
className={`${
|
||||
active ? 'text-white' : 'text-indigo-600'
|
||||
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
)}
|
||||
{sortedRegions?.map((region) => (
|
||||
<Listbox.Option key={region.iso_3166_1} value={region}>
|
||||
{({ selected, active }) => (
|
||||
|
@@ -1,16 +1,29 @@
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import RegionSelector from '@app/components/RegionSelector';
|
||||
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { ArrowDownIcon, ArrowUpIcon } from '@heroicons/react/20/solid';
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/solid';
|
||||
import type {
|
||||
TmdbCompanySearchResponse,
|
||||
TmdbGenre,
|
||||
TmdbKeywordSearchResponse,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type { Keyword, ProductionCompany } from '@server/models/common';
|
||||
import type {
|
||||
Keyword,
|
||||
ProductionCompany,
|
||||
WatchProviderDetails,
|
||||
} from '@server/models/common';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { orderBy } from 'lodash';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import type { MultiValue, SingleValue } from 'react-select';
|
||||
import AsyncSelect from 'react-select/async';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const messages = defineMessages({
|
||||
searchKeywords: 'Search keywords…',
|
||||
@@ -18,6 +31,8 @@ const messages = defineMessages({
|
||||
searchStudios: 'Search studios…',
|
||||
starttyping: 'Starting typing to search.',
|
||||
nooptions: 'No results.',
|
||||
showmore: 'Show More',
|
||||
showless: 'Show Less',
|
||||
});
|
||||
|
||||
type SingleVal = {
|
||||
@@ -259,3 +274,183 @@ export const KeywordSelector = ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type WatchProviderSelectorProps = {
|
||||
type: 'movie' | 'tv';
|
||||
region?: string;
|
||||
activeProviders?: number[];
|
||||
onChange: (region: string, value: number[]) => void;
|
||||
};
|
||||
|
||||
export const WatchProviderSelector = ({
|
||||
type,
|
||||
onChange,
|
||||
region,
|
||||
activeProviders,
|
||||
}: WatchProviderSelectorProps) => {
|
||||
const intl = useIntl();
|
||||
const { currentSettings } = useSettings();
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
const [watchRegion, setWatchRegion] = useState(
|
||||
region ? region : currentSettings.region ? currentSettings.region : 'US'
|
||||
);
|
||||
const [activeProvider, setActiveProvider] = useState<number[]>(
|
||||
activeProviders ?? []
|
||||
);
|
||||
const { data, isLoading } = useSWR<WatchProviderDetails[]>(
|
||||
`/api/v1/watchproviders/${
|
||||
type === 'movie' ? 'movies' : 'tv'
|
||||
}?watchRegion=${watchRegion}`
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onChange(watchRegion, activeProvider);
|
||||
}, [activeProvider, watchRegion, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveProvider([]);
|
||||
}, [watchRegion]);
|
||||
|
||||
const orderedData = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return orderBy(data, ['display_priority'], ['asc']);
|
||||
}, [data]);
|
||||
|
||||
const toggleProvider = (id: number) => {
|
||||
if (activeProvider.includes(id)) {
|
||||
setActiveProvider(activeProvider.filter((p) => p !== id));
|
||||
} else {
|
||||
setActiveProvider([...activeProvider, id]);
|
||||
}
|
||||
};
|
||||
|
||||
const initialProviders = orderedData.slice(0, 24);
|
||||
const otherProviders = orderedData.slice(24);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RegionSelector
|
||||
value={watchRegion}
|
||||
name="watchRegion"
|
||||
onChange={(_name, value) => setWatchRegion(value)}
|
||||
disableAll
|
||||
watchProviders
|
||||
/>
|
||||
{isLoading ? (
|
||||
<SmallLoadingSpinner />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{initialProviders.map((provider) => {
|
||||
const isActive = activeProvider.includes(provider.id);
|
||||
return (
|
||||
<Tooltip
|
||||
content={provider.name}
|
||||
key={`prodiver-${provider.id}`}
|
||||
>
|
||||
<div
|
||||
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 ${
|
||||
isActive
|
||||
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
||||
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
||||
}`}
|
||||
onClick={() => toggleProvider(provider.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
toggleProvider(provider.id);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<CachedImage
|
||||
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
||||
alt=""
|
||||
layout="responsive"
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
{isActive && (
|
||||
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
|
||||
<CheckCircleIcon className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showMore && otherProviders.length > 0 && (
|
||||
<div className="relative -top-2 grid grid-cols-6 gap-2">
|
||||
{otherProviders.map((provider) => {
|
||||
const isActive = activeProvider.includes(provider.id);
|
||||
return (
|
||||
<Tooltip
|
||||
content={provider.name}
|
||||
key={`prodiver-${provider.id}`}
|
||||
>
|
||||
<div
|
||||
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
|
||||
isActive
|
||||
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
||||
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
||||
}`}
|
||||
onClick={() => toggleProvider(provider.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
toggleProvider(provider.id);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<CachedImage
|
||||
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
||||
alt=""
|
||||
layout="responsive"
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
{isActive && (
|
||||
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
|
||||
<CheckCircleIcon className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{otherProviders.length > 0 && (
|
||||
<button
|
||||
className="mt-2 flex items-center justify-center space-x-2 text-sm text-gray-400 transition hover:text-gray-200"
|
||||
onClick={() => setShowMore(!showMore)}
|
||||
>
|
||||
<div className="h-0.5 flex-1 bg-gray-600" />
|
||||
{showMore ? (
|
||||
<>
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
<span>{intl.formatMessage(messages.showless)}</span>
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
<span>{intl.formatMessage(messages.showmore)}</span>
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
<div className="h-0.5 flex-1 bg-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
Reference in New Issue
Block a user