mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-01 04:08:45 -05: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:
@@ -26,8 +26,8 @@ tags:
|
|||||||
description: Endpoints related to retrieving movies and their details.
|
description: Endpoints related to retrieving movies and their details.
|
||||||
- name: tv
|
- name: tv
|
||||||
description: Endpoints related to retrieving TV series and their details.
|
description: Endpoints related to retrieving TV series and their details.
|
||||||
- name: keyword
|
- name: other
|
||||||
description: Endpoints related to getting keywords and their details.
|
description: Endpoints related to other TMDB data
|
||||||
- name: person
|
- name: person
|
||||||
description: Endpoints related to retrieving person details.
|
description: Endpoints related to retrieving person details.
|
||||||
- name: media
|
- name: media
|
||||||
@@ -1820,6 +1820,15 @@ components:
|
|||||||
- enabled
|
- enabled
|
||||||
- title
|
- title
|
||||||
- data
|
- data
|
||||||
|
WatchProviderRegion:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
iso_3166_1:
|
||||||
|
type: string
|
||||||
|
english_name:
|
||||||
|
type: string
|
||||||
|
native_name:
|
||||||
|
type: string
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
cookieAuth:
|
cookieAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
@@ -4177,6 +4186,16 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
example: 10
|
example: 10
|
||||||
|
- in: query
|
||||||
|
name: watchRegion
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: US
|
||||||
|
- in: query
|
||||||
|
name: watchProviders
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 8|9
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Results
|
description: Results
|
||||||
@@ -4446,6 +4465,16 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
example: 10
|
example: 10
|
||||||
|
- in: query
|
||||||
|
name: watchRegion
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: US
|
||||||
|
- in: query
|
||||||
|
name: watchProviders
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: 8|9
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Results
|
description: Results
|
||||||
@@ -6250,7 +6279,7 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
Returns a single keyword in JSON format.
|
Returns a single keyword in JSON format.
|
||||||
tags:
|
tags:
|
||||||
- keyword
|
- other
|
||||||
parameters:
|
parameters:
|
||||||
- in: path
|
- in: path
|
||||||
name: keywordId
|
name: keywordId
|
||||||
@@ -6265,6 +6294,68 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Keyword'
|
$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:
|
security:
|
||||||
- cookieAuth: []
|
- cookieAuth: []
|
||||||
- apiKey: []
|
- apiKey: []
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import type {
|
|||||||
TmdbSeasonWithEpisodes,
|
TmdbSeasonWithEpisodes,
|
||||||
TmdbTvDetails,
|
TmdbTvDetails,
|
||||||
TmdbUpcomingMoviesResponse,
|
TmdbUpcomingMoviesResponse,
|
||||||
|
TmdbWatchProviderDetails,
|
||||||
|
TmdbWatchProviderRegion,
|
||||||
} from './interfaces';
|
} from './interfaces';
|
||||||
|
|
||||||
interface SearchOptions {
|
interface SearchOptions {
|
||||||
@@ -68,6 +70,8 @@ interface DiscoverMovieOptions {
|
|||||||
studio?: string;
|
studio?: string;
|
||||||
keywords?: string;
|
keywords?: string;
|
||||||
sortBy?: SortOptions;
|
sortBy?: SortOptions;
|
||||||
|
watchRegion?: string;
|
||||||
|
watchProviders?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiscoverTvOptions {
|
interface DiscoverTvOptions {
|
||||||
@@ -85,6 +89,8 @@ interface DiscoverTvOptions {
|
|||||||
network?: number;
|
network?: number;
|
||||||
keywords?: string;
|
keywords?: string;
|
||||||
sortBy?: SortOptions;
|
sortBy?: SortOptions;
|
||||||
|
watchRegion?: string;
|
||||||
|
watchProviders?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TheMovieDb extends ExternalAPI {
|
class TheMovieDb extends ExternalAPI {
|
||||||
@@ -454,6 +460,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
withRuntimeLte,
|
withRuntimeLte,
|
||||||
voteAverageGte,
|
voteAverageGte,
|
||||||
voteAverageLte,
|
voteAverageLte,
|
||||||
|
watchProviders,
|
||||||
|
watchRegion,
|
||||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||||
try {
|
try {
|
||||||
const defaultFutureDate = new Date(
|
const defaultFutureDate = new Date(
|
||||||
@@ -496,6 +504,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
'with_runtime.lte': withRuntimeLte,
|
'with_runtime.lte': withRuntimeLte,
|
||||||
'vote_average.gte': voteAverageGte,
|
'vote_average.gte': voteAverageGte,
|
||||||
'vote_average.lte': voteAverageLte,
|
'vote_average.lte': voteAverageLte,
|
||||||
|
watch_region: watchRegion,
|
||||||
|
with_watch_providers: watchProviders,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -520,6 +530,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
withRuntimeLte,
|
withRuntimeLte,
|
||||||
voteAverageGte,
|
voteAverageGte,
|
||||||
voteAverageLte,
|
voteAverageLte,
|
||||||
|
watchProviders,
|
||||||
|
watchRegion,
|
||||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||||
try {
|
try {
|
||||||
const defaultFutureDate = new Date(
|
const defaultFutureDate = new Date(
|
||||||
@@ -562,6 +574,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
'with_runtime.lte': withRuntimeLte,
|
'with_runtime.lte': withRuntimeLte,
|
||||||
'vote_average.gte': voteAverageGte,
|
'vote_average.gte': voteAverageGte,
|
||||||
'vote_average.lte': voteAverageLte,
|
'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}`);
|
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;
|
export default TheMovieDb;
|
||||||
|
|||||||
@@ -446,3 +446,9 @@ export interface TmdbCompany {
|
|||||||
export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse {
|
export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse {
|
||||||
results: TmdbCompany[];
|
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(),
|
voteAverageGte: z.coerce.string().optional(),
|
||||||
voteAverageLte: z.coerce.string().optional(),
|
voteAverageLte: z.coerce.string().optional(),
|
||||||
network: 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>;
|
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||||
@@ -93,6 +95,8 @@ discoverRoutes.get('/movies', async (req, res, next) => {
|
|||||||
withRuntimeLte: query.withRuntimeLte,
|
withRuntimeLte: query.withRuntimeLte,
|
||||||
voteAverageGte: query.voteAverageGte,
|
voteAverageGte: query.voteAverageGte,
|
||||||
voteAverageLte: query.voteAverageLte,
|
voteAverageLte: query.voteAverageLte,
|
||||||
|
watchProviders: query.watchProviders,
|
||||||
|
watchRegion: query.watchRegion,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
@@ -366,6 +370,8 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
|||||||
withRuntimeLte: query.withRuntimeLte,
|
withRuntimeLte: query.withRuntimeLte,
|
||||||
voteAverageGte: query.voteAverageGte,
|
voteAverageGte: query.voteAverageGte,
|
||||||
voteAverageLte: query.voteAverageLte,
|
voteAverageLte: query.voteAverageLte,
|
||||||
|
watchProviders: query.watchProviders,
|
||||||
|
watchRegion: query.watchRegion,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Permission } from '@server/lib/permissions';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { checkUser, isAuthenticated } from '@server/middleware/auth';
|
import { checkUser, isAuthenticated } from '@server/middleware/auth';
|
||||||
|
import { mapWatchProviderDetails } from '@server/models/common';
|
||||||
import { mapProductionCompany } from '@server/models/Movie';
|
import { mapProductionCompany } from '@server/models/Movie';
|
||||||
import { mapNetwork } from '@server/models/Tv';
|
import { mapNetwork } from '@server/models/Tv';
|
||||||
import settingsRoutes from '@server/routes/settings';
|
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) => {
|
router.get('/', (_req, res) => {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
api: 'Overseerr API',
|
api: 'Overseerr API',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
CompanySelector,
|
CompanySelector,
|
||||||
GenreSelector,
|
GenreSelector,
|
||||||
KeywordSelector,
|
KeywordSelector,
|
||||||
|
WatchProviderSelector,
|
||||||
} from '@app/components/Selector';
|
} from '@app/components/Selector';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,7 @@ const messages = defineMessages({
|
|||||||
clearfilters: 'Clear Active Filters',
|
clearfilters: 'Clear Active Filters',
|
||||||
tmdbuserscore: 'TMDB User Score',
|
tmdbuserscore: 'TMDB User Score',
|
||||||
runtime: 'Runtime',
|
runtime: 'Runtime',
|
||||||
|
streamingservices: 'Streaming Services',
|
||||||
});
|
});
|
||||||
|
|
||||||
type FilterSlideoverProps = {
|
type FilterSlideoverProps = {
|
||||||
@@ -244,6 +246,30 @@ const FilterSlideover = ({
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="pt-4">
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export const QueryFilterOptions = z.object({
|
|||||||
withRuntimeLte: z.string().optional(),
|
withRuntimeLte: z.string().optional(),
|
||||||
voteAverageGte: z.string().optional(),
|
voteAverageGte: z.string().optional(),
|
||||||
voteAverageLte: z.string().optional(),
|
voteAverageLte: z.string().optional(),
|
||||||
|
watchRegion: z.string().optional(),
|
||||||
|
watchProviders: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||||
@@ -165,6 +167,14 @@ export const prepareFilterValues = (
|
|||||||
filterValues.voteAverageLte = values.voteAverageLte;
|
filterValues.voteAverageLte = values.voteAverageLte;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (values.watchProviders) {
|
||||||
|
filterValues.watchProviders = values.watchProviders;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.watchRegion) {
|
||||||
|
filterValues.watchRegion = values.watchRegion;
|
||||||
|
}
|
||||||
|
|
||||||
return filterValues;
|
return filterValues;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,6 +194,12 @@ export const countActiveFilters = (filterValues: FilterOptions): number => {
|
|||||||
delete clonedFilters.withRuntimeLte;
|
delete clonedFilters.withRuntimeLte;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (clonedFilters.watchProviders) {
|
||||||
|
totalCount += 1;
|
||||||
|
delete clonedFilters.watchProviders;
|
||||||
|
delete clonedFilters.watchRegion;
|
||||||
|
}
|
||||||
|
|
||||||
totalCount += Object.keys(clonedFilters).length;
|
totalCount += Object.keys(clonedFilters).length;
|
||||||
|
|
||||||
return totalCount;
|
return totalCount;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface RegionSelectorProps {
|
|||||||
value: string;
|
value: string;
|
||||||
name: string;
|
name: string;
|
||||||
isUserSetting?: boolean;
|
isUserSetting?: boolean;
|
||||||
|
disableAll?: boolean;
|
||||||
|
watchProviders?: boolean;
|
||||||
onChange?: (fieldName: string, region: string) => void;
|
onChange?: (fieldName: string, region: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,11 +27,15 @@ const RegionSelector = ({
|
|||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
isUserSetting = false,
|
isUserSetting = false,
|
||||||
|
disableAll = false,
|
||||||
|
watchProviders = false,
|
||||||
onChange,
|
onChange,
|
||||||
}: RegionSelectorProps) => {
|
}: RegionSelectorProps) => {
|
||||||
const { currentSettings } = useSettings();
|
const { currentSettings } = useSettings();
|
||||||
const intl = useIntl();
|
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 [selectedRegion, setSelectedRegion] = useState<Region | null>(null);
|
||||||
|
|
||||||
const allRegion: Region = useMemo(
|
const allRegion: Region = useMemo(
|
||||||
@@ -166,32 +172,34 @@ const RegionSelector = ({
|
|||||||
)}
|
)}
|
||||||
</Listbox.Option>
|
</Listbox.Option>
|
||||||
)}
|
)}
|
||||||
<Listbox.Option value={isUserSetting ? allRegion : null}>
|
{!disableAll && (
|
||||||
{({ selected, active }) => (
|
<Listbox.Option value={isUserSetting ? allRegion : null}>
|
||||||
<div
|
{({ selected, active }) => (
|
||||||
className={`${
|
<div
|
||||||
active ? 'bg-indigo-600 text-white' : 'text-gray-300'
|
|
||||||
} relative cursor-default select-none py-2 pl-8 pr-4`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`${
|
className={`${
|
||||||
selected ? 'font-semibold' : 'font-normal'
|
active ? 'bg-indigo-600 text-white' : 'text-gray-300'
|
||||||
} block truncate pl-8`}
|
} relative cursor-default select-none py-2 pl-8 pr-4`}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.regionDefault)}
|
|
||||||
</span>
|
|
||||||
{selected && (
|
|
||||||
<span
|
<span
|
||||||
className={`${
|
className={`${
|
||||||
active ? 'text-white' : 'text-indigo-600'
|
selected ? 'font-semibold' : 'font-normal'
|
||||||
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
|
} block truncate pl-8`}
|
||||||
>
|
>
|
||||||
<CheckIcon className="h-5 w-5" />
|
{intl.formatMessage(messages.regionDefault)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
{selected && (
|
||||||
</div>
|
<span
|
||||||
)}
|
className={`${
|
||||||
</Listbox.Option>
|
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) => (
|
{sortedRegions?.map((region) => (
|
||||||
<Listbox.Option key={region.iso_3166_1} value={region}>
|
<Listbox.Option key={region.iso_3166_1} value={region}>
|
||||||
{({ selected, active }) => (
|
{({ 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 { 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 {
|
import type {
|
||||||
TmdbCompanySearchResponse,
|
TmdbCompanySearchResponse,
|
||||||
TmdbGenre,
|
TmdbGenre,
|
||||||
TmdbKeywordSearchResponse,
|
TmdbKeywordSearchResponse,
|
||||||
} from '@server/api/themoviedb/interfaces';
|
} from '@server/api/themoviedb/interfaces';
|
||||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
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 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 { defineMessages, useIntl } from 'react-intl';
|
||||||
import type { MultiValue, SingleValue } from 'react-select';
|
import type { MultiValue, SingleValue } from 'react-select';
|
||||||
import AsyncSelect from 'react-select/async';
|
import AsyncSelect from 'react-select/async';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
searchKeywords: 'Search keywords…',
|
searchKeywords: 'Search keywords…',
|
||||||
@@ -18,6 +31,8 @@ const messages = defineMessages({
|
|||||||
searchStudios: 'Search studios…',
|
searchStudios: 'Search studios…',
|
||||||
starttyping: 'Starting typing to search.',
|
starttyping: 'Starting typing to search.',
|
||||||
nooptions: 'No results.',
|
nooptions: 'No results.',
|
||||||
|
showmore: 'Show More',
|
||||||
|
showless: 'Show Less',
|
||||||
});
|
});
|
||||||
|
|
||||||
type SingleVal = {
|
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 = (
|
export const useBatchUpdateQueryParams = (
|
||||||
filter: ParsedUrlQuery
|
filter: ParsedUrlQuery
|
||||||
): ((items: Record<string, string>) => void) => {
|
): ((items: Record<string, string | undefined>) => void) => {
|
||||||
const updateQueryParams = useQueryParams();
|
const updateQueryParams = useQueryParams();
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(items: Record<string, string>) => {
|
(items: Record<string, string | undefined>) => {
|
||||||
const query = {
|
const query = {
|
||||||
...filter,
|
...filter,
|
||||||
...items,
|
...items,
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"components.Discover.FilterSlideover.releaseDate": "Release Date",
|
"components.Discover.FilterSlideover.releaseDate": "Release Date",
|
||||||
"components.Discover.FilterSlideover.runtime": "Runtime",
|
"components.Discover.FilterSlideover.runtime": "Runtime",
|
||||||
"components.Discover.FilterSlideover.runtimeText": "{minValue}-{maxValue} minute runtime",
|
"components.Discover.FilterSlideover.runtimeText": "{minValue}-{maxValue} minute runtime",
|
||||||
|
"components.Discover.FilterSlideover.streamingservices": "Streaming Services",
|
||||||
"components.Discover.FilterSlideover.studio": "Studio",
|
"components.Discover.FilterSlideover.studio": "Studio",
|
||||||
"components.Discover.FilterSlideover.tmdbuserscore": "TMDB User Score",
|
"components.Discover.FilterSlideover.tmdbuserscore": "TMDB User Score",
|
||||||
"components.Discover.FilterSlideover.to": "To",
|
"components.Discover.FilterSlideover.to": "To",
|
||||||
@@ -511,6 +512,8 @@
|
|||||||
"components.Selector.searchGenres": "Select genres…",
|
"components.Selector.searchGenres": "Select genres…",
|
||||||
"components.Selector.searchKeywords": "Search keywords…",
|
"components.Selector.searchKeywords": "Search keywords…",
|
||||||
"components.Selector.searchStudios": "Search studios…",
|
"components.Selector.searchStudios": "Search studios…",
|
||||||
|
"components.Selector.showless": "Show Less",
|
||||||
|
"components.Selector.showmore": "Show More",
|
||||||
"components.Selector.starttyping": "Starting typing to search.",
|
"components.Selector.starttyping": "Starting typing to search.",
|
||||||
"components.Settings.Notifications.NotificationsGotify.agentenabled": "Enable Agent",
|
"components.Settings.Notifications.NotificationsGotify.agentenabled": "Enable Agent",
|
||||||
"components.Settings.Notifications.NotificationsGotify.gotifysettingsfailed": "Gotify notification settings failed to save.",
|
"components.Settings.Notifications.NotificationsGotify.gotifysettingsfailed": "Gotify notification settings failed to save.",
|
||||||
|
|||||||
Reference in New Issue
Block a user