Skip to content

Commit 149d79e

Browse files
authored
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
1 parent e5b77b2 commit 149d79e

10 files changed

Lines changed: 766 additions & 4 deletions

File tree

jellyseerr-api.yml

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1976,6 +1976,41 @@ components:
19761976
properties:
19771977
id:
19781978
type: string
1979+
Certification:
1980+
type: object
1981+
properties:
1982+
certification:
1983+
type: string
1984+
example: 'PG-13'
1985+
meaning:
1986+
type: string
1987+
example: 'Some material may be inappropriate for children under 13.'
1988+
nullable: true
1989+
order:
1990+
type: number
1991+
example: 3
1992+
nullable: true
1993+
required:
1994+
- certification
1995+
1996+
CertificationResponse:
1997+
type: object
1998+
properties:
1999+
certifications:
2000+
type: object
2001+
additionalProperties:
2002+
type: array
2003+
items:
2004+
$ref: '#/components/schemas/Certification'
2005+
example:
2006+
certifications:
2007+
US:
2008+
- certification: 'G'
2009+
meaning: 'All ages admitted'
2010+
order: 1
2011+
- certification: 'PG'
2012+
meaning: 'Some material may not be suitable for children under 10.'
2013+
order: 2
19792014
securitySchemes:
19802015
cookieAuth:
19812016
type: apiKey
@@ -5026,6 +5061,37 @@ paths:
50265061
schema:
50275062
type: string
50285063
example: 8|9
5064+
- in: query
5065+
name: certification
5066+
schema:
5067+
type: string
5068+
example: PG-13
5069+
description: Exact certification to filter by (used when certificationMode is 'exact')
5070+
- in: query
5071+
name: certificationGte
5072+
schema:
5073+
type: string
5074+
example: G
5075+
description: Minimum certification to filter by (used when certificationMode is 'range')
5076+
- in: query
5077+
name: certificationLte
5078+
schema:
5079+
type: string
5080+
example: PG-13
5081+
description: Maximum certification to filter by (used when certificationMode is 'range')
5082+
- in: query
5083+
name: certificationCountry
5084+
schema:
5085+
type: string
5086+
example: US
5087+
description: Country code for the certification system (e.g., US, GB, CA)
5088+
- in: query
5089+
name: certificationMode
5090+
schema:
5091+
type: string
5092+
enum: [exact, range]
5093+
example: exact
5094+
description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API)
50295095
responses:
50305096
'200':
50315097
description: Results
@@ -5320,6 +5386,37 @@ paths:
53205386
schema:
53215387
type: string
53225388
example: 3|4
5389+
- in: query
5390+
name: certification
5391+
schema:
5392+
type: string
5393+
example: TV-14
5394+
description: Exact certification to filter by (used when certificationMode is 'exact')
5395+
- in: query
5396+
name: certificationGte
5397+
schema:
5398+
type: string
5399+
example: TV-PG
5400+
description: Minimum certification to filter by (used when certificationMode is 'range')
5401+
- in: query
5402+
name: certificationLte
5403+
schema:
5404+
type: string
5405+
example: TV-MA
5406+
description: Maximum certification to filter by (used when certificationMode is 'range')
5407+
- in: query
5408+
name: certificationCountry
5409+
schema:
5410+
type: string
5411+
example: US
5412+
description: Country code for the certification system (e.g., US, GB, CA)
5413+
- in: query
5414+
name: certificationMode
5415+
schema:
5416+
type: string
5417+
enum: [exact, range]
5418+
example: exact
5419+
description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API)
53235420
responses:
53245421
'200':
53255422
description: Results
@@ -7293,6 +7390,64 @@ paths:
72937390
type: array
72947391
items:
72957392
$ref: '#/components/schemas/WatchProviderDetails'
7393+
/certifications/movie:
7394+
get:
7395+
summary: Get movie certifications
7396+
description: Returns list of movie certifications from TMDB.
7397+
tags:
7398+
- other
7399+
security:
7400+
- cookieAuth: []
7401+
- apiKey: []
7402+
responses:
7403+
'200':
7404+
description: Movie certifications returned
7405+
content:
7406+
application/json:
7407+
schema:
7408+
$ref: '#/components/schemas/CertificationResponse'
7409+
'500':
7410+
description: Unable to retrieve movie certifications
7411+
content:
7412+
application/json:
7413+
schema:
7414+
type: object
7415+
properties:
7416+
status:
7417+
type: number
7418+
example: 500
7419+
message:
7420+
type: string
7421+
example: Unable to retrieve movie certifications.
7422+
/certifications/tv:
7423+
get:
7424+
summary: Get TV certifications
7425+
description: Returns list of TV show certifications from TMDB.
7426+
tags:
7427+
- other
7428+
security:
7429+
- cookieAuth: []
7430+
- apiKey: []
7431+
responses:
7432+
'200':
7433+
description: TV certifications returned
7434+
content:
7435+
application/json:
7436+
schema:
7437+
$ref: '#/components/schemas/CertificationResponse'
7438+
'500':
7439+
description: Unable to retrieve TV certifications
7440+
content:
7441+
application/json:
7442+
schema:
7443+
type: object
7444+
properties:
7445+
status:
7446+
type: number
7447+
example: 500
7448+
message:
7449+
type: string
7450+
example: Unable to retrieve TV certifications.
72967451
/overrideRule:
72977452
get:
72987453
summary: Get override rules

server/api/themoviedb/index.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ export const SortOptionsIterable = [
5959

6060
export type SortOptions = (typeof SortOptionsIterable)[number];
6161

62+
export interface TmdbCertificationResponse {
63+
certifications: {
64+
[country: string]: {
65+
certification: string;
66+
meaning?: string;
67+
order?: number;
68+
}[];
69+
};
70+
}
71+
6272
interface DiscoverMovieOptions {
6373
page?: number;
6474
includeAdult?: boolean;
@@ -78,6 +88,10 @@ interface DiscoverMovieOptions {
7888
sortBy?: SortOptions;
7989
watchRegion?: string;
8090
watchProviders?: string;
91+
certification?: string;
92+
certificationGte?: string;
93+
certificationLte?: string;
94+
certificationCountry?: string;
8195
}
8296

8397
interface DiscoverTvOptions {
@@ -100,6 +114,10 @@ interface DiscoverTvOptions {
100114
watchRegion?: string;
101115
watchProviders?: string;
102116
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
117+
certification?: string;
118+
certificationGte?: string;
119+
certificationLte?: string;
120+
certificationCountry?: string;
103121
}
104122

105123
class TheMovieDb extends ExternalAPI {
@@ -477,6 +495,10 @@ class TheMovieDb extends ExternalAPI {
477495
voteCountLte,
478496
watchProviders,
479497
watchRegion,
498+
certification,
499+
certificationGte,
500+
certificationLte,
501+
certificationCountry,
480502
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
481503
try {
482504
const defaultFutureDate = new Date(
@@ -523,6 +545,10 @@ class TheMovieDb extends ExternalAPI {
523545
'vote_count.lte': voteCountLte,
524546
watch_region: watchRegion,
525547
with_watch_providers: watchProviders,
548+
certification: certification,
549+
'certification.gte': certificationGte,
550+
'certification.lte': certificationLte,
551+
certification_country: certificationCountry,
526552
},
527553
});
528554

@@ -552,6 +578,10 @@ class TheMovieDb extends ExternalAPI {
552578
watchProviders,
553579
watchRegion,
554580
withStatus,
581+
certification,
582+
certificationGte,
583+
certificationLte,
584+
certificationCountry,
555585
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
556586
try {
557587
const defaultFutureDate = new Date(
@@ -599,6 +629,10 @@ class TheMovieDb extends ExternalAPI {
599629
with_watch_providers: watchProviders,
600630
watch_region: watchRegion,
601631
with_status: withStatus,
632+
certification: certification,
633+
'certification.gte': certificationGte,
634+
'certification.lte': certificationLte,
635+
certification_country: certificationCountry,
602636
},
603637
});
604638

@@ -987,6 +1021,35 @@ class TheMovieDb extends ExternalAPI {
9871021
}
9881022
}
9891023

1024+
public getMovieCertifications =
1025+
async (): Promise<TmdbCertificationResponse> => {
1026+
try {
1027+
const data = await this.get<TmdbCertificationResponse>(
1028+
'/certification/movie/list',
1029+
{},
1030+
604800 // 7 days
1031+
);
1032+
1033+
return data;
1034+
} catch (e) {
1035+
throw new Error(`[TMDB] Failed to fetch movie certifications: ${e}`);
1036+
}
1037+
};
1038+
1039+
public getTvCertifications = async (): Promise<TmdbCertificationResponse> => {
1040+
try {
1041+
const data = await this.get<TmdbCertificationResponse>(
1042+
'/certification/tv/list',
1043+
{},
1044+
604800 // 7 days
1045+
);
1046+
1047+
return data;
1048+
} catch (e) {
1049+
throw new Error(`[TMDB] Failed to fetch TV certifications: ${e.message}`);
1050+
}
1051+
};
1052+
9901053
public async getKeywordDetails({
9911054
keywordId,
9921055
}: {

server/routes/discover.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,25 @@ const QueryFilterOptions = z.object({
7272
watchProviders: z.coerce.string().optional(),
7373
watchRegion: z.coerce.string().optional(),
7474
status: z.coerce.string().optional(),
75+
certification: z.coerce.string().optional(),
76+
certificationGte: z.coerce.string().optional(),
77+
certificationLte: z.coerce.string().optional(),
78+
certificationCountry: z.coerce.string().optional(),
79+
certificationMode: z.enum(['exact', 'range']).optional(),
7580
});
7681

7782
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
83+
const ApiQuerySchema = QueryFilterOptions.omit({
84+
certificationMode: true,
85+
});
7886

7987
discoverRoutes.get('/movies', async (req, res, next) => {
8088
const tmdb = createTmdbWithRegionLanguage(req.user);
8189

8290
try {
83-
const query = QueryFilterOptions.parse(req.query);
91+
const query = ApiQuerySchema.parse(req.query);
8492
const keywords = query.keywords;
93+
8594
const data = await tmdb.getDiscoverMovies({
8695
page: Number(query.page),
8796
sortBy: query.sortBy as SortOptions,
@@ -104,6 +113,10 @@ discoverRoutes.get('/movies', async (req, res, next) => {
104113
voteCountLte: query.voteCountLte,
105114
watchProviders: query.watchProviders,
106115
watchRegion: query.watchRegion,
116+
certification: query.certification,
117+
certificationGte: query.certificationGte,
118+
certificationLte: query.certificationLte,
119+
certificationCountry: query.certificationCountry,
107120
});
108121

109122
const media = await Media.getRelatedMedia(
@@ -362,7 +375,7 @@ discoverRoutes.get('/tv', async (req, res, next) => {
362375
const tmdb = createTmdbWithRegionLanguage(req.user);
363376

364377
try {
365-
const query = QueryFilterOptions.parse(req.query);
378+
const query = ApiQuerySchema.parse(req.query);
366379
const keywords = query.keywords;
367380
const data = await tmdb.getDiscoverTv({
368381
page: Number(query.page),
@@ -387,6 +400,10 @@ discoverRoutes.get('/tv', async (req, res, next) => {
387400
watchProviders: query.watchProviders,
388401
watchRegion: query.watchRegion,
389402
withStatus: query.status,
403+
certification: query.certification,
404+
certificationGte: query.certificationGte,
405+
certificationLte: query.certificationLte,
406+
certificationCountry: query.certificationCountry,
390407
});
391408

392409
const media = await Media.getRelatedMedia(

0 commit comments

Comments
 (0)