From 1154156459403494e8daf0c89a3ba356aeea1d97 Mon Sep 17 00:00:00 2001 From: Ryan Cohen Date: Mon, 16 Jan 2023 17:05:21 +0900 Subject: [PATCH] feat: add streaming services filter (#3247) * feat: add streaming services filter * fix: count watch region/provider as one filter --- overseerr-api.yml | 97 ++++++++- server/api/themoviedb/index.ts | 92 ++++++++ server/api/themoviedb/interfaces.ts | 6 + server/routes/discover.ts | 6 + server/routes/index.ts | 61 ++++++ .../Discover/FilterSlideover/index.tsx | 26 +++ src/components/Discover/constants.ts | 16 ++ src/components/RegionSelector/index.tsx | 50 +++-- src/components/Selector/index.tsx | 199 +++++++++++++++++- src/hooks/useUpdateQueryParams.ts | 4 +- src/i18n/locale/en.json | 3 + 11 files changed, 532 insertions(+), 28 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 58b9b41f..ef31c749 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -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: [] diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index c6d0d29f..7aad32c1 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -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 => { 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 => { 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; diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index 9a07d769..955e1b12 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -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; +} diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 079f37e1..ce9e3aff 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -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; @@ -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( diff --git a/server/routes/index.ts b/server/routes/index.ts index 8318bbfc..f76f09fa 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -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', diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx index 52d550fd..10ee0fea 100644 --- a/src/components/Discover/FilterSlideover/index.tsx +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -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 = ({ })} /> + + {intl.formatMessage(messages.streamingservices)} + + Number(v)) ?? + [] + } + onChange={(region, providers) => { + if (providers.length) { + batchUpdateQueryParams({ + watchRegion: region, + watchProviders: providers.join('|'), + }); + } else { + batchUpdateQueryParams({ + watchRegion: undefined, + watchProviders: undefined, + }); + } + }} + />
+ )} + + )} + + ); +}; diff --git a/src/hooks/useUpdateQueryParams.ts b/src/hooks/useUpdateQueryParams.ts index aadfaac2..7b991267 100644 --- a/src/hooks/useUpdateQueryParams.ts +++ b/src/hooks/useUpdateQueryParams.ts @@ -135,11 +135,11 @@ export const useUpdateQueryParams = ( export const useBatchUpdateQueryParams = ( filter: ParsedUrlQuery -): ((items: Record) => void) => { +): ((items: Record) => void) => { const updateQueryParams = useQueryParams(); return useCallback( - (items: Record) => { + (items: Record) => { const query = { ...filter, ...items, diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 44736d2d..72d58ca5 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.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.",