mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
feat: add content certification/age-rating filter (#1418)
* feat(api): add TMDB certifications endpoint and discover certification params re #501 * feat(discover): add certification/age-rating filter to movies and series Add generic and US-only certification selector components, update Discover FilterSlideover, add certification options to query constants re #501 * fix(certificationselector): fix linter warning from useEffect missing dependency * fix(jellyseerr-api.yml): prettier formatting * chore(translation keys): run pnpm i18n:extract * fix(certificationselector): change query destructure to Zod omit, fix translations, fix formatting * style: fix whitespace with prettier
This commit is contained in:
@@ -1976,6 +1976,41 @@ components:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
Certification:
|
||||
type: object
|
||||
properties:
|
||||
certification:
|
||||
type: string
|
||||
example: 'PG-13'
|
||||
meaning:
|
||||
type: string
|
||||
example: 'Some material may be inappropriate for children under 13.'
|
||||
nullable: true
|
||||
order:
|
||||
type: number
|
||||
example: 3
|
||||
nullable: true
|
||||
required:
|
||||
- certification
|
||||
|
||||
CertificationResponse:
|
||||
type: object
|
||||
properties:
|
||||
certifications:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Certification'
|
||||
example:
|
||||
certifications:
|
||||
US:
|
||||
- certification: 'G'
|
||||
meaning: 'All ages admitted'
|
||||
order: 1
|
||||
- certification: 'PG'
|
||||
meaning: 'Some material may not be suitable for children under 10.'
|
||||
order: 2
|
||||
securitySchemes:
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
@@ -5026,6 +5061,37 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
example: 8|9
|
||||
- in: query
|
||||
name: certification
|
||||
schema:
|
||||
type: string
|
||||
example: PG-13
|
||||
description: Exact certification to filter by (used when certificationMode is 'exact')
|
||||
- in: query
|
||||
name: certificationGte
|
||||
schema:
|
||||
type: string
|
||||
example: G
|
||||
description: Minimum certification to filter by (used when certificationMode is 'range')
|
||||
- in: query
|
||||
name: certificationLte
|
||||
schema:
|
||||
type: string
|
||||
example: PG-13
|
||||
description: Maximum certification to filter by (used when certificationMode is 'range')
|
||||
- in: query
|
||||
name: certificationCountry
|
||||
schema:
|
||||
type: string
|
||||
example: US
|
||||
description: Country code for the certification system (e.g., US, GB, CA)
|
||||
- in: query
|
||||
name: certificationMode
|
||||
schema:
|
||||
type: string
|
||||
enum: [exact, range]
|
||||
example: exact
|
||||
description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API)
|
||||
responses:
|
||||
'200':
|
||||
description: Results
|
||||
@@ -5320,6 +5386,37 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
example: 3|4
|
||||
- in: query
|
||||
name: certification
|
||||
schema:
|
||||
type: string
|
||||
example: TV-14
|
||||
description: Exact certification to filter by (used when certificationMode is 'exact')
|
||||
- in: query
|
||||
name: certificationGte
|
||||
schema:
|
||||
type: string
|
||||
example: TV-PG
|
||||
description: Minimum certification to filter by (used when certificationMode is 'range')
|
||||
- in: query
|
||||
name: certificationLte
|
||||
schema:
|
||||
type: string
|
||||
example: TV-MA
|
||||
description: Maximum certification to filter by (used when certificationMode is 'range')
|
||||
- in: query
|
||||
name: certificationCountry
|
||||
schema:
|
||||
type: string
|
||||
example: US
|
||||
description: Country code for the certification system (e.g., US, GB, CA)
|
||||
- in: query
|
||||
name: certificationMode
|
||||
schema:
|
||||
type: string
|
||||
enum: [exact, range]
|
||||
example: exact
|
||||
description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API)
|
||||
responses:
|
||||
'200':
|
||||
description: Results
|
||||
@@ -7293,6 +7390,64 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/WatchProviderDetails'
|
||||
/certifications/movie:
|
||||
get:
|
||||
summary: Get movie certifications
|
||||
description: Returns list of movie certifications from TMDB.
|
||||
tags:
|
||||
- other
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- apiKey: []
|
||||
responses:
|
||||
'200':
|
||||
description: Movie certifications returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CertificationResponse'
|
||||
'500':
|
||||
description: Unable to retrieve movie certifications
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: number
|
||||
example: 500
|
||||
message:
|
||||
type: string
|
||||
example: Unable to retrieve movie certifications.
|
||||
/certifications/tv:
|
||||
get:
|
||||
summary: Get TV certifications
|
||||
description: Returns list of TV show certifications from TMDB.
|
||||
tags:
|
||||
- other
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- apiKey: []
|
||||
responses:
|
||||
'200':
|
||||
description: TV certifications returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CertificationResponse'
|
||||
'500':
|
||||
description: Unable to retrieve TV certifications
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: number
|
||||
example: 500
|
||||
message:
|
||||
type: string
|
||||
example: Unable to retrieve TV certifications.
|
||||
/overrideRule:
|
||||
get:
|
||||
summary: Get override rules
|
||||
|
||||
@@ -59,6 +59,16 @@ export const SortOptionsIterable = [
|
||||
|
||||
export type SortOptions = (typeof SortOptionsIterable)[number];
|
||||
|
||||
export interface TmdbCertificationResponse {
|
||||
certifications: {
|
||||
[country: string]: {
|
||||
certification: string;
|
||||
meaning?: string;
|
||||
order?: number;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
interface DiscoverMovieOptions {
|
||||
page?: number;
|
||||
includeAdult?: boolean;
|
||||
@@ -78,6 +88,10 @@ interface DiscoverMovieOptions {
|
||||
sortBy?: SortOptions;
|
||||
watchRegion?: string;
|
||||
watchProviders?: string;
|
||||
certification?: string;
|
||||
certificationGte?: string;
|
||||
certificationLte?: string;
|
||||
certificationCountry?: string;
|
||||
}
|
||||
|
||||
interface DiscoverTvOptions {
|
||||
@@ -100,6 +114,10 @@ interface DiscoverTvOptions {
|
||||
watchRegion?: string;
|
||||
watchProviders?: string;
|
||||
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
|
||||
certification?: string;
|
||||
certificationGte?: string;
|
||||
certificationLte?: string;
|
||||
certificationCountry?: string;
|
||||
}
|
||||
|
||||
class TheMovieDb extends ExternalAPI {
|
||||
@@ -477,6 +495,10 @@ class TheMovieDb extends ExternalAPI {
|
||||
voteCountLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
certification,
|
||||
certificationGte,
|
||||
certificationLte,
|
||||
certificationCountry,
|
||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||
try {
|
||||
const defaultFutureDate = new Date(
|
||||
@@ -523,6 +545,10 @@ class TheMovieDb extends ExternalAPI {
|
||||
'vote_count.lte': voteCountLte,
|
||||
watch_region: watchRegion,
|
||||
with_watch_providers: watchProviders,
|
||||
certification: certification,
|
||||
'certification.gte': certificationGte,
|
||||
'certification.lte': certificationLte,
|
||||
certification_country: certificationCountry,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -552,6 +578,10 @@ class TheMovieDb extends ExternalAPI {
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
withStatus,
|
||||
certification,
|
||||
certificationGte,
|
||||
certificationLte,
|
||||
certificationCountry,
|
||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||
try {
|
||||
const defaultFutureDate = new Date(
|
||||
@@ -599,6 +629,10 @@ class TheMovieDb extends ExternalAPI {
|
||||
with_watch_providers: watchProviders,
|
||||
watch_region: watchRegion,
|
||||
with_status: withStatus,
|
||||
certification: certification,
|
||||
'certification.gte': certificationGte,
|
||||
'certification.lte': certificationLte,
|
||||
certification_country: certificationCountry,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -987,6 +1021,35 @@ class TheMovieDb extends ExternalAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public getMovieCertifications =
|
||||
async (): Promise<TmdbCertificationResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbCertificationResponse>(
|
||||
'/certification/movie/list',
|
||||
{},
|
||||
604800 // 7 days
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch movie certifications: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
public getTvCertifications = async (): Promise<TmdbCertificationResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbCertificationResponse>(
|
||||
'/certification/tv/list',
|
||||
{},
|
||||
604800 // 7 days
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch TV certifications: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public async getKeywordDetails({
|
||||
keywordId,
|
||||
}: {
|
||||
|
||||
@@ -72,16 +72,25 @@ const QueryFilterOptions = z.object({
|
||||
watchProviders: z.coerce.string().optional(),
|
||||
watchRegion: z.coerce.string().optional(),
|
||||
status: z.coerce.string().optional(),
|
||||
certification: z.coerce.string().optional(),
|
||||
certificationGte: z.coerce.string().optional(),
|
||||
certificationLte: z.coerce.string().optional(),
|
||||
certificationCountry: z.coerce.string().optional(),
|
||||
certificationMode: z.enum(['exact', 'range']).optional(),
|
||||
});
|
||||
|
||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||
const ApiQuerySchema = QueryFilterOptions.omit({
|
||||
certificationMode: true,
|
||||
});
|
||||
|
||||
discoverRoutes.get('/movies', async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
try {
|
||||
const query = QueryFilterOptions.parse(req.query);
|
||||
const query = ApiQuerySchema.parse(req.query);
|
||||
const keywords = query.keywords;
|
||||
|
||||
const data = await tmdb.getDiscoverMovies({
|
||||
page: Number(query.page),
|
||||
sortBy: query.sortBy as SortOptions,
|
||||
@@ -104,6 +113,10 @@ discoverRoutes.get('/movies', async (req, res, next) => {
|
||||
voteCountLte: query.voteCountLte,
|
||||
watchProviders: query.watchProviders,
|
||||
watchRegion: query.watchRegion,
|
||||
certification: query.certification,
|
||||
certificationGte: query.certificationGte,
|
||||
certificationLte: query.certificationLte,
|
||||
certificationCountry: query.certificationCountry,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
@@ -362,7 +375,7 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
try {
|
||||
const query = QueryFilterOptions.parse(req.query);
|
||||
const query = ApiQuerySchema.parse(req.query);
|
||||
const keywords = query.keywords;
|
||||
const data = await tmdb.getDiscoverTv({
|
||||
page: Number(query.page),
|
||||
@@ -387,6 +400,10 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
||||
watchProviders: query.watchProviders,
|
||||
watchRegion: query.watchRegion,
|
||||
withStatus: query.status,
|
||||
certification: query.certification,
|
||||
certificationGte: query.certificationGte,
|
||||
certificationLte: query.certificationLte,
|
||||
certificationCountry: query.certificationCountry,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
|
||||
@@ -401,6 +401,48 @@ router.get('/watchproviders/tv', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/certifications/movie',
|
||||
isAuthenticated(),
|
||||
async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const certifications = await tmdb.getMovieCertifications();
|
||||
|
||||
return res.status(200).json(certifications);
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong retrieving movie certifications', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve movie certifications.',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get('/certifications/tv', isAuthenticated(), async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const certifications = await tmdb.getTvCertifications();
|
||||
|
||||
return res.status(200).json(certifications);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving TV certifications', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve TV certifications.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
return res.status(200).json({
|
||||
api: 'Jellyseerr API',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GenreSelector,
|
||||
KeywordSelector,
|
||||
StatusSelector,
|
||||
USCertificationSelector,
|
||||
WatchProviderSelector,
|
||||
} from '@app/components/Selector';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
@@ -42,6 +43,7 @@ const messages = defineMessages('components.Discover.FilterSlideover', {
|
||||
streamingservices: 'Streaming Services',
|
||||
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
||||
status: 'Status',
|
||||
certification: 'Content Rating',
|
||||
});
|
||||
|
||||
type FilterSlideoverProps = {
|
||||
@@ -190,6 +192,16 @@ const FilterSlideover = ({
|
||||
updateQueryParams('language', value);
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.certification)}
|
||||
</span>
|
||||
<USCertificationSelector
|
||||
type={type}
|
||||
certification={currentFilters.certification}
|
||||
onChange={(params) => {
|
||||
batchUpdateQueryParams(params);
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.runtime)}
|
||||
</span>
|
||||
|
||||
@@ -109,6 +109,11 @@ export const QueryFilterOptions = z.object({
|
||||
watchRegion: z.string().optional(),
|
||||
watchProviders: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
certification: z.string().optional(),
|
||||
certificationGte: z.string().optional(),
|
||||
certificationLte: z.string().optional(),
|
||||
certificationCountry: z.string().optional(),
|
||||
certificationMode: z.enum(['exact', 'range']).optional(),
|
||||
});
|
||||
|
||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||
@@ -192,6 +197,30 @@ export const prepareFilterValues = (
|
||||
filterValues.watchRegion = values.watchRegion;
|
||||
}
|
||||
|
||||
if (values.certification) {
|
||||
filterValues.certification = values.certification;
|
||||
}
|
||||
|
||||
if (values.certificationGte) {
|
||||
filterValues.certificationGte = values.certificationGte;
|
||||
}
|
||||
|
||||
if (values.certificationLte) {
|
||||
filterValues.certificationLte = values.certificationLte;
|
||||
}
|
||||
|
||||
if (values.certificationCountry) {
|
||||
filterValues.certificationCountry = values.certificationCountry;
|
||||
}
|
||||
|
||||
if (values.certificationMode) {
|
||||
filterValues.certificationMode = values.certificationMode;
|
||||
} else if (values.certification) {
|
||||
filterValues.certificationMode = 'exact';
|
||||
} else if (values.certificationGte || values.certificationLte) {
|
||||
filterValues.certificationMode = 'range';
|
||||
}
|
||||
|
||||
return filterValues;
|
||||
};
|
||||
|
||||
@@ -223,6 +252,20 @@ export const countActiveFilters = (filterValues: FilterOptions): number => {
|
||||
delete clonedFilters.watchRegion;
|
||||
}
|
||||
|
||||
if (
|
||||
clonedFilters.certification ||
|
||||
clonedFilters.certificationGte ||
|
||||
clonedFilters.certificationLte ||
|
||||
clonedFilters.certificationCountry
|
||||
) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.certification;
|
||||
delete clonedFilters.certificationGte;
|
||||
delete clonedFilters.certificationLte;
|
||||
delete clonedFilters.certificationCountry;
|
||||
}
|
||||
|
||||
delete clonedFilters.certificationMode;
|
||||
totalCount += Object.keys(clonedFilters).length;
|
||||
|
||||
return totalCount;
|
||||
|
||||
333
src/components/Selector/CertificationSelector.tsx
Normal file
333
src/components/Selector/CertificationSelector.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import type { Region } from '@server/lib/settings';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import AsyncSelect from 'react-select/async';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface Certification {
|
||||
certification: string;
|
||||
meaning?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
interface CertificationResponse {
|
||||
certifications: {
|
||||
[country: string]: Certification[];
|
||||
};
|
||||
}
|
||||
|
||||
interface CertificationOption {
|
||||
value: string;
|
||||
label: string;
|
||||
certification?: string;
|
||||
}
|
||||
|
||||
interface CertificationSelectorProps {
|
||||
type: string;
|
||||
certificationCountry?: string;
|
||||
certification?: string;
|
||||
certificationGte?: string;
|
||||
certificationLte?: string;
|
||||
onChange: (params: {
|
||||
certificationCountry?: string;
|
||||
certification?: string;
|
||||
certificationGte?: string;
|
||||
certificationLte?: string;
|
||||
}) => void;
|
||||
showRange?: boolean;
|
||||
}
|
||||
|
||||
const messages = defineMessages('components.Selector.CertificationSelector', {
|
||||
selectCountry: 'Select a country',
|
||||
selectCertification: 'Select a certification',
|
||||
minRating: 'Minimum rating',
|
||||
maxRating: 'Maximum rating',
|
||||
noOptions: 'No options available',
|
||||
starttyping: 'Starting typing to search.',
|
||||
errorLoading: 'Failed to load certifications',
|
||||
});
|
||||
|
||||
const CertificationSelector: React.FC<CertificationSelectorProps> = ({
|
||||
type,
|
||||
certificationCountry,
|
||||
certification,
|
||||
certificationGte,
|
||||
certificationLte,
|
||||
showRange = false,
|
||||
onChange,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [selectedCountry, setSelectedCountry] =
|
||||
useState<CertificationOption | null>(
|
||||
certificationCountry
|
||||
? { value: certificationCountry, label: certificationCountry }
|
||||
: null
|
||||
);
|
||||
const [selectedCertification, setSelectedCertification] =
|
||||
useState<CertificationOption | null>(null);
|
||||
const [selectedCertificationGte, setSelectedCertificationGte] =
|
||||
useState<CertificationOption | null>(null);
|
||||
const [selectedCertificationLte, setSelectedCertificationLte] =
|
||||
useState<CertificationOption | null>(null);
|
||||
|
||||
const {
|
||||
data: certificationData,
|
||||
error: certificationError,
|
||||
isLoading: certificationLoading,
|
||||
} = useSWR<CertificationResponse>(`/api/v1/certifications/${type}`);
|
||||
|
||||
const { data: regionsData } = useSWR<Region[]>('/api/v1/regions');
|
||||
|
||||
// Get the country name from its code
|
||||
const getCountryName = useCallback(
|
||||
(countryCode: string): string => {
|
||||
const region = regionsData?.find(
|
||||
(region) => region.iso_3166_1 === countryCode
|
||||
);
|
||||
return region?.name || countryCode;
|
||||
},
|
||||
[regionsData]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (certificationCountry && regionsData) {
|
||||
setSelectedCountry({
|
||||
value: certificationCountry,
|
||||
label: getCountryName(certificationCountry),
|
||||
});
|
||||
}
|
||||
}, [certificationCountry, regionsData, getCountryName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!certificationData || !certificationCountry) return;
|
||||
|
||||
const certifications = (
|
||||
certificationData.certifications[certificationCountry] || []
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.order !== undefined && b.order !== undefined) {
|
||||
return a.order - b.order;
|
||||
}
|
||||
return a.certification.localeCompare(b.certification);
|
||||
})
|
||||
.map((cert) => ({
|
||||
value: cert.certification,
|
||||
label: `${cert.certification}${
|
||||
cert.meaning ? ` - ${cert.meaning}` : ''
|
||||
}`,
|
||||
certification: cert.certification,
|
||||
}));
|
||||
|
||||
if (certification) {
|
||||
setSelectedCertification(
|
||||
certifications.find((c) => c.value === certification) || null
|
||||
);
|
||||
}
|
||||
|
||||
if (certificationGte) {
|
||||
setSelectedCertificationGte(
|
||||
certifications.find((c) => c.value === certificationGte) || null
|
||||
);
|
||||
}
|
||||
|
||||
if (certificationLte) {
|
||||
setSelectedCertificationLte(
|
||||
certifications.find((c) => c.value === certificationLte) || null
|
||||
);
|
||||
}
|
||||
}, [
|
||||
certificationData,
|
||||
certificationCountry,
|
||||
certification,
|
||||
certificationGte,
|
||||
certificationLte,
|
||||
]);
|
||||
|
||||
if (certificationError) {
|
||||
return (
|
||||
<div className="text-red-500">
|
||||
{intl.formatMessage(messages.errorLoading)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (certificationLoading || !certificationData) {
|
||||
return <SmallLoadingSpinner />;
|
||||
}
|
||||
|
||||
const loadCountryOptions = async (inputValue: string) => {
|
||||
if (!certificationData || !regionsData) return [];
|
||||
|
||||
return Object.keys(certificationData.certifications)
|
||||
.filter(
|
||||
(code) =>
|
||||
certificationData.certifications[code] &&
|
||||
certificationData.certifications[code].length > 0 &&
|
||||
(code.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||||
getCountryName(code)
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase()))
|
||||
)
|
||||
.sort((a, b) => getCountryName(a).localeCompare(getCountryName(b)))
|
||||
.map((code) => ({
|
||||
value: code,
|
||||
label: getCountryName(code),
|
||||
}));
|
||||
};
|
||||
|
||||
const loadCertificationOptions = async (inputValue: string) => {
|
||||
if (!certificationData || !certificationCountry) return [];
|
||||
|
||||
return (certificationData.certifications[certificationCountry] || [])
|
||||
.sort((a, b) => {
|
||||
if (a.order !== undefined && b.order !== undefined) {
|
||||
return a.order - b.order;
|
||||
}
|
||||
return a.certification.localeCompare(b.certification);
|
||||
})
|
||||
.map((cert) => ({
|
||||
value: cert.certification,
|
||||
label: `${cert.certification}${
|
||||
cert.meaning ? ` - ${cert.meaning}` : ''
|
||||
}`,
|
||||
certification: cert.certification,
|
||||
}))
|
||||
.filter((cert) =>
|
||||
cert.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
const handleCountryChange = (option: CertificationOption | null) => {
|
||||
setSelectedCountry(option);
|
||||
setSelectedCertification(null);
|
||||
setSelectedCertificationGte(null);
|
||||
setSelectedCertificationLte(null);
|
||||
|
||||
onChange({
|
||||
certificationCountry: option?.value,
|
||||
certification: undefined,
|
||||
certificationGte: undefined,
|
||||
certificationLte: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCertificationChange = (option: CertificationOption | null) => {
|
||||
setSelectedCertification(option);
|
||||
|
||||
onChange({
|
||||
certificationCountry,
|
||||
certification: option?.value,
|
||||
certificationGte: undefined,
|
||||
certificationLte: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMinCertificationChange = (option: CertificationOption | null) => {
|
||||
setSelectedCertificationGte(option);
|
||||
|
||||
onChange({
|
||||
certificationCountry,
|
||||
certification: undefined,
|
||||
certificationGte: option?.value,
|
||||
certificationLte: certificationLte,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMaxCertificationChange = (option: CertificationOption | null) => {
|
||||
setSelectedCertificationLte(option);
|
||||
|
||||
onChange({
|
||||
certificationCountry,
|
||||
certification: undefined,
|
||||
certificationGte: certificationGte,
|
||||
certificationLte: option?.value,
|
||||
});
|
||||
};
|
||||
|
||||
const formatCertificationLabel = (
|
||||
option: CertificationOption,
|
||||
{ context }: { context: string }
|
||||
) => {
|
||||
if (context === 'value') {
|
||||
return option.certification || option.value;
|
||||
}
|
||||
// Show the full label with description in the menu
|
||||
return option.label;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<AsyncSelect
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
cacheOptions
|
||||
defaultOptions
|
||||
loadOptions={loadCountryOptions}
|
||||
value={selectedCountry}
|
||||
onChange={handleCountryChange}
|
||||
placeholder={intl.formatMessage(messages.selectCountry)}
|
||||
isClearable
|
||||
noOptionsMessage={({ inputValue }) =>
|
||||
inputValue === ''
|
||||
? intl.formatMessage(messages.starttyping)
|
||||
: intl.formatMessage(messages.noOptions)
|
||||
}
|
||||
/>
|
||||
|
||||
{certificationCountry && !showRange && (
|
||||
<AsyncSelect
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
cacheOptions
|
||||
defaultOptions
|
||||
loadOptions={loadCertificationOptions}
|
||||
value={selectedCertification}
|
||||
onChange={handleCertificationChange}
|
||||
placeholder={intl.formatMessage(messages.selectCertification)}
|
||||
formatOptionLabel={formatCertificationLabel}
|
||||
isClearable
|
||||
noOptionsMessage={() => intl.formatMessage(messages.noOptions)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{certificationCountry && showRange && (
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex-1">
|
||||
<AsyncSelect
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
cacheOptions
|
||||
defaultOptions
|
||||
loadOptions={loadCertificationOptions}
|
||||
value={selectedCertificationGte}
|
||||
onChange={handleMinCertificationChange}
|
||||
placeholder={intl.formatMessage(messages.minRating)}
|
||||
formatOptionLabel={formatCertificationLabel}
|
||||
isClearable
|
||||
noOptionsMessage={() => intl.formatMessage(messages.noOptions)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<AsyncSelect
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
cacheOptions
|
||||
defaultOptions
|
||||
loadOptions={loadCertificationOptions}
|
||||
value={selectedCertificationLte}
|
||||
onChange={handleMaxCertificationChange}
|
||||
placeholder={intl.formatMessage(messages.maxRating)}
|
||||
formatOptionLabel={formatCertificationLabel}
|
||||
isClearable
|
||||
noOptionsMessage={() => intl.formatMessage(messages.noOptions)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificationSelector;
|
||||
87
src/components/Selector/USCertificationSelector.tsx
Normal file
87
src/components/Selector/USCertificationSelector.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
interface USCertificationSelectorProps {
|
||||
type: string;
|
||||
certification?: string;
|
||||
onChange: (params: {
|
||||
certificationCountry?: string;
|
||||
certification?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const US_MOVIE_CERTIFICATIONS = ['NR', 'G', 'PG', 'PG-13', 'R', 'NC-17'];
|
||||
const US_TV_CERTIFICATIONS = [
|
||||
'NR',
|
||||
'TV-Y',
|
||||
'TV-Y7',
|
||||
'TV-G',
|
||||
'TV-PG',
|
||||
'TV-14',
|
||||
'TV-MA',
|
||||
];
|
||||
|
||||
const USCertificationSelector: React.FC<USCertificationSelectorProps> = ({
|
||||
type,
|
||||
certification,
|
||||
onChange,
|
||||
}) => {
|
||||
const [selectedRatings, setSelectedRatings] = useState<string[]>(() =>
|
||||
certification ? certification.split('|') : []
|
||||
);
|
||||
|
||||
const certifications =
|
||||
type === 'movie' ? US_MOVIE_CERTIFICATIONS : US_TV_CERTIFICATIONS;
|
||||
|
||||
useEffect(() => {
|
||||
if (certification) {
|
||||
setSelectedRatings(certification.split('|'));
|
||||
} else {
|
||||
setSelectedRatings([]);
|
||||
}
|
||||
}, [certification]);
|
||||
|
||||
const toggleRating = (rating: string) => {
|
||||
setSelectedRatings((prevSelected) => {
|
||||
let newSelected;
|
||||
|
||||
if (prevSelected.includes(rating)) {
|
||||
newSelected = prevSelected.filter((r) => r !== rating);
|
||||
} else {
|
||||
newSelected = [...prevSelected, rating];
|
||||
}
|
||||
|
||||
const newCertification =
|
||||
newSelected.length > 0 ? newSelected.join('|') : undefined;
|
||||
|
||||
onChange({
|
||||
certificationCountry: 'US',
|
||||
certification: newCertification,
|
||||
});
|
||||
|
||||
return newSelected;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{certifications.map((rating) => (
|
||||
<button
|
||||
key={rating}
|
||||
onClick={() => toggleRating(rating)}
|
||||
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
|
||||
selectedRatings.includes(rating)
|
||||
? 'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
type="button"
|
||||
>
|
||||
{rating}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default USCertificationSelector;
|
||||
@@ -631,3 +631,5 @@ export const UserSelector = ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { default as USCertificationSelector } from './USCertificationSelector';
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Watchlist",
|
||||
"components.Discover.DiscoverWatchlist.watchlist": "Plex Watchlist",
|
||||
"components.Discover.FilterSlideover.activefilters": "{count, plural, one {# Active Filter} other {# Active Filters}}",
|
||||
"components.Discover.FilterSlideover.certification": "Content Rating",
|
||||
"components.Discover.FilterSlideover.clearfilters": "Clear Active Filters",
|
||||
"components.Discover.FilterSlideover.filters": "Filters",
|
||||
"components.Discover.FilterSlideover.firstAirDate": "First Air Date",
|
||||
@@ -103,7 +104,6 @@
|
||||
"components.Discover.StudioSlider.studios": "Studios",
|
||||
"components.Discover.TvGenreList.seriesgenres": "Series Genres",
|
||||
"components.Discover.TvGenreSlider.tvgenres": "Series Genres",
|
||||
"components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
|
||||
"components.Discover.createnewslider": "Create New Slider",
|
||||
"components.Discover.customizediscover": "Customize Discover",
|
||||
"components.Discover.discover": "Discover",
|
||||
@@ -137,6 +137,7 @@
|
||||
"components.Discover.upcomingtv": "Upcoming Series",
|
||||
"components.Discover.updatefailed": "Something went wrong updating the discover customization settings.",
|
||||
"components.Discover.updatesuccess": "Updated discover customization settings.",
|
||||
"components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
|
||||
"components.DownloadBlock.estimatedtime": "Estimated {time}",
|
||||
"components.DownloadBlock.formattedTitle": "{title}: Season {seasonNumber} Episode {episodeNumber}",
|
||||
"components.IssueDetails.IssueComment.areyousuredelete": "Are you sure you want to delete this comment?",
|
||||
@@ -583,6 +584,13 @@
|
||||
"components.ResetPassword.validationpasswordrequired": "You must provide a password",
|
||||
"components.Search.search": "Search",
|
||||
"components.Search.searchresults": "Search Results",
|
||||
"components.Selector.CertificationSelector.errorLoading": "Failed to load certifications",
|
||||
"components.Selector.CertificationSelector.maxRating": "Maximum rating",
|
||||
"components.Selector.CertificationSelector.minRating": "Minimum rating",
|
||||
"components.Selector.CertificationSelector.noOptions": "No options available",
|
||||
"components.Selector.CertificationSelector.selectCertification": "Select a certification",
|
||||
"components.Selector.CertificationSelector.selectCountry": "Select a country",
|
||||
"components.Selector.CertificationSelector.starttyping": "Starting typing to search.",
|
||||
"components.Selector.canceled": "Canceled",
|
||||
"components.Selector.ended": "Ended",
|
||||
"components.Selector.inProduction": "In Production",
|
||||
@@ -1199,7 +1207,7 @@
|
||||
"components.Setup.librarieserror": "Validation failed. Please toggle the libraries again to continue.",
|
||||
"components.Setup.servertype": "Choose Server Type",
|
||||
"components.Setup.setup": "Setup",
|
||||
"components.Setup.signin": "Sign In",
|
||||
"components.Setup.signin": "Sign in to your account",
|
||||
"components.Setup.signinMessage": "Get started by signing in",
|
||||
"components.Setup.signinWithEmby": "Enter your Emby details",
|
||||
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
|
||||
|
||||
Reference in New Issue
Block a user