From 149d79e5404cae48217806079b0ac0a34fbaeb35 Mon Sep 17 00:00:00 2001 From: Dillion <91228469+DillionLowry@users.noreply.github.com> Date: Tue, 6 May 2025 12:55:02 -0500 Subject: [PATCH] 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 --- jellyseerr-api.yml | 155 ++++++++ server/api/themoviedb/index.ts | 63 ++++ server/routes/discover.ts | 21 +- server/routes/index.ts | 42 +++ .../Discover/FilterSlideover/index.tsx | 12 + src/components/Discover/constants.ts | 43 +++ .../Selector/CertificationSelector.tsx | 333 ++++++++++++++++++ .../Selector/USCertificationSelector.tsx | 87 +++++ src/components/Selector/index.tsx | 2 + src/i18n/locale/en.json | 12 +- 10 files changed, 766 insertions(+), 4 deletions(-) create mode 100644 src/components/Selector/CertificationSelector.tsx create mode 100644 src/components/Selector/USCertificationSelector.tsx diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index 2152a5a3e..b6660596b 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -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 diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 21ca42206..d55e32788 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -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 => { 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 => { 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 => { + try { + const data = await this.get( + '/certification/movie/list', + {}, + 604800 // 7 days + ); + + return data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch movie certifications: ${e}`); + } + }; + + public getTvCertifications = async (): Promise => { + try { + const data = await this.get( + '/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, }: { diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 79ae7f285..72688b2f3 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -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; +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( diff --git a/server/routes/index.ts b/server/routes/index.ts index 7d0ad5d8e..b28421396 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -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', diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx index 7df6d55ab..1f06cf0a2 100644 --- a/src/components/Discover/FilterSlideover/index.tsx +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -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); }} /> + + {intl.formatMessage(messages.certification)} + + { + batchUpdateQueryParams(params); + }} + /> {intl.formatMessage(messages.runtime)} diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index c123c9272..425ee7de4 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -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; @@ -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; diff --git a/src/components/Selector/CertificationSelector.tsx b/src/components/Selector/CertificationSelector.tsx new file mode 100644 index 000000000..671c97e5d --- /dev/null +++ b/src/components/Selector/CertificationSelector.tsx @@ -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 = ({ + type, + certificationCountry, + certification, + certificationGte, + certificationLte, + showRange = false, + onChange, +}) => { + const intl = useIntl(); + const [selectedCountry, setSelectedCountry] = + useState( + certificationCountry + ? { value: certificationCountry, label: certificationCountry } + : null + ); + const [selectedCertification, setSelectedCertification] = + useState(null); + const [selectedCertificationGte, setSelectedCertificationGte] = + useState(null); + const [selectedCertificationLte, setSelectedCertificationLte] = + useState(null); + + const { + data: certificationData, + error: certificationError, + isLoading: certificationLoading, + } = useSWR(`/api/v1/certifications/${type}`); + + const { data: regionsData } = useSWR('/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 ( +
+ {intl.formatMessage(messages.errorLoading)} +
+ ); + } + + if (certificationLoading || !certificationData) { + return ; + } + + 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 ( +
+ + inputValue === '' + ? intl.formatMessage(messages.starttyping) + : intl.formatMessage(messages.noOptions) + } + /> + + {certificationCountry && !showRange && ( + intl.formatMessage(messages.noOptions)} + /> + )} + + {certificationCountry && showRange && ( +
+
+ intl.formatMessage(messages.noOptions)} + /> +
+
+ intl.formatMessage(messages.noOptions)} + /> +
+
+ )} +
+ ); +}; + +export default CertificationSelector; diff --git a/src/components/Selector/USCertificationSelector.tsx b/src/components/Selector/USCertificationSelector.tsx new file mode 100644 index 000000000..ba5641579 --- /dev/null +++ b/src/components/Selector/USCertificationSelector.tsx @@ -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 = ({ + type, + certification, + onChange, +}) => { + const [selectedRatings, setSelectedRatings] = useState(() => + 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 ( +
+
+ {certifications.map((rating) => ( + + ))} +
+
+ ); +}; + +export default USCertificationSelector; diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index 4be9b7436..e6eb15ff4 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -631,3 +631,5 @@ export const UserSelector = ({ /> ); }; + +export { default as USCertificationSelector } from './USCertificationSelector'; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index d059904f4..1ce6c2741 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -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",