Skip to content

Commit 9cbb6f4

Browse files
ProgenyAlphaclaude
andcommitted
feat: add per-user parental controls (content rating limits)
Admin-enforced content rating limits per user: - Max movie rating (MPAA: G through NC-17) - Max TV rating (US Parental Guidelines: TV-Y through TV-MA) - Block unrated content toggle (fail-closed) Filtering applied to all discover routes and search with parallel TMDB certification lookups. Backfills from next page when filtering drops results below 15. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 15be3d7 commit 9cbb6f4

File tree

16 files changed

+1412
-93
lines changed

16 files changed

+1412
-93
lines changed
224 KB
Loading

seerr-api.yml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5012,6 +5012,80 @@ paths:
50125012
permissions:
50135013
type: number
50145014
example: 2
5015+
/user/{userId}/settings/parental-controls:
5016+
get:
5017+
summary: Get parental control settings for a user
5018+
description: Returns parental control settings (content rating limits) for a specific user. Requires `MANAGE_USERS` permission.
5019+
tags:
5020+
- users
5021+
parameters:
5022+
- in: path
5023+
name: userId
5024+
required: true
5025+
schema:
5026+
type: number
5027+
responses:
5028+
'200':
5029+
description: User parental control settings returned
5030+
content:
5031+
application/json:
5032+
schema:
5033+
type: object
5034+
properties:
5035+
maxMovieRating:
5036+
type: string
5037+
nullable: true
5038+
example: 'PG-13'
5039+
description: Maximum allowed MPAA movie rating (G, PG, PG-13, R, NC-17)
5040+
maxTvRating:
5041+
type: string
5042+
nullable: true
5043+
example: 'TV-14'
5044+
description: Maximum allowed TV rating (TV-Y, TV-Y7, TV-G, TV-PG, TV-14, TV-MA)
5045+
post:
5046+
summary: Update parental control settings for a user
5047+
description: Updates and returns parental control settings for a specific user. Requires `MANAGE_USERS` permission.
5048+
tags:
5049+
- users
5050+
parameters:
5051+
- in: path
5052+
name: userId
5053+
required: true
5054+
schema:
5055+
type: number
5056+
requestBody:
5057+
required: true
5058+
content:
5059+
application/json:
5060+
schema:
5061+
type: object
5062+
properties:
5063+
maxMovieRating:
5064+
type: string
5065+
nullable: true
5066+
example: 'PG-13'
5067+
description: Maximum allowed MPAA movie rating (G, PG, PG-13, R, NC-17)
5068+
maxTvRating:
5069+
type: string
5070+
nullable: true
5071+
example: 'TV-14'
5072+
description: Maximum allowed TV rating (TV-Y, TV-Y7, TV-G, TV-PG, TV-14, TV-MA)
5073+
responses:
5074+
'200':
5075+
description: Updated user parental control settings returned
5076+
content:
5077+
application/json:
5078+
schema:
5079+
type: object
5080+
properties:
5081+
maxMovieRating:
5082+
type: string
5083+
nullable: true
5084+
example: 'PG-13'
5085+
maxTvRating:
5086+
type: string
5087+
nullable: true
5088+
example: 'TV-14'
50155089
/user/{userId}/watch_data:
50165090
get:
50175091
summary: Get watch data

server/constants/contentRatings.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Content Rating Constants for Parental Controls
3+
*
4+
* Single source of truth for US content rating hierarchies and filtering logic.
5+
* Lower index = more restrictive (suitable for younger audiences).
6+
*/
7+
8+
// MPAA Movie Ratings (US)
9+
export const MOVIE_RATINGS = ['G', 'PG', 'PG-13', 'R', 'NC-17'] as const;
10+
export type MovieRating = (typeof MOVIE_RATINGS)[number];
11+
12+
// TV Parental Guidelines Ratings (US)
13+
export const TV_RATINGS = [
14+
'TV-Y',
15+
'TV-Y7',
16+
'TV-G',
17+
'TV-PG',
18+
'TV-14',
19+
'TV-MA',
20+
] as const;
21+
export type TvRating = (typeof TV_RATINGS)[number];
22+
23+
// Values that indicate content has no rating
24+
export const UNRATED_VALUES = ['NR', 'UR', 'Unrated', 'Not Rated', ''];
25+
26+
/** Per-user content rating limits set by admins */
27+
export interface UserContentRatingLimits {
28+
maxMovieRating?: string;
29+
maxTvRating?: string;
30+
blockUnrated?: boolean;
31+
}
32+
33+
/**
34+
* Check if a movie should be filtered out based on rating.
35+
* Returns true if the movie should be BLOCKED.
36+
*
37+
* Uses fail-closed approach: unknown/missing ratings are blocked
38+
* when blockUnrated is true.
39+
*/
40+
export function shouldFilterMovie(
41+
rating: string | undefined | null,
42+
maxRating: string | undefined,
43+
blockUnrated = false
44+
): boolean {
45+
if (!maxRating && !blockUnrated) return false;
46+
47+
if (!rating || UNRATED_VALUES.includes(rating)) {
48+
return blockUnrated;
49+
}
50+
51+
if (!maxRating) return false;
52+
53+
const ratingIndex = MOVIE_RATINGS.indexOf(rating as MovieRating);
54+
const maxIndex = MOVIE_RATINGS.indexOf(maxRating as MovieRating);
55+
56+
// Unknown rating not in our hierarchy — treat as unrated
57+
if (ratingIndex === -1) return blockUnrated;
58+
if (maxIndex === -1) return false;
59+
60+
return ratingIndex > maxIndex;
61+
}
62+
63+
/**
64+
* Check if a TV show should be filtered out based on rating.
65+
* Returns true if the show should be BLOCKED.
66+
*
67+
* Uses fail-closed approach: unknown/missing ratings are blocked
68+
* when blockUnrated is true.
69+
*/
70+
export function shouldFilterTv(
71+
rating: string | undefined | null,
72+
maxRating: string | undefined,
73+
blockUnrated = false
74+
): boolean {
75+
if (!maxRating && !blockUnrated) return false;
76+
77+
if (!rating || UNRATED_VALUES.includes(rating)) {
78+
return blockUnrated;
79+
}
80+
81+
if (!maxRating) return false;
82+
83+
const ratingIndex = TV_RATINGS.indexOf(rating as TvRating);
84+
const maxIndex = TV_RATINGS.indexOf(maxRating as TvRating);
85+
86+
if (ratingIndex === -1) return blockUnrated;
87+
if (maxIndex === -1) return false;
88+
89+
return ratingIndex > maxIndex;
90+
}
91+
92+
/** Display options for movie rating dropdown (admin UI) */
93+
export function getMovieRatingOptions(): { value: string; label: string }[] {
94+
return [
95+
{ value: '', label: 'No Restriction' },
96+
{ value: 'G', label: 'G - General Audiences' },
97+
{ value: 'PG', label: 'PG - Parental Guidance Suggested' },
98+
{ value: 'PG-13', label: 'PG-13 - Parents Strongly Cautioned' },
99+
{ value: 'R', label: 'R - Restricted' },
100+
{ value: 'NC-17', label: 'NC-17 - Adults Only' },
101+
];
102+
}
103+
104+
/** Display options for TV rating dropdown (admin UI) */
105+
export function getTvRatingOptions(): { value: string; label: string }[] {
106+
return [
107+
{ value: '', label: 'No Restriction' },
108+
{ value: 'TV-Y', label: 'TV-Y - All Children' },
109+
{ value: 'TV-Y7', label: 'TV-Y7 - Directed to Older Children' },
110+
{ value: 'TV-G', label: 'TV-G - General Audience' },
111+
{ value: 'TV-PG', label: 'TV-PG - Parental Guidance Suggested' },
112+
{ value: 'TV-14', label: 'TV-14 - Parents Strongly Cautioned' },
113+
{ value: 'TV-MA', label: 'TV-MA - Mature Audience Only' },
114+
];
115+
}

server/entity/UserSettings.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,17 @@ export class UserSettings {
7272
@Column({ nullable: true })
7373
public watchlistSyncTv?: boolean;
7474

75+
// Per-user content rating limits (admin-enforced parental controls)
76+
// Users cannot see or modify their own limits - only admins can set these
77+
@Column({ nullable: true })
78+
public maxMovieRating?: string; // MPAA: "G", "PG", "PG-13", "R", "NC-17" (null = unrestricted)
79+
80+
@Column({ nullable: true })
81+
public maxTvRating?: string; // TV Guidelines: "TV-Y", "TV-Y7", "TV-G", "TV-PG", "TV-14", "TV-MA" (null = unrestricted)
82+
83+
@Column({ default: false })
84+
public blockUnrated?: boolean; // Block content with no rating (NR, unrated)
85+
7586
@Column({
7687
type: 'text',
7788
nullable: true,

server/interfaces/api/userSettingsInterfaces.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ export interface UserSettingsGeneralResponse {
2020
watchlistSyncTv?: boolean;
2121
}
2222

23+
export interface UserSettingsParentalControlsResponse {
24+
maxMovieRating?: string;
25+
maxTvRating?: string;
26+
blockUnrated?: boolean;
27+
}
28+
2329
export type NotificationAgentTypes = Record<NotificationAgentKey, number>;
2430
export interface UserSettingsNotificationsResponse {
2531
emailEnabled?: boolean;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddUserContentRatingLimits1765557160380
4+
implements MigrationInterface
5+
{
6+
name = 'AddUserContentRatingLimits1765557160380';
7+
8+
public async up(queryRunner: QueryRunner): Promise<void> {
9+
await queryRunner.query(
10+
`ALTER TABLE "user_settings" ADD "maxMovieRating" character varying`
11+
);
12+
await queryRunner.query(
13+
`ALTER TABLE "user_settings" ADD "maxTvRating" character varying`
14+
);
15+
}
16+
17+
public async down(queryRunner: QueryRunner): Promise<void> {
18+
await queryRunner.query(
19+
`ALTER TABLE "user_settings" DROP COLUMN "maxTvRating"`
20+
);
21+
await queryRunner.query(
22+
`ALTER TABLE "user_settings" DROP COLUMN "maxMovieRating"`
23+
);
24+
}
25+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddBlockUnrated1765557160381 implements MigrationInterface {
4+
name = 'AddBlockUnrated1765557160381';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`ALTER TABLE "user_settings" ADD "blockUnrated" boolean DEFAULT false`
9+
);
10+
}
11+
12+
public async down(queryRunner: QueryRunner): Promise<void> {
13+
await queryRunner.query(
14+
`ALTER TABLE "user_settings" DROP COLUMN "blockUnrated"`
15+
);
16+
}
17+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddUserContentRatingLimits1765557160380
4+
implements MigrationInterface
5+
{
6+
name = 'AddUserContentRatingLimits1765557160380';
7+
8+
public async up(queryRunner: QueryRunner): Promise<void> {
9+
await queryRunner.query(
10+
`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)`
11+
);
12+
await queryRunner.query(
13+
`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"`
14+
);
15+
await queryRunner.query(`DROP TABLE "user_settings"`);
16+
await queryRunner.query(
17+
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
18+
);
19+
}
20+
21+
public async down(queryRunner: QueryRunner): Promise<void> {
22+
await queryRunner.query(
23+
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
24+
);
25+
await queryRunner.query(
26+
`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)`
27+
);
28+
await queryRunner.query(
29+
`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"`
30+
);
31+
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
32+
}
33+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddBlockUnrated1765557160381 implements MigrationInterface {
4+
name = 'AddBlockUnrated1765557160381';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`ALTER TABLE "user_settings" ADD "blockUnrated" boolean DEFAULT (0)`
9+
);
10+
}
11+
12+
public async down(queryRunner: QueryRunner): Promise<void> {
13+
await queryRunner.query(
14+
`ALTER TABLE "user_settings" DROP COLUMN "blockUnrated"`
15+
);
16+
}
17+
}

0 commit comments

Comments
 (0)