diff --git a/docs/screenshots/parental-controls-settings.png b/docs/screenshots/parental-controls-settings.png new file mode 100644 index 0000000000..660d8fb0da Binary files /dev/null and b/docs/screenshots/parental-controls-settings.png differ diff --git a/seerr-api.yml b/seerr-api.yml index bf9d882712..d2f42c9f34 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -5012,6 +5012,80 @@ paths: permissions: type: number example: 2 + /user/{userId}/settings/parental-controls: + get: + summary: Get parental control settings for a user + description: Returns parental control settings (content rating limits) for a specific user. Requires `MANAGE_USERS` permission. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: User parental control settings returned + content: + application/json: + schema: + type: object + properties: + maxMovieRating: + type: string + nullable: true + example: 'PG-13' + description: Maximum allowed MPAA movie rating (G, PG, PG-13, R, NC-17) + maxTvRating: + type: string + nullable: true + example: 'TV-14' + description: Maximum allowed TV rating (TV-Y, TV-Y7, TV-G, TV-PG, TV-14, TV-MA) + post: + summary: Update parental control settings for a user + description: Updates and returns parental control settings for a specific user. Requires `MANAGE_USERS` permission. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + maxMovieRating: + type: string + nullable: true + example: 'PG-13' + description: Maximum allowed MPAA movie rating (G, PG, PG-13, R, NC-17) + maxTvRating: + type: string + nullable: true + example: 'TV-14' + description: Maximum allowed TV rating (TV-Y, TV-Y7, TV-G, TV-PG, TV-14, TV-MA) + responses: + '200': + description: Updated user parental control settings returned + content: + application/json: + schema: + type: object + properties: + maxMovieRating: + type: string + nullable: true + example: 'PG-13' + maxTvRating: + type: string + nullable: true + example: 'TV-14' /user/{userId}/watch_data: get: summary: Get watch data diff --git a/server/constants/contentRatings.ts b/server/constants/contentRatings.ts new file mode 100644 index 0000000000..a4a22acb2b --- /dev/null +++ b/server/constants/contentRatings.ts @@ -0,0 +1,116 @@ +/** + * Content Rating Constants for Parental Controls + * + * Single source of truth for US content rating hierarchies and filtering logic. + * Lower index = more restrictive (suitable for younger audiences). + */ + +// MPAA Movie Ratings (US) +export const MOVIE_RATINGS = ['G', 'PG', 'PG-13', 'R', 'NC-17'] as const; +export type MovieRating = (typeof MOVIE_RATINGS)[number]; + +// TV Parental Guidelines Ratings (US) +export const TV_RATINGS = [ + 'TV-Y', + 'TV-Y7', + 'TV-G', + 'TV-PG', + 'TV-14', + 'TV-MA', +] as const; +export type TvRating = (typeof TV_RATINGS)[number]; + +// Values that indicate content has no rating +export const UNRATED_VALUES = ['NR', 'UR', 'Unrated', 'Not Rated', '']; + +/** Per-user content rating limits set by admins */ +export interface UserContentRatingLimits { + maxMovieRating?: string; + maxTvRating?: string; + blockUnrated?: boolean; + blockAdult?: boolean; +} + +/** + * Check if a movie should be filtered out based on rating. + * Returns true if the movie should be BLOCKED. + * + * Uses fail-closed approach: unknown/missing ratings are blocked + * when blockUnrated is true. + */ +export function shouldFilterMovie( + rating: string | undefined | null, + maxRating: string | undefined, + blockUnrated = false +): boolean { + if (!maxRating && !blockUnrated) return false; + + if (!rating || UNRATED_VALUES.includes(rating)) { + return blockUnrated; + } + + if (!maxRating) return false; + + const ratingIndex = MOVIE_RATINGS.indexOf(rating as MovieRating); + const maxIndex = MOVIE_RATINGS.indexOf(maxRating as MovieRating); + + // Unknown rating not in our hierarchy — treat as unrated + if (ratingIndex === -1) return blockUnrated; + if (maxIndex === -1) return false; + + return ratingIndex > maxIndex; +} + +/** + * Check if a TV show should be filtered out based on rating. + * Returns true if the show should be BLOCKED. + * + * Uses fail-closed approach: unknown/missing ratings are blocked + * when blockUnrated is true. + */ +export function shouldFilterTv( + rating: string | undefined | null, + maxRating: string | undefined, + blockUnrated = false +): boolean { + if (!maxRating && !blockUnrated) return false; + + if (!rating || UNRATED_VALUES.includes(rating)) { + return blockUnrated; + } + + if (!maxRating) return false; + + const ratingIndex = TV_RATINGS.indexOf(rating as TvRating); + const maxIndex = TV_RATINGS.indexOf(maxRating as TvRating); + + if (ratingIndex === -1) return blockUnrated; + if (maxIndex === -1) return false; + + return ratingIndex > maxIndex; +} + +/** Display options for movie rating dropdown (admin UI) */ +export function getMovieRatingOptions(): { value: string; label: string }[] { + return [ + { value: '', label: 'No Restriction' }, + { value: 'G', label: 'G - General Audiences' }, + { value: 'PG', label: 'PG - Parental Guidance Suggested' }, + { value: 'PG-13', label: 'PG-13 - Parents Strongly Cautioned' }, + { value: 'R', label: 'R - Restricted' }, + { value: 'NC-17', label: 'NC-17 - Adults Only' }, + ]; +} + +/** Display options for TV rating dropdown (admin UI) */ +export function getTvRatingOptions(): { value: string; label: string }[] { + return [ + { value: '', label: 'No Restriction' }, + { value: 'TV-Y', label: 'TV-Y - All Children' }, + { value: 'TV-Y7', label: 'TV-Y7 - Directed to Older Children' }, + { value: 'TV-G', label: 'TV-G - General Audience' }, + { value: 'TV-PG', label: 'TV-PG - Parental Guidance Suggested' }, + { value: 'TV-14', label: 'TV-14 - Parents Strongly Cautioned' }, + { value: 'TV-MA', label: 'TV-MA - Mature Audience Only' }, + ]; +} diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 82671fe3b3..46366157cd 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -72,6 +72,20 @@ export class UserSettings { @Column({ nullable: true }) public watchlistSyncTv?: boolean; + // Per-user content rating limits (admin-enforced parental controls) + // Users cannot see or modify their own limits - only admins can set these + @Column({ nullable: true }) + public maxMovieRating?: string; // MPAA: "G", "PG", "PG-13", "R", "NC-17" (null = unrestricted) + + @Column({ nullable: true }) + public maxTvRating?: string; // TV Guidelines: "TV-Y", "TV-Y7", "TV-G", "TV-PG", "TV-14", "TV-MA" (null = unrestricted) + + @Column({ default: false }) + public blockUnrated?: boolean; // Block content with no rating (NR, unrated) + + @Column({ default: false }) + public blockAdult?: boolean; // Block adult content (TMDB adult flag) + @Column({ type: 'text', nullable: true, diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 327764618e..d9e6954d00 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -20,6 +20,13 @@ export interface UserSettingsGeneralResponse { watchlistSyncTv?: boolean; } +export interface UserSettingsParentalControlsResponse { + maxMovieRating?: string; + maxTvRating?: string; + blockUnrated?: boolean; + blockAdult?: boolean; +} + export type NotificationAgentTypes = Record; export interface UserSettingsNotificationsResponse { emailEnabled?: boolean; diff --git a/server/migration/postgres/1765557160380-AddUserContentRatingLimits.ts b/server/migration/postgres/1765557160380-AddUserContentRatingLimits.ts new file mode 100644 index 0000000000..8fec7c51c9 --- /dev/null +++ b/server/migration/postgres/1765557160380-AddUserContentRatingLimits.ts @@ -0,0 +1,23 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserContentRatingLimits1765557160380 implements MigrationInterface { + name = 'AddUserContentRatingLimits1765557160380'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "maxMovieRating" character varying` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "maxTvRating" character varying` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "maxTvRating"` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "maxMovieRating"` + ); + } +} diff --git a/server/migration/postgres/1765557160381-AddBlockUnrated.ts b/server/migration/postgres/1765557160381-AddBlockUnrated.ts new file mode 100644 index 0000000000..352e8aeba9 --- /dev/null +++ b/server/migration/postgres/1765557160381-AddBlockUnrated.ts @@ -0,0 +1,17 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBlockUnrated1765557160381 implements MigrationInterface { + name = 'AddBlockUnrated1765557160381'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "blockUnrated" boolean DEFAULT false` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "blockUnrated"` + ); + } +} diff --git a/server/migration/postgres/1770627987305-AddBlockAdult.ts b/server/migration/postgres/1770627987305-AddBlockAdult.ts new file mode 100644 index 0000000000..98ea02cde9 --- /dev/null +++ b/server/migration/postgres/1770627987305-AddBlockAdult.ts @@ -0,0 +1,17 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBlockAdult1770627987305 implements MigrationInterface { + name = 'AddBlockAdult1770627987305'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "blockAdult" boolean DEFAULT false` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "blockAdult"` + ); + } +} diff --git a/server/migration/sqlite/1765557160380-AddUserContentRatingLimits.ts b/server/migration/sqlite/1765557160380-AddUserContentRatingLimits.ts new file mode 100644 index 0000000000..c80041eacc --- /dev/null +++ b/server/migration/sqlite/1765557160380-AddUserContentRatingLimits.ts @@ -0,0 +1,31 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserContentRatingLimits1765557160380 implements MigrationInterface { + name = 'AddUserContentRatingLimits1765557160380'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramMessageThreadId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "maxMovieRating" varchar, "maxTvRating" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramMessageThreadId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramMessageThreadId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramMessageThreadId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramMessageThreadId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramMessageThreadId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/migration/sqlite/1765557160381-AddBlockUnrated.ts b/server/migration/sqlite/1765557160381-AddBlockUnrated.ts new file mode 100644 index 0000000000..1aab254aa9 --- /dev/null +++ b/server/migration/sqlite/1765557160381-AddBlockUnrated.ts @@ -0,0 +1,17 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBlockUnrated1765557160381 implements MigrationInterface { + name = 'AddBlockUnrated1765557160381'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "blockUnrated" boolean DEFAULT (0)` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "blockUnrated"` + ); + } +} diff --git a/server/migration/sqlite/1770627987305-AddBlockAdult.ts b/server/migration/sqlite/1770627987305-AddBlockAdult.ts new file mode 100644 index 0000000000..57adc1e6c5 --- /dev/null +++ b/server/migration/sqlite/1770627987305-AddBlockAdult.ts @@ -0,0 +1,17 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBlockAdult1770627987305 implements MigrationInterface { + name = 'AddBlockAdult1770627987305'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "blockAdult" boolean DEFAULT (0)` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "blockAdult"` + ); + } +} diff --git a/server/routes/discover.ts b/server/routes/discover.ts index b40ba99619..09df7c3356 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,7 +1,19 @@ import PlexTvAPI from '@server/api/plextv'; import type { SortOptions } from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb'; -import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; +import type { + TmdbKeyword, + TmdbMovieResult, + TmdbTvResult, +} from '@server/api/themoviedb/interfaces'; +import type { UserContentRatingLimits } from '@server/constants/contentRatings'; +import { + MOVIE_RATINGS, + UNRATED_VALUES, + shouldFilterMovie, + shouldFilterTv, + type MovieRating, +} from '@server/constants/contentRatings'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; @@ -49,6 +61,273 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { }); }; +/** + * Get the user's content rating limits (admin-enforced parental controls) + * These limits are set by admins and users cannot see or modify them + */ +export const getUserContentRatingLimits = ( + user?: User +): UserContentRatingLimits => { + return { + maxMovieRating: user?.settings?.maxMovieRating ?? undefined, + maxTvRating: user?.settings?.maxTvRating ?? undefined, + blockUnrated: user?.settings?.blockUnrated ?? false, + blockAdult: user?.settings?.blockAdult ?? false, + }; +}; + +/** + * Apply certification limits to discover movie options + * Only applies if user has rating limits set and no explicit certification params + */ +const applyMovieCertificationLimits = ( + options: { + certification?: string; + certificationLte?: string; + certificationCountry?: string; + }, + limits: UserContentRatingLimits +): { certificationLte?: string; certificationCountry?: string } => { + // If user has a movie rating limit and no explicit certification filter + if ( + limits.maxMovieRating && + !options.certification && + !options.certificationLte + ) { + return { + certificationLte: limits.maxMovieRating, + certificationCountry: options.certificationCountry || 'US', + }; + } + return {}; +}; + +/** + * Apply certification limits to discover TV options + * Only applies if user has rating limits set and no explicit certification params + */ +const applyTvCertificationLimits = ( + options: { + certification?: string; + certificationLte?: string; + certificationCountry?: string; + }, + limits: UserContentRatingLimits +): { certificationLte?: string; certificationCountry?: string } => { + // If user has a TV rating limit and no explicit certification filter + if ( + limits.maxTvRating && + !options.certification && + !options.certificationLte + ) { + return { + certificationLte: limits.maxTvRating, + certificationCountry: options.certificationCountry || 'US', + }; + } + return {}; +}; + +/** Minimum results before triggering a backfill from the next TMDB page */ +const BACKFILL_THRESHOLD = 15; + +/** + * Post-filter discover results to remove unrated content. + * TMDB's certificationLte query param caps rated content but does NOT + * exclude items with no certification at all. This filter handles that gap. + * Only runs when blockUnrated is true — otherwise it's a no-op. + * + * When filtering drops results below BACKFILL_THRESHOLD, fetches one + * additional TMDB page to compensate for the gap. + */ +/** + * Extract the best US movie certification from release dates. + * Collects ALL US release date certifications, excludes NR/unrated + * (so unrated director's cuts don't override a theatrical R rating), + * and returns the most restrictive one found. + * Falls back to international ratings if no US rating exists. + */ +const getMovieCertFromDetails = ( + releaseDates: { + iso_3166_1: string; + release_dates: { certification: string }[]; + }[] +): string | undefined => { + const usRelease = releaseDates.find((r) => r.iso_3166_1 === 'US'); + const usCerts: string[] = []; + + if (usRelease?.release_dates) { + for (const rd of usRelease.release_dates) { + if (rd.certification && !UNRATED_VALUES.includes(rd.certification)) { + usCerts.push(rd.certification); + } + } + } + + if (usCerts.length > 0) { + // Return the most restrictive US rating + let best = usCerts[0]; + let bestIdx = MOVIE_RATINGS.indexOf(best as MovieRating); + for (const c of usCerts) { + const idx = MOVIE_RATINGS.indexOf(c as MovieRating); + if (idx > bestIdx) { + bestIdx = idx; + best = c; + } + } + return best; + } + + // Fallback: check all countries for a known MPAA-equivalent rating + for (const release of releaseDates) { + for (const rd of release.release_dates || []) { + if ( + rd.certification && + !UNRATED_VALUES.includes(rd.certification) && + MOVIE_RATINGS.indexOf(rd.certification as MovieRating) !== -1 + ) { + return rd.certification; + } + } + } + + return undefined; +}; + +const filterMovieBatch = async ( + movies: TmdbMovieResult[], + tmdb: TheMovieDb, + limits: UserContentRatingLimits +): Promise => { + const settled = await Promise.allSettled( + movies.map(async (movie) => { + const details = await tmdb.getMovie({ movieId: movie.id }); + const cert = getMovieCertFromDetails( + details.release_dates?.results ?? [] + ); + return { movie, cert, title: details.title }; + }) + ); + + const filtered: TmdbMovieResult[] = []; + for (const outcome of settled) { + if (outcome.status !== 'fulfilled') continue; + const { movie, cert, title } = outcome.value; + if (!shouldFilterMovie(cert, limits.maxMovieRating, limits.blockUnrated)) { + filtered.push(movie); + } else { + logger.debug('Blocked movie by rating (post-filter)', { + label: 'Content Filtering', + movieId: movie.id, + movieTitle: title, + certification: cert ?? 'unrated', + maxRating: limits.maxMovieRating, + }); + } + } + return filtered; +}; + +const postFilterDiscoverMovies = async ( + results: TmdbMovieResult[], + tmdb: TheMovieDb, + limits: UserContentRatingLimits, + fetchNextPage?: () => Promise, + preFiltered = true +): Promise => { + // Free in-memory filter: remove TMDB adult-flagged content + let filtered = limits.blockAdult + ? results.filter((movie) => !movie.adult) + : results; + + // When certification.lte was already applied (preFiltered=true), + // only run expensive per-item checks for blockUnrated. + // When not pre-filtered (e.g. trending), also check maxRating. + const needsPostFilter = preFiltered + ? limits.blockUnrated + : limits.blockUnrated || !!limits.maxMovieRating; + if (!needsPostFilter) return filtered; + + filtered = await filterMovieBatch(filtered, tmdb, limits); + + // Backfill: if too many results were removed, grab one more page + if (filtered.length < BACKFILL_THRESHOLD && fetchNextPage) { + const nextResults = await fetchNextPage(); + if (nextResults && nextResults.length > 0) { + const nextInput = limits.blockAdult + ? nextResults.filter((movie) => !movie.adult) + : nextResults; + const nextFiltered = await filterMovieBatch(nextInput, tmdb, limits); + filtered.push(...nextFiltered); + } + } + + return filtered; +}; + +const filterTvBatch = async ( + shows: TmdbTvResult[], + tmdb: TheMovieDb, + limits: UserContentRatingLimits +): Promise => { + const settled = await Promise.allSettled( + shows.map(async (show) => { + const details = await tmdb.getTvShow({ tvId: show.id }); + const usRating = details.content_ratings?.results?.find( + (r) => r.iso_3166_1 === 'US' + ); + return { show, cert: usRating?.rating, title: details.name }; + }) + ); + + const filtered: TmdbTvResult[] = []; + for (const outcome of settled) { + if (outcome.status !== 'fulfilled') continue; + const { show, cert, title } = outcome.value; + if (!shouldFilterTv(cert, limits.maxTvRating, limits.blockUnrated)) { + filtered.push(show); + } else { + logger.debug('Blocked TV show by rating (post-filter)', { + label: 'Content Filtering', + tvId: show.id, + tvTitle: title, + certification: cert ?? 'unrated', + maxRating: limits.maxTvRating, + }); + } + } + return filtered; +}; + +const postFilterDiscoverTv = async ( + results: TmdbTvResult[], + tmdb: TheMovieDb, + limits: UserContentRatingLimits, + fetchNextPage?: () => Promise, + preFiltered = true +): Promise => { + // When certification.lte was already applied (preFiltered=true), + // only run expensive per-item checks for blockUnrated. + // When not pre-filtered (e.g. trending), also check maxRating. + const needsPostFilter = preFiltered + ? limits.blockUnrated + : limits.blockUnrated || !!limits.maxTvRating; + if (!needsPostFilter) return results; + + const filtered = await filterTvBatch(results, tmdb, limits); + + // Backfill: if too many results were removed, grab one more page + if (filtered.length < BACKFILL_THRESHOLD && fetchNextPage) { + const nextResults = await fetchNextPage(); + if (nextResults && nextResults.length > 0) { + const nextFiltered = await filterTvBatch(nextResults, tmdb, limits); + filtered.push(...nextFiltered); + } + } + + return filtered; +}; + const discoverRoutes = Router(); const QueryFilterOptions = z.object({ @@ -87,14 +366,24 @@ const ApiQuerySchema = QueryFilterOptions.omit({ discoverRoutes.get('/movies', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); + const ratingLimits = getUserContentRatingLimits(req.user); try { const query = ApiQuerySchema.parse(req.query); const keywords = query.keywords; const excludeKeywords = query.excludeKeywords; - const data = await tmdb.getDiscoverMovies({ - page: Number(query.page), + // Apply user's content rating limits (parental controls) + const certificationOverrides = applyMovieCertificationLimits( + { + certification: query.certification, + certificationLte: query.certificationLte, + certificationCountry: query.certificationCountry, + }, + ratingLimits + ); + + const discoverOpts = { sortBy: query.sortBy as SortOptions, language: req.locale ?? query.language, originalLanguage: query.language, @@ -118,23 +407,48 @@ discoverRoutes.get('/movies', async (req, res, next) => { watchRegion: query.watchRegion, certification: query.certification, certificationGte: query.certificationGte, - certificationLte: query.certificationLte, - certificationCountry: query.certificationCountry, + certificationLte: + certificationOverrides.certificationLte ?? query.certificationLte, + certificationCountry: + certificationOverrides.certificationCountry ?? + query.certificationCountry, + }; + const currentPage = Number(query.page); + + const data = await tmdb.getDiscoverMovies({ + page: currentPage, + ...discoverOpts, }); + // Post-filter unrated content if blockUnrated is enabled + const filteredResults = await postFilterDiscoverMovies( + data.results, + tmdb, + ratingLimits, + currentPage < data.total_pages + ? async () => + ( + await tmdb.getDiscoverMovies({ + page: currentPage + 1, + ...discoverOpts, + }) + ).results + : undefined + ); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); let keywordData: TmdbKeyword[] = []; if (keywords) { - const splitKeywords = keywords.split(','); - const keywordResults = await Promise.all( - splitKeywords.map(async (keywordId) => { - return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) }); - }) + keywords + .split(',') + .map((keywordId) => + tmdb.getKeywordDetails({ keywordId: Number(keywordId) }) + ) ); keywordData = keywordResults.filter( @@ -147,12 +461,12 @@ discoverRoutes.get('/movies', async (req, res, next) => { totalPages: data.total_pages, totalResults: data.total_results, keywords: keywordData, - results: data.results.map((result) => + results: filteredResults.map((result) => mapMovieResult( result, media.find( - (req) => - req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.MOVIE ) ) ), @@ -173,6 +487,7 @@ discoverRoutes.get<{ language: string }>( '/movies/language/:language', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); + const ratingLimits = getUserContentRatingLimits(req.user); try { const languages = await tmdb.getLanguages(); @@ -185,15 +500,43 @@ discoverRoutes.get<{ language: string }>( return next({ status: 404, message: 'Language not found.' }); } - const data = await tmdb.getDiscoverMovies({ - page: Number(req.query.page), + // Apply user's content rating limits (parental controls) + const certificationOverrides = applyMovieCertificationLimits( + {}, + ratingLimits + ); + + const langDiscoverOpts = { language: (req.query.language as string) ?? req.locale, originalLanguage: req.params.language, + certificationLte: certificationOverrides.certificationLte, + certificationCountry: certificationOverrides.certificationCountry, + }; + const langPage = Number(req.query.page); + + const data = await tmdb.getDiscoverMovies({ + page: langPage, + ...langDiscoverOpts, }); + const filteredResults = await postFilterDiscoverMovies( + data.results, + tmdb, + ratingLimits, + langPage < data.total_pages + ? async () => + ( + await tmdb.getDiscoverMovies({ + page: langPage + 1, + ...langDiscoverOpts, + }) + ).results + : undefined + ); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); return res.status(200).json({ @@ -201,12 +544,12 @@ discoverRoutes.get<{ language: string }>( totalPages: data.total_pages, totalResults: data.total_results, language, - results: data.results.map((result) => + results: filteredResults.map((result) => mapMovieResult( result, media.find( - (req) => - req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.MOVIE ) ) ), @@ -229,6 +572,7 @@ discoverRoutes.get<{ genreId: string }>( '/movies/genre/:genreId', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); + const ratingLimits = getUserContentRatingLimits(req.user); try { const genres = await tmdb.getMovieGenres({ @@ -243,15 +587,43 @@ discoverRoutes.get<{ genreId: string }>( return next({ status: 404, message: 'Genre not found.' }); } - const data = await tmdb.getDiscoverMovies({ - page: Number(req.query.page), + // Apply user's content rating limits (parental controls) + const certificationOverrides = applyMovieCertificationLimits( + {}, + ratingLimits + ); + + const genreDiscoverOpts = { language: (req.query.language as string) ?? req.locale, genre: req.params.genreId as string, + certificationLte: certificationOverrides.certificationLte, + certificationCountry: certificationOverrides.certificationCountry, + }; + const genrePage = Number(req.query.page); + + const data = await tmdb.getDiscoverMovies({ + page: genrePage, + ...genreDiscoverOpts, }); + const filteredResults = await postFilterDiscoverMovies( + data.results, + tmdb, + ratingLimits, + genrePage < data.total_pages + ? async () => + ( + await tmdb.getDiscoverMovies({ + page: genrePage + 1, + ...genreDiscoverOpts, + }) + ).results + : undefined + ); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); return res.status(200).json({ @@ -259,12 +631,12 @@ discoverRoutes.get<{ genreId: string }>( totalPages: data.total_pages, totalResults: data.total_results, genre, - results: data.results.map((result) => + results: filteredResults.map((result) => mapMovieResult( result, media.find( - (req) => - req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.MOVIE ) ) ), @@ -287,19 +659,48 @@ discoverRoutes.get<{ studioId: string }>( '/movies/studio/:studioId', async (req, res, next) => { const tmdb = new TheMovieDb(); + const ratingLimits = getUserContentRatingLimits(req.user); try { const studio = await tmdb.getStudio(Number(req.params.studioId)); - const data = await tmdb.getDiscoverMovies({ - page: Number(req.query.page), + // Apply user's content rating limits (parental controls) + const certificationOverrides = applyMovieCertificationLimits( + {}, + ratingLimits + ); + + const studioDiscoverOpts = { language: (req.query.language as string) ?? req.locale, studio: req.params.studioId as string, + certificationLte: certificationOverrides.certificationLte, + certificationCountry: certificationOverrides.certificationCountry, + }; + const studioPage = Number(req.query.page); + + const data = await tmdb.getDiscoverMovies({ + page: studioPage, + ...studioDiscoverOpts, }); + const filteredResults = await postFilterDiscoverMovies( + data.results, + tmdb, + ratingLimits, + studioPage < data.total_pages + ? async () => + ( + await tmdb.getDiscoverMovies({ + page: studioPage + 1, + ...studioDiscoverOpts, + }) + ).results + : undefined + ); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); return res.status(200).json({ @@ -307,7 +708,7 @@ discoverRoutes.get<{ studioId: string }>( totalPages: data.total_pages, totalResults: data.total_results, studio: mapProductionCompany(studio), - results: data.results.map((result) => + results: filteredResults.map((result) => mapMovieResult( result, media.find( @@ -333,6 +734,7 @@ discoverRoutes.get<{ studioId: string }>( discoverRoutes.get('/movies/upcoming', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); + const ratingLimits = getUserContentRatingLimits(req.user); const now = new Date(); const offset = now.getTimezoneOffset(); @@ -340,23 +742,51 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => { .toISOString() .split('T')[0]; + // Apply user's content rating limits (parental controls) + const certificationOverrides = applyMovieCertificationLimits( + {}, + ratingLimits + ); + + const upcomingMovieOpts = { + language: (req.query.language as string) ?? req.locale, + primaryReleaseDateGte: date, + certificationLte: certificationOverrides.certificationLte, + certificationCountry: certificationOverrides.certificationCountry, + }; + const upcomingMoviePage = Number(req.query.page); + try { const data = await tmdb.getDiscoverMovies({ - page: Number(req.query.page), - language: (req.query.language as string) ?? req.locale, - primaryReleaseDateGte: date, + page: upcomingMoviePage, + ...upcomingMovieOpts, }); + const filteredResults = await postFilterDiscoverMovies( + data.results, + tmdb, + ratingLimits, + upcomingMoviePage < data.total_pages + ? async () => + ( + await tmdb.getDiscoverMovies({ + page: upcomingMoviePage + 1, + ...upcomingMovieOpts, + }) + ).results + : undefined + ); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, - results: data.results.map((result) => + results: filteredResults.map((result) => mapMovieResult( result, media.find( @@ -380,13 +810,24 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => { discoverRoutes.get('/tv', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); + const ratingLimits = getUserContentRatingLimits(req.user); try { const query = ApiQuerySchema.parse(req.query); const keywords = query.keywords; const excludeKeywords = query.excludeKeywords; - const data = await tmdb.getDiscoverTv({ - page: Number(query.page), + + // Apply user's content rating limits (parental controls) + const certificationOverrides = applyTvCertificationLimits( + { + certification: query.certification, + certificationLte: query.certificationLte, + certificationCountry: query.certificationCountry, + }, + ratingLimits + ); + + const tvDiscoverOpts = { sortBy: query.sortBy as SortOptions, language: req.locale ?? query.language, genre: query.genre, @@ -411,23 +852,44 @@ discoverRoutes.get('/tv', async (req, res, next) => { withStatus: query.status, certification: query.certification, certificationGte: query.certificationGte, - certificationLte: query.certificationLte, - certificationCountry: query.certificationCountry, + certificationLte: + certificationOverrides.certificationLte ?? query.certificationLte, + certificationCountry: + certificationOverrides.certificationCountry ?? + query.certificationCountry, + }; + const tvPage = Number(query.page); + + const data = await tmdb.getDiscoverTv({ + page: tvPage, + ...tvDiscoverOpts, }); + // Post-filter unrated content if blockUnrated is enabled + const filteredResults = await postFilterDiscoverTv( + data.results, + tmdb, + ratingLimits, + tvPage < data.total_pages + ? async () => + (await tmdb.getDiscoverTv({ page: tvPage + 1, ...tvDiscoverOpts })) + .results + : undefined + ); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); let keywordData: TmdbKeyword[] = []; if (keywords) { - const splitKeywords = keywords.split(','); - const keywordResults = await Promise.all( - splitKeywords.map(async (keywordId) => { - return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) }); - }) + keywords + .split(',') + .map((keywordId) => + tmdb.getKeywordDetails({ keywordId: Number(keywordId) }) + ) ); keywordData = keywordResults.filter( @@ -440,7 +902,7 @@ discoverRoutes.get('/tv', async (req, res, next) => { totalPages: data.total_pages, totalResults: data.total_results, keywords: keywordData, - results: data.results.map((result) => + results: filteredResults.map((result) => mapTvResult( result, media.find( @@ -465,6 +927,7 @@ discoverRoutes.get<{ language: string }>( '/tv/language/:language', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); + const ratingLimits = getUserContentRatingLimits(req.user); try { const languages = await tmdb.getLanguages(); @@ -477,15 +940,43 @@ discoverRoutes.get<{ language: string }>( return next({ status: 404, message: 'Language not found.' }); } - const data = await tmdb.getDiscoverTv({ - page: Number(req.query.page), + // Apply user's content rating limits (parental controls) + const certificationOverrides = applyTvCertificationLimits( + {}, + ratingLimits + ); + + const tvLangOpts = { language: (req.query.language as string) ?? req.locale, originalLanguage: req.params.language, + certificationLte: certificationOverrides.certificationLte, + certificationCountry: certificationOverrides.certificationCountry, + }; + const tvLangPage = Number(req.query.page); + + const data = await tmdb.getDiscoverTv({ + page: tvLangPage, + ...tvLangOpts, }); + const filteredResults = await postFilterDiscoverTv( + data.results, + tmdb, + ratingLimits, + tvLangPage < data.total_pages + ? async () => + ( + await tmdb.getDiscoverTv({ + page: tvLangPage + 1, + ...tvLangOpts, + }) + ).results + : undefined + ); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); return res.status(200).json({ @@ -493,7 +984,7 @@ discoverRoutes.get<{ language: string }>( totalPages: data.total_pages, totalResults: data.total_results, language, - results: data.results.map((result) => + results: filteredResults.map((result) => mapTvResult( result, media.find( @@ -521,6 +1012,7 @@ discoverRoutes.get<{ genreId: string }>( '/tv/genre/:genreId', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); + const ratingLimits = getUserContentRatingLimits(req.user); try { const genres = await tmdb.getTvGenres({ @@ -535,15 +1027,43 @@ discoverRoutes.get<{ genreId: string }>( return next({ status: 404, message: 'Genre not found.' }); } - const data = await tmdb.getDiscoverTv({ - page: Number(req.query.page), + // Apply user's content rating limits (parental controls) + const certificationOverrides = applyTvCertificationLimits( + {}, + ratingLimits + ); + + const tvGenreOpts = { language: (req.query.language as string) ?? req.locale, genre: req.params.genreId, + certificationLte: certificationOverrides.certificationLte, + certificationCountry: certificationOverrides.certificationCountry, + }; + const tvGenrePage = Number(req.query.page); + + const data = await tmdb.getDiscoverTv({ + page: tvGenrePage, + ...tvGenreOpts, }); + const filteredResults = await postFilterDiscoverTv( + data.results, + tmdb, + ratingLimits, + tvGenrePage < data.total_pages + ? async () => + ( + await tmdb.getDiscoverTv({ + page: tvGenrePage + 1, + ...tvGenreOpts, + }) + ).results + : undefined + ); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); return res.status(200).json({ @@ -551,7 +1071,7 @@ discoverRoutes.get<{ genreId: string }>( totalPages: data.total_pages, totalResults: data.total_results, genre, - results: data.results.map((result) => + results: filteredResults.map((result) => mapTvResult( result, media.find( @@ -579,19 +1099,48 @@ discoverRoutes.get<{ networkId: string }>( '/tv/network/:networkId', async (req, res, next) => { const tmdb = new TheMovieDb(); + const ratingLimits = getUserContentRatingLimits(req.user); try { const network = await tmdb.getNetwork(Number(req.params.networkId)); - const data = await tmdb.getDiscoverTv({ - page: Number(req.query.page), + // Apply user's content rating limits (parental controls) + const certificationOverrides = applyTvCertificationLimits( + {}, + ratingLimits + ); + + const tvNetworkOpts = { language: (req.query.language as string) ?? req.locale, network: Number(req.params.networkId), + certificationLte: certificationOverrides.certificationLte, + certificationCountry: certificationOverrides.certificationCountry, + }; + const tvNetworkPage = Number(req.query.page); + + const data = await tmdb.getDiscoverTv({ + page: tvNetworkPage, + ...tvNetworkOpts, }); + const filteredResults = await postFilterDiscoverTv( + data.results, + tmdb, + ratingLimits, + tvNetworkPage < data.total_pages + ? async () => + ( + await tmdb.getDiscoverTv({ + page: tvNetworkPage + 1, + ...tvNetworkOpts, + }) + ).results + : undefined + ); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); return res.status(200).json({ @@ -599,7 +1148,7 @@ discoverRoutes.get<{ networkId: string }>( totalPages: data.total_pages, totalResults: data.total_results, network: mapNetwork(network), - results: data.results.map((result) => + results: filteredResults.map((result) => mapTvResult( result, media.find( @@ -625,6 +1174,7 @@ discoverRoutes.get<{ networkId: string }>( discoverRoutes.get('/tv/upcoming', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); + const ratingLimits = getUserContentRatingLimits(req.user); const now = new Date(); const offset = now.getTimezoneOffset(); @@ -632,23 +1182,48 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => { .toISOString() .split('T')[0]; + // Apply user's content rating limits (parental controls) + const certificationOverrides = applyTvCertificationLimits({}, ratingLimits); + + const upcomingTvOpts = { + language: (req.query.language as string) ?? req.locale, + firstAirDateGte: date, + certificationLte: certificationOverrides.certificationLte, + certificationCountry: certificationOverrides.certificationCountry, + }; + const upcomingTvPage = Number(req.query.page); + try { const data = await tmdb.getDiscoverTv({ - page: Number(req.query.page), - language: (req.query.language as string) ?? req.locale, - firstAirDateGte: date, + page: upcomingTvPage, + ...upcomingTvOpts, }); + const filteredResults = await postFilterDiscoverTv( + data.results, + tmdb, + ratingLimits, + upcomingTvPage < data.total_pages + ? async () => + ( + await tmdb.getDiscoverTv({ + page: upcomingTvPage + 1, + ...upcomingTvOpts, + }) + ).results + : undefined + ); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, - results: data.results.map((result) => + results: filteredResults.map((result) => mapTvResult( result, media.find( @@ -671,6 +1246,12 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => { discoverRoutes.get('/trending', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); + const ratingLimits = getUserContentRatingLimits(req.user); + const hasLimits = + ratingLimits.maxMovieRating || + ratingLimits.maxTvRating || + ratingLimits.blockUnrated || + ratingLimits.blockAdult; try { const data = await tmdb.getAllTrending({ @@ -678,35 +1259,68 @@ discoverRoutes.get('/trending', async (req, res, next) => { language: (req.query.language as string) ?? req.locale, }); + // Post-filter trending results if user has any parental controls + let filteredResults = data.results; + if (hasLimits) { + const movieResults = data.results.filter(isMovie) as TmdbMovieResult[]; + const tvResults = data.results.filter( + (r) => !isMovie(r) && !isPerson(r) && !isCollection(r) + ) as TmdbTvResult[]; + const otherResults = data.results.filter( + (r) => isPerson(r) || isCollection(r) + ); + + const filteredMovies = await postFilterDiscoverMovies( + movieResults, + tmdb, + ratingLimits, + undefined, + false // trending has no certification.lte pre-filter + ); + const filteredTv = await postFilterDiscoverTv( + tvResults, + tmdb, + ratingLimits, + undefined, + false // trending has no certification.lte pre-filter + ); + + filteredResults = [ + ...filteredMovies, + ...filteredTv, + ...otherResults, + ] as typeof data.results; + } + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, - results: data.results.map((result) => + results: filteredResults.map((result) => isMovie(result) ? mapMovieResult( - result, - media.find( - (med) => - med.tmdbId === result.id && med.mediaType === MediaType.MOVIE - ) + result, + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.MOVIE ) + ) : isPerson(result) ? mapPersonResult(result) : isCollection(result) ? mapCollectionResult(result) : mapTvResult( - result, - media.find( - (med) => - med.tmdbId === result.id && med.mediaType === MediaType.TV - ) + result, + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.TV ) + ) ), }); } catch (e) { diff --git a/server/routes/search.ts b/server/routes/search.ts index ee2fd9eb89..7cb89082d0 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,17 +1,185 @@ import TheMovieDb from '@server/api/themoviedb'; -import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces'; +import type { + TmdbCollectionResult, + TmdbMovieResult, + TmdbPersonResult, + TmdbSearchMultiResponse, + TmdbTvResult, +} from '@server/api/themoviedb/interfaces'; +import { + shouldFilterMovie, + shouldFilterTv, +} from '@server/constants/contentRatings'; import Media from '@server/entity/Media'; import { findSearchProvider } from '@server/lib/search'; import logger from '@server/logger'; import { mapSearchResults } from '@server/models/Search'; +import { getUserContentRatingLimits } from '@server/routes/discover'; import { Router } from 'express'; +type TmdbSearchResult = + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult; + +/** + * Fetch US certification for a single search result. + * Returns the result paired with its certification, or null on failure. + */ +const getCertification = async ( + result: TmdbSearchResult, + tmdb: TheMovieDb +): Promise<{ result: TmdbSearchResult; certification?: string } | null> => { + try { + if (result.media_type === 'movie') { + const details = await tmdb.getMovie({ movieId: result.id }); + const usRelease = details.release_dates?.results?.find( + (r) => r.iso_3166_1 === 'US' + ); + return { + result, + certification: usRelease?.release_dates?.find((rd) => rd.certification) + ?.certification, + }; + } else if (result.media_type === 'tv') { + const details = await tmdb.getTvShow({ tvId: result.id }); + const usRating = details.content_ratings?.results?.find( + (r) => r.iso_3166_1 === 'US' + ); + return { result, certification: usRating?.rating }; + } + // Person/collection — no certification needed + return { result }; + } catch { + return null; // Fail closed — will be filtered out + } +}; + +/** Minimum results before triggering a backfill from the next TMDB page */ +const BACKFILL_THRESHOLD = 15; + +/** + * Filter a batch of search results by content rating. + * Fail-closed: if certification lookup fails, the result is blocked. + */ +const filterSearchBatch = async ( + results: TmdbSearchResult[], + tmdb: TheMovieDb, + maxMovieRating?: string, + maxTvRating?: string, + blockUnrated = false +): Promise => { + const settled = await Promise.allSettled( + results.map((r) => getCertification(r, tmdb)) + ); + + const filtered: TmdbSearchResult[] = []; + + for (const outcome of settled) { + if (outcome.status === 'rejected' || !outcome.value) { + continue; + } + + const { result, certification } = outcome.value; + + if (result.media_type === 'movie') { + if (!maxMovieRating && !blockUnrated) { + filtered.push(result); + continue; + } + if (shouldFilterMovie(certification, maxMovieRating, blockUnrated)) { + logger.debug( + `Filtering movie "${result.title}" (${ + certification || 'NO RATING' + }) — limit: ${maxMovieRating}`, + { label: 'Search' } + ); + continue; + } + } else if (result.media_type === 'tv') { + if (!maxTvRating && !blockUnrated) { + filtered.push(result); + continue; + } + if (shouldFilterTv(certification, maxTvRating, blockUnrated)) { + logger.debug( + `Filtering TV "${result.name}" (${ + certification || 'NO RATING' + }) — limit: ${maxTvRating}`, + { label: 'Search' } + ); + continue; + } + } + + filtered.push(result); + } + + return filtered; +}; + +/** + * Filter search results by user's content rating limits. + * Fetches certifications in parallel for performance. + * When filtering drops results below BACKFILL_THRESHOLD, + * fetches one additional TMDB page to compensate. + */ +const filterSearchResultsByRating = async ( + results: TmdbSearchResult[], + tmdb: TheMovieDb, + maxMovieRating?: string, + maxTvRating?: string, + blockUnrated = false, + fetchNextPage?: () => Promise +): Promise => { + if (!maxMovieRating && !maxTvRating && !blockUnrated) { + return results; + } + + const filtered = await filterSearchBatch( + results, + tmdb, + maxMovieRating, + maxTvRating, + blockUnrated + ); + + // Backfill: if too many results were removed, grab one more page + if (filtered.length < BACKFILL_THRESHOLD && fetchNextPage) { + const nextResults = await fetchNextPage(); + if (nextResults && nextResults.length > 0) { + const nextFiltered = await filterSearchBatch( + nextResults, + tmdb, + maxMovieRating, + maxTvRating, + blockUnrated + ); + filtered.push(...nextFiltered); + } + } + + return filtered; +}; + const searchRoutes = Router(); searchRoutes.get('/', async (req, res, next) => { const queryString = req.query.query as string; const searchProvider = findSearchProvider(queryString.toLowerCase()); let results: TmdbSearchMultiResponse; + const tmdb = new TheMovieDb(); + + const limits = getUserContentRatingLimits(req.user); + + const searchPage = Number(req.query.page) || 1; + const searchLang = (req.query.language as string) ?? req.locale; + const hasFilters = !!( + limits.maxMovieRating || + limits.maxTvRating || + limits.blockUnrated + ); try { if (searchProvider) { @@ -20,29 +188,51 @@ searchRoutes.get('/', async (req, res, next) => { .match(searchProvider.pattern) as RegExpMatchArray; results = await searchProvider.search({ id, - language: (req.query.language as string) ?? req.locale, + language: searchLang, query: queryString, }); } else { - const tmdb = new TheMovieDb(); - results = await tmdb.searchMulti({ query: queryString, - page: Number(req.query.page), - language: (req.query.language as string) ?? req.locale, + page: searchPage, + language: searchLang, }); } + const originalCount = results.results.length; + const filteredResults = await filterSearchResultsByRating( + results.results, + tmdb, + limits.maxMovieRating, + limits.maxTvRating, + limits.blockUnrated ?? false, + // Only backfill for non-provider multi-search with more pages available + !searchProvider && hasFilters && searchPage < results.total_pages + ? async () => { + const next = await tmdb.searchMulti({ + query: queryString, + page: searchPage + 1, + language: searchLang, + }); + return next.results; + } + : undefined + ); + const filteredCount = filteredResults.length; + const media = await Media.getRelatedMedia( req.user, - results.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); + // Estimate total counts based on the filter ratio from this page + const filterRatio = originalCount > 0 ? filteredCount / originalCount : 1; + return res.status(200).json({ page: results.page, - totalPages: results.total_pages, - totalResults: results.total_results, - results: mapSearchResults(results.results, media), + totalPages: Math.ceil(results.total_pages * filterRatio), + totalResults: Math.ceil(results.total_results * filterRatio), + results: mapSearchResults(filteredResults, media), }); } catch (e) { logger.debug('Something went wrong retrieving search results', { diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index b7733c92ad..52b2f87062 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -9,6 +9,7 @@ import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; import { UserPushSubscription } from '@server/entity/UserPushSubscription'; +import { UserSettings } from '@server/entity/UserSettings'; import { Watchlist } from '@server/entity/Watchlist'; import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces'; import type { @@ -17,7 +18,7 @@ import type { UserResultsResponse, UserWatchDataResponse, } from '@server/interfaces/api/userInterfaces'; -import { Permission, hasPermission } from '@server/lib/permissions'; +import { hasPermission, Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; @@ -437,7 +438,14 @@ export const canMakePermissionsChange = ( router.put< Record, Partial[], - { ids: string[]; permissions: number } + { + ids: string[]; + permissions: number; + maxMovieRating?: string; + maxTvRating?: string; + blockUnrated?: boolean; + blockAdult?: boolean; + } >('/', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { try { const isOwner = req.user?.id === 1; @@ -457,14 +465,40 @@ router.put< isOwner ? req.body.ids : req.body.ids.filter((id) => Number(id) !== 1) ), }, + relations: ['settings'], }); const updatedUsers = await Promise.all( users.map(async (user) => { - return userRepository.save({ - ...user, - ...{ permissions: req.body.permissions }, - }); + // Update permissions + user.permissions = req.body.permissions; + + // Update parental controls if provided + if ( + req.body.maxMovieRating !== undefined || + req.body.maxTvRating !== undefined || + req.body.blockUnrated !== undefined || + req.body.blockAdult !== undefined + ) { + if (!user.settings) { + user.settings = new UserSettings({ user }); + } + const settings = user.settings; + if (req.body.maxMovieRating !== undefined) { + settings.maxMovieRating = req.body.maxMovieRating || undefined; + } + if (req.body.maxTvRating !== undefined) { + settings.maxTvRating = req.body.maxTvRating || undefined; + } + if (req.body.blockUnrated !== undefined) { + settings.blockUnrated = req.body.blockUnrated; + } + if (req.body.blockAdult !== undefined) { + settings.blockAdult = req.body.blockAdult; + } + } + + return userRepository.save(user); }) ); diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 50ea7c5a5f..ed2ebe4553 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -9,6 +9,7 @@ import { UserSettings } from '@server/entity/UserSettings'; import type { UserSettingsGeneralResponse, UserSettingsNotificationsResponse, + UserSettingsParentalControlsResponse, } from '@server/interfaces/api/userSettingsInterfaces'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; @@ -728,4 +729,98 @@ userSettingsRoutes.post< } ); +// Parental Controls settings - admin only +userSettingsRoutes.get<{ id: string }, UserSettingsParentalControlsResponse>( + '/parental-controls', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + return res.status(200).json({ + maxMovieRating: user.settings?.maxMovieRating ?? undefined, + maxTvRating: user.settings?.maxTvRating ?? undefined, + blockUnrated: user.settings?.blockUnrated ?? false, + blockAdult: user.settings?.blockAdult ?? false, + }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +userSettingsRoutes.post< + { id: string }, + UserSettingsParentalControlsResponse, + UserSettingsParentalControlsResponse +>( + '/parental-controls', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + // "Owner" user (id=1) parental controls cannot be set + if (user.id === 1) { + return next({ + status: 403, + message: + 'Cannot set parental controls for the primary administrator.', + }); + } + + // Users with MANAGE_USERS permission cannot have parental controls set on them + if (user.hasPermission(Permission.MANAGE_USERS)) { + return next({ + status: 403, + message: + 'Cannot set parental controls for users with admin permissions.', + }); + } + + if (!user.settings) { + user.settings = new UserSettings({ + user: user, + maxMovieRating: req.body.maxMovieRating || undefined, + maxTvRating: req.body.maxTvRating || undefined, + blockUnrated: req.body.blockUnrated ?? false, + blockAdult: req.body.blockAdult ?? false, + }); + } else { + user.settings.maxMovieRating = req.body.maxMovieRating || undefined; + user.settings.maxTvRating = req.body.maxTvRating || undefined; + user.settings.blockUnrated = req.body.blockUnrated ?? false; + user.settings.blockAdult = req.body.blockAdult ?? false; + } + + await userRepository.save(user); + + return res.status(200).json({ + maxMovieRating: user.settings.maxMovieRating, + maxTvRating: user.settings.maxTvRating, + blockUnrated: user.settings.blockUnrated ?? false, + blockAdult: user.settings.blockAdult ?? false, + }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + export default userSettingsRoutes; diff --git a/src/components/UserList/BulkEditModal.tsx b/src/components/UserList/BulkEditModal.tsx index d5f72ab952..016ef58c96 100644 --- a/src/components/UserList/BulkEditModal.tsx +++ b/src/components/UserList/BulkEditModal.tsx @@ -22,8 +22,33 @@ const messages = defineMessages('components.UserList', { userssaved: 'User permissions saved successfully!', userfail: 'Something went wrong while saving user permissions.', edituser: 'Edit User Permissions', + contentfiltering: 'Content Filtering', + maxmovierating: 'Max Movie Rating', + maxtvrating: 'Max TV Rating', + norestriction: 'No Restriction', + blockunrated: 'Block Unrated Content', + blockadult: 'Block Adult Content', }); +const MOVIE_RATINGS = [ + { value: '', label: 'No Restriction' }, + { value: 'G', label: 'G - General Audiences' }, + { value: 'PG', label: 'PG - Parental Guidance Suggested' }, + { value: 'PG-13', label: 'PG-13 - Parents Strongly Cautioned' }, + { value: 'R', label: 'R - Restricted' }, + { value: 'NC-17', label: 'NC-17 - Adults Only' }, +]; + +const TV_RATINGS = [ + { value: '', label: 'No Restriction' }, + { value: 'TV-Y', label: 'TV-Y - All Children' }, + { value: 'TV-Y7', label: 'TV-Y7 - Directed to Older Children' }, + { value: 'TV-G', label: 'TV-G - General Audience' }, + { value: 'TV-PG', label: 'TV-PG - Parental Guidance Suggested' }, + { value: 'TV-14', label: 'TV-14 - Parents Strongly Cautioned' }, + { value: 'TV-MA', label: 'TV-MA - Mature Audience Only' }, +]; + const BulkEditModal = ({ selectedUserIds, users, @@ -35,6 +60,14 @@ const BulkEditModal = ({ const intl = useIntl(); const { addToast } = useToasts(); const [currentPermission, setCurrentPermission] = useState(0); + const [currentMaxMovieRating, setCurrentMaxMovieRating] = useState< + string | undefined + >(undefined); + const [currentMaxTvRating, setCurrentMaxTvRating] = useState< + string | undefined + >(undefined); + const [currentBlockUnrated, setCurrentBlockUnrated] = useState(false); + const [currentBlockAdult, setCurrentBlockAdult] = useState(false); const [isSaving, setIsSaving] = useState(false); useEffect(() => { @@ -49,6 +82,10 @@ const BulkEditModal = ({ const { data: updated } = await axios.put(`/api/v1/user`, { ids: selectedUserIds, permissions: currentPermission, + maxMovieRating: currentMaxMovieRating || '', + maxTvRating: currentMaxTvRating || '', + blockUnrated: currentBlockUnrated, + blockAdult: currentBlockAdult, }); if (onComplete) { onComplete(updated); @@ -104,6 +141,84 @@ const BulkEditModal = ({ onUpdate={(newPermission) => setCurrentPermission(newPermission)} /> + {hasPermission( + Permission.MANAGE_USERS, + currentUser?.permissions ?? 0 + ) && ( +
+

+ {intl.formatMessage(messages.contentfiltering)} +

+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ setCurrentBlockUnrated(e.target.checked)} + className="rounded-md" + /> + +
+
+ setCurrentBlockAdult(e.target.checked)} + className="rounded-md" + /> + +
+
+ )} ); }; diff --git a/src/components/UserProfile/UserSettings/UserParentalControlsSettings/index.tsx b/src/components/UserProfile/UserSettings/UserParentalControlsSettings/index.tsx new file mode 100644 index 0000000000..10350de45c --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserParentalControlsSettings/index.tsx @@ -0,0 +1,237 @@ +import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import { useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import ErrorPage from '@app/pages/_error'; +import defineMessages from '@app/utils/defineMessages'; +import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; +import type { UserSettingsParentalControlsResponse } from '@server/interfaces/api/userSettingsInterfaces'; +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import { useRouter } from 'next/router'; +import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; + +const messages = defineMessages( + 'components.UserProfile.UserSettings.UserParentalControlsSettings', + { + parentalcontrols: 'Parental Controls', + parentalcontrolssettings: 'Content Rating Limits', + parentalcontrolsdescription: + 'Set maximum content ratings for this user. Content above these ratings will be hidden from discover and search results.', + maxmovierating: 'Max Movie Rating', + maxmovieratingTip: + 'Movies above this rating will be hidden from this user (MPAA ratings)', + maxtvrating: 'Max TV Rating', + maxtvratingTip: + 'TV shows above this rating will be hidden from this user (TV Parental Guidelines)', + norestriction: 'No Restriction', + blockunrated: 'Block Unrated Content', + blockunratedTip: + 'Block content that has no rating (NR, Unrated). When disabled, unrated content is allowed through.', + blockadult: 'Block Adult Content', + blockadultTip: + 'Block content flagged as adult by TMDB. This is separate from content ratings and covers explicit/pornographic content.', + toastSettingsSuccess: 'Parental control settings saved successfully!', + toastSettingsFailure: 'Something went wrong while saving settings.', + } +); + +const MOVIE_RATINGS = [ + { value: '', label: 'No Restriction' }, + { value: 'G', label: 'G - General Audiences' }, + { value: 'PG', label: 'PG - Parental Guidance Suggested' }, + { value: 'PG-13', label: 'PG-13 - Parents Strongly Cautioned' }, + { value: 'R', label: 'R - Restricted' }, + { value: 'NC-17', label: 'NC-17 - Adults Only' }, +]; + +const TV_RATINGS = [ + { value: '', label: 'No Restriction' }, + { value: 'TV-Y', label: 'TV-Y - All Children' }, + { value: 'TV-Y7', label: 'TV-Y7 - Directed to Older Children' }, + { value: 'TV-G', label: 'TV-G - General Audience' }, + { value: 'TV-PG', label: 'TV-PG - Parental Guidance Suggested' }, + { value: 'TV-14', label: 'TV-14 - Parents Strongly Cautioned' }, + { value: 'TV-MA', label: 'TV-MA - Mature Audience Only' }, +]; + +const UserParentalControlsSettings = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const router = useRouter(); + const { user } = useUser({ + id: Number(router.query.userId), + }); + const { + data, + error, + mutate: revalidate, + } = useSWR( + user ? `/api/v1/user/${user?.id}/settings/parental-controls` : null + ); + + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + return ( + <> + +
+

+ {intl.formatMessage(messages.parentalcontrolssettings)} +

+

+ {intl.formatMessage(messages.parentalcontrolsdescription)} +

+
+ { + try { + await axios.post( + `/api/v1/user/${user?.id}/settings/parental-controls`, + { + maxMovieRating: values.maxMovieRating || undefined, + maxTvRating: values.maxTvRating || undefined, + blockUnrated: values.blockUnrated, + blockAdult: values.blockAdult, + } + ); + + addToast(intl.formatMessage(messages.toastSettingsSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + addToast(intl.formatMessage(messages.toastSettingsFailure), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + revalidate(); + } + }} + > + {({ isSubmitting, isValid }) => { + return ( +
+
+ +
+
+ + {MOVIE_RATINGS.map((rating) => ( + + ))} + +
+
+
+
+ +
+
+ + {TV_RATINGS.map((rating) => ( + + ))} + +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ + + +
+
+
+ ); + }} +
+ + ); +}; + +export default UserParentalControlsSettings; diff --git a/src/components/UserProfile/UserSettings/index.tsx b/src/components/UserProfile/UserSettings/index.tsx index cada6e41a3..d10b02c94a 100644 --- a/src/components/UserProfile/UserSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/index.tsx @@ -21,6 +21,7 @@ const messages = defineMessages('components.UserProfile.UserSettings', { menuLinkedAccounts: 'Linked Accounts', menuNotifications: 'Notifications', menuPermissions: 'Permissions', + menuParentalControls: 'Parental Controls', unauthorizedDescription: "You do not have permission to modify this user's settings.", }); @@ -87,6 +88,17 @@ const UserSettings = ({ children }: UserSettingsProps) => { requiredPermission: Permission.MANAGE_USERS, hidden: currentUser?.id !== 1 && currentUser?.id === user.id, }, + { + text: intl.formatMessage(messages.menuParentalControls), + route: '/settings/parental-controls', + regex: /\/settings\/parental-controls/, + requiredPermission: Permission.MANAGE_USERS, + // Hide for owner user (id=1), users viewing their own profile, or admin users + hidden: + user.id === 1 || + currentUser?.id === user.id || + hasPermission(Permission.MANAGE_USERS, user.permissions ?? 0), + }, ]; if (currentUser?.id !== 1 && user.id === 1) { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index d2b4453de2..bfc2c707e4 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1502,6 +1502,18 @@ "components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramChatId": "You must provide a valid chat ID", "components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramMessageThreadId": "The thread/topic ID must be a positive whole number", "components.UserProfile.UserSettings.UserNotificationSettings.webpush": "Web Push", + "components.UserProfile.UserSettings.UserParentalControlsSettings.blockunrated": "Block Unrated Content", + "components.UserProfile.UserSettings.UserParentalControlsSettings.blockunratedTip": "Block content that has no rating (NR, Unrated). When disabled, unrated content is allowed through.", + "components.UserProfile.UserSettings.UserParentalControlsSettings.maxmovierating": "Max Movie Rating", + "components.UserProfile.UserSettings.UserParentalControlsSettings.maxmovieratingTip": "Movies above this rating will be hidden from this user (MPAA ratings)", + "components.UserProfile.UserSettings.UserParentalControlsSettings.maxtvrating": "Max TV Rating", + "components.UserProfile.UserSettings.UserParentalControlsSettings.maxtvratingTip": "TV shows above this rating will be hidden from this user (TV Parental Guidelines)", + "components.UserProfile.UserSettings.UserParentalControlsSettings.norestriction": "No Restriction", + "components.UserProfile.UserSettings.UserParentalControlsSettings.parentalcontrols": "Parental Controls", + "components.UserProfile.UserSettings.UserParentalControlsSettings.parentalcontrolsdescription": "Set maximum content ratings for this user. Content above these ratings will be hidden from discover and search results.", + "components.UserProfile.UserSettings.UserParentalControlsSettings.parentalcontrolssettings": "Content Rating Limits", + "components.UserProfile.UserSettings.UserParentalControlsSettings.toastSettingsFailure": "Something went wrong while saving settings.", + "components.UserProfile.UserSettings.UserParentalControlsSettings.toastSettingsSuccess": "Parental control settings saved successfully!", "components.UserProfile.UserSettings.UserPasswordChange.confirmpassword": "Confirm Password", "components.UserProfile.UserSettings.UserPasswordChange.currentpassword": "Current Password", "components.UserProfile.UserSettings.UserPasswordChange.newpassword": "New Password", @@ -1525,6 +1537,7 @@ "components.UserProfile.UserSettings.menuGeneralSettings": "General", "components.UserProfile.UserSettings.menuLinkedAccounts": "Linked Accounts", "components.UserProfile.UserSettings.menuNotifications": "Notifications", + "components.UserProfile.UserSettings.menuParentalControls": "Parental Controls", "components.UserProfile.UserSettings.menuPermissions": "Permissions", "components.UserProfile.UserSettings.unauthorizedDescription": "You do not have permission to modify this user's settings.", "components.UserProfile.emptywatchlist": "Media added to your Plex Watchlist will appear here.", diff --git a/src/pages/users/[userId]/settings/parental-controls.tsx b/src/pages/users/[userId]/settings/parental-controls.tsx new file mode 100644 index 0000000000..f3bbe61117 --- /dev/null +++ b/src/pages/users/[userId]/settings/parental-controls.tsx @@ -0,0 +1,16 @@ +import UserSettings from '@app/components/UserProfile/UserSettings'; +import UserParentalControlsSettings from '@app/components/UserProfile/UserSettings/UserParentalControlsSettings'; +import useRouteGuard from '@app/hooks/useRouteGuard'; +import { Permission } from '@app/hooks/useUser'; +import type { NextPage } from 'next'; + +const UserSettingsParentalControlsPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); + return ( + + + + ); +}; + +export default UserSettingsParentalControlsPage;