feat: add streaming services filter (#3247)

* feat: add streaming services filter

* fix: count watch region/provider as one filter
pull/3248/head
Ryan Cohen 1 year ago committed by GitHub
parent cb650745f6
commit 1154156459
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -26,8 +26,8 @@ tags:
description: Endpoints related to retrieving movies and their details.
- name: tv
description: Endpoints related to retrieving TV series and their details.
- name: keyword
description: Endpoints related to getting keywords and their details.
- name: other
description: Endpoints related to other TMDB data
- name: person
description: Endpoints related to retrieving person details.
- name: media
@ -1820,6 +1820,15 @@ components:
- enabled
- title
- data
WatchProviderRegion:
type: object
properties:
iso_3166_1:
type: string
english_name:
type: string
native_name:
type: string
securitySchemes:
cookieAuth:
type: apiKey
@ -4177,6 +4186,16 @@ paths:
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
@ -4446,6 +4465,16 @@ paths:
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
@ -6250,7 +6279,7 @@ paths:
description: |
Returns a single keyword in JSON format.
tags:
- keyword
- other
parameters:
- in: path
name: keywordId
@ -6265,6 +6294,68 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Keyword'
/watchproviders/regions:
get:
summary: Get watch provider regions
description: |
Returns a list of all available watch provider regions.
tags:
- other
responses:
'200':
description: Watch provider regions returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WatchProviderRegion'
/watchproviders/movies:
get:
summary: Get watch provider movies
description: |
Returns a list of all available watch providers for movies.
tags:
- other
parameters:
- in: query
name: watchRegion
required: true
schema:
type: string
example: US
responses:
'200':
description: Watch providers for movies returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WatchProviderDetails'
/watchproviders/tv:
get:
summary: Get watch provider series
description: |
Returns a list of all available watch providers for series.
tags:
- other
parameters:
- in: query
name: watchRegion
required: true
schema:
type: string
example: US
responses:
'200':
description: Watch providers for series returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WatchProviderDetails'
security:
- cookieAuth: []
- apiKey: []

@ -22,6 +22,8 @@ import type {
TmdbSeasonWithEpisodes,
TmdbTvDetails,
TmdbUpcomingMoviesResponse,
TmdbWatchProviderDetails,
TmdbWatchProviderRegion,
} from './interfaces';
interface SearchOptions {
@ -68,6 +70,8 @@ interface DiscoverMovieOptions {
studio?: string;
keywords?: string;
sortBy?: SortOptions;
watchRegion?: string;
watchProviders?: string;
}
interface DiscoverTvOptions {
@ -85,6 +89,8 @@ interface DiscoverTvOptions {
network?: number;
keywords?: string;
sortBy?: SortOptions;
watchRegion?: string;
watchProviders?: string;
}
class TheMovieDb extends ExternalAPI {
@ -454,6 +460,8 @@ class TheMovieDb extends ExternalAPI {
withRuntimeLte,
voteAverageGte,
voteAverageLte,
watchProviders,
watchRegion,
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
try {
const defaultFutureDate = new Date(
@ -496,6 +504,8 @@ class TheMovieDb extends ExternalAPI {
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
watch_region: watchRegion,
with_watch_providers: watchProviders,
},
});
@ -520,6 +530,8 @@ class TheMovieDb extends ExternalAPI {
withRuntimeLte,
voteAverageGte,
voteAverageLte,
watchProviders,
watchRegion,
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const defaultFutureDate = new Date(
@ -562,6 +574,8 @@ class TheMovieDb extends ExternalAPI {
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
with_watch_providers: watchProviders,
watch_region: watchRegion,
},
});
@ -1017,6 +1031,84 @@ class TheMovieDb extends ExternalAPI {
throw new Error(`[TMDB] Failed to search companies: ${e.message}`);
}
}
public async getAvailableWatchProviderRegions({
language,
}: {
language?: string;
}) {
try {
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
'/watch/providers/regions',
{
params: {
language: language ?? this.originalLanguage,
},
},
86400 // 24 hours
);
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch available watch regions: ${e.message}`
);
}
}
public async getMovieWatchProviders({
language,
watchRegion,
}: {
language?: string;
watchRegion: string;
}) {
try {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/movie',
{
params: {
language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
},
86400 // 24 hours
);
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch movie watch providers: ${e.message}`
);
}
}
public async getTvWatchProviders({
language,
watchRegion,
}: {
language?: string;
watchRegion: string;
}) {
try {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/tv',
{
params: {
language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
},
86400 // 24 hours
);
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch TV watch providers: ${e.message}`
);
}
}
}
export default TheMovieDb;

@ -446,3 +446,9 @@ export interface TmdbCompany {
export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse {
results: TmdbCompany[];
}
export interface TmdbWatchProviderRegion {
iso_3166_1: string;
english_name: string;
native_name: string;
}

@ -65,6 +65,8 @@ const QueryFilterOptions = z.object({
voteAverageGte: z.coerce.string().optional(),
voteAverageLte: z.coerce.string().optional(),
network: z.coerce.string().optional(),
watchProviders: z.coerce.string().optional(),
watchRegion: z.coerce.string().optional(),
});
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
@ -93,6 +95,8 @@ discoverRoutes.get('/movies', async (req, res, next) => {
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
});
const media = await Media.getRelatedMedia(
@ -366,6 +370,8 @@ discoverRoutes.get('/tv', async (req, res, next) => {
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
});
const media = await Media.getRelatedMedia(

@ -11,6 +11,7 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { checkUser, isAuthenticated } from '@server/middleware/auth';
import { mapWatchProviderDetails } from '@server/models/common';
import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
import settingsRoutes from '@server/routes/settings';
@ -299,6 +300,66 @@ router.get('/keyword/:keywordId', async (req, res, next) => {
}
});
router.get('/watchproviders/regions', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getAvailableWatchProviderRegions({});
return res.status(200).json(result);
} catch (e) {
logger.debug('Something went wrong retrieving watch provider regions', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve watch provider regions.',
});
}
});
router.get('/watchproviders/movies', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getMovieWatchProviders({
watchRegion: req.query.watchRegion as string,
});
return res.status(200).json(mapWatchProviderDetails(result));
} catch (e) {
logger.debug('Something went wrong retrieving movie watch providers', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve movie watch providers.',
});
}
});
router.get('/watchproviders/tv', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getTvWatchProviders({
watchRegion: req.query.watchRegion as string,
});
return res.status(200).json(mapWatchProviderDetails(result));
} catch (e) {
logger.debug('Something went wrong retrieving tv watch providers', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve tv watch providers.',
});
}
});
router.get('/', (_req, res) => {
return res.status(200).json({
api: 'Overseerr API',

@ -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>
)}
</>
)}
</>
);
};

@ -135,11 +135,11 @@ export const useUpdateQueryParams = (
export const useBatchUpdateQueryParams = (
filter: ParsedUrlQuery
): ((items: Record<string, string>) => void) => {
): ((items: Record<string, string | undefined>) => void) => {
const updateQueryParams = useQueryParams();
return useCallback(
(items: Record<string, string>) => {
(items: Record<string, string | undefined>) => {
const query = {
...filter,
...items,

@ -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.streamingservices": "Streaming Services",
"components.Discover.FilterSlideover.studio": "Studio",
"components.Discover.FilterSlideover.tmdbuserscore": "TMDB User Score",
"components.Discover.FilterSlideover.to": "To",
@ -511,6 +512,8 @@
"components.Selector.searchGenres": "Select genres…",
"components.Selector.searchKeywords": "Search keywords…",
"components.Selector.searchStudios": "Search studios…",
"components.Selector.showless": "Show Less",
"components.Selector.showmore": "Show More",
"components.Selector.starttyping": "Starting typing to search.",
"components.Settings.Notifications.NotificationsGotify.agentenabled": "Enable Agent",
"components.Settings.Notifications.NotificationsGotify.gotifysettingsfailed": "Gotify notification settings failed to save.",

Loading…
Cancel
Save