Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 74 additions & 0 deletions seerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions server/constants/contentRatings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* 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;
}

/**
* 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' },
];
}
11 changes: 11 additions & 0 deletions server/entity/UserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ 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({
type: 'text',
nullable: true,
Expand Down
6 changes: 6 additions & 0 deletions server/interfaces/api/userSettingsInterfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export interface UserSettingsGeneralResponse {
watchlistSyncTv?: boolean;
}

export interface UserSettingsParentalControlsResponse {
maxMovieRating?: string;
maxTvRating?: string;
blockUnrated?: boolean;
}

export type NotificationAgentTypes = Record<NotificationAgentKey, number>;
export interface UserSettingsNotificationsResponse {
emailEnabled?: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddUserContentRatingLimits1765557160380 implements MigrationInterface {
name = 'AddUserContentRatingLimits1765557160380';

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" DROP COLUMN "maxTvRating"`
);
await queryRunner.query(
`ALTER TABLE "user_settings" DROP COLUMN "maxMovieRating"`
);
}
}
17 changes: 17 additions & 0 deletions server/migration/postgres/1765557160381-AddBlockUnrated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddBlockUnrated1765557160381 implements MigrationInterface {
name = 'AddBlockUnrated1765557160381';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" ADD "blockUnrated" boolean DEFAULT false`
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" DROP COLUMN "blockUnrated"`
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddUserContentRatingLimits1765557160380 implements MigrationInterface {
name = 'AddUserContentRatingLimits1765557160380';

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}
17 changes: 17 additions & 0 deletions server/migration/sqlite/1765557160381-AddBlockUnrated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddBlockUnrated1765557160381 implements MigrationInterface {
name = 'AddBlockUnrated1765557160381';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" ADD "blockUnrated" boolean DEFAULT (0)`
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" DROP COLUMN "blockUnrated"`
);
}
}
Loading
Loading