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:
Dillion
2025-05-06 12:55:02 -05:00
committed by GitHub
parent e5b77b2688
commit 149d79e540
10 changed files with 766 additions and 4 deletions

View File

@@ -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

View File

@@ -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,
}: {

View File

@@ -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(

View File

@@ -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',

View File

@@ -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>

View File

@@ -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;

View 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;

View 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;

View File

@@ -631,3 +631,5 @@ export const UserSelector = ({
/>
);
};
export { default as USCertificationSelector } from './USCertificationSelector';

View File

@@ -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",