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

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