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
14 changes: 13 additions & 1 deletion docs/using-seerr/users/editing-users.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ sidebar_position: 3

From the **User List**, you can click the **Edit** button to modify a particular user's settings.

You can also click the check boxes and click the **Bulk Edit** button to set user permissions for multiple users at once.
You can also click the check boxes and click the **Bulk Edit** button to set user permissions or parental controls for multiple users at once.

## General

Expand Down Expand Up @@ -60,3 +60,15 @@ Users can configure their personal notification settings here. Please see [Notif
## Permissions

Users cannot modify their own permissions. Users with the **Manage Users** permission can manage permissions of other users, except those of users with the **Admin** permission.

## Parental Controls

Users with the **Manage Users** permission can set content rating limits for other users. Rating limits use the US rating systems: MPAA ratings for movies and the TV Parental Guidelines for series.

When a limit is set, content above it is hidden from Discover, search, and recommendations for that user, its detail pages are blocked, and requests for it are rejected. **Block Unrated Content** additionally hides titles that have no US rating.

:::note
Setting a series rating limit hides shows that have no US TV rating. Most popular shows are rated, but much of the wider catalog is not.
:::

Parental controls cannot be set for the server owner or for users with the **Manage Users** permission, and users cannot see or change their own limits.
101 changes: 101 additions & 0 deletions seerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4363,6 +4363,8 @@ paths:
description: |
Update users with given IDs with provided values in request `body.settings`. You cannot update users' Plex tokens through this request.

Parental control fields (`maxMovieRating`, `maxTvRating`, `blockUnrated`) are only written for the users where they are explicitly provided, and are skipped for the primary administrator and users with the `MANAGE_USERS` permission.

Requires the `MANAGE_USERS` permission.
tags:
- users
Expand All @@ -4379,6 +4381,20 @@ paths:
type: integer
permissions:
type: integer
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)
blockUnrated:
type: boolean
default: false
description: Block content with no rating (NR, Unrated)
responses:
'200':
description: Successfully updated user details
Expand Down Expand Up @@ -5460,6 +5476,91 @@ 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)
blockUnrated:
type: boolean
default: false
description: Block content with no rating (NR, Unrated)
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)
blockUnrated:
type: boolean
default: false
description: Block content with no rating (NR, Unrated)
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'
blockUnrated:
type: boolean
default: false
/user/{userId}/watch_data:
get:
summary: Get watch data
Expand Down
55 changes: 46 additions & 9 deletions server/api/themoviedb/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import ExternalAPI from '@server/api/externalapi';
import type { TvShowProvider } from '@server/api/provider';
import {
getAllowedRatings,
type UserContentRatingLimits,
} from '@server/constants/contentRatings';
import type { CacheStore } from '@server/lib/cache';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
Expand Down Expand Up @@ -178,10 +182,16 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
private locale: string;
private discoverRegion?: string;
private originalLanguage?: string;
private contentRatingLimits?: UserContentRatingLimits;
constructor({
discoverRegion,
originalLanguage,
}: { discoverRegion?: string; originalLanguage?: string } = {}) {
contentRatingLimits,
}: {
discoverRegion?: string;
originalLanguage?: string;
contentRatingLimits?: UserContentRatingLimits;
} = {}) {
super(
'https://api.themoviedb.org/3',
{
Expand All @@ -198,6 +208,7 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
this.locale = getSettings().main?.locale || 'en';
this.discoverRegion = discoverRegion;
this.originalLanguage = originalLanguage;
this.contentRatingLimits = contentRatingLimits;
}

public searchMulti = async ({
Expand Down Expand Up @@ -714,6 +725,11 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
.toISOString()
.split('T')[0];

const allowedCertifications = getAllowedRatings(
'movie',
this.contentRatingLimits ?? {}
);

const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
params: {
sort_by: sortBy,
Expand Down Expand Up @@ -750,10 +766,18 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
'vote_count.lte': voteCountLte,
watch_region: watchRegion,
with_watch_providers: watchProviders,
certification: certification,
'certification.gte': certificationGte,
'certification.lte': certificationLte,
certification_country: certificationCountry,
certification: allowedCertifications
? allowedCertifications.join('|')
: certification,
'certification.gte': allowedCertifications
? undefined
: certificationGte,
'certification.lte': allowedCertifications
? undefined
: certificationLte,
certification_country: allowedCertifications
? 'US'
: certificationCountry,
},
});

Expand Down Expand Up @@ -802,6 +826,11 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
.toISOString()
.split('T')[0];

const allowedCertifications = getAllowedRatings(
'tv',
this.contentRatingLimits ?? {}
);

const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
params: {
sort_by: sortBy,
Expand Down Expand Up @@ -838,10 +867,18 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
with_watch_providers: watchProviders,
watch_region: watchRegion,
with_status: withStatus,
certification: certification,
'certification.gte': certificationGte,
'certification.lte': certificationLte,
certification_country: certificationCountry,
certification: allowedCertifications
? allowedCertifications.join('|')
: certification,
'certification.gte': allowedCertifications
? undefined
: certificationGte,
'certification.lte': allowedCertifications
? undefined
: certificationLte,
certification_country: allowedCertifications
? 'US'
: certificationCountry,
},
});

Expand Down
98 changes: 98 additions & 0 deletions server/constants/contentRatings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {
getAllowedRatings,
MOVIE_RATINGS,
shouldFilterMovie,
shouldFilterTv,
TV_RATINGS,
} from '@server/constants/contentRatings';
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

describe('shouldFilterMovie', () => {
it('allows a rating within the cap', () => {
assert.equal(shouldFilterMovie('PG', 'PG-13'), false);
});

it('blocks a rating above the cap', () => {
assert.equal(shouldFilterMovie('R', 'PG-13'), true);
});

it('allows unrated content when blockUnrated is false', () => {
assert.equal(shouldFilterMovie('NR', 'PG-13', false), false);
});

it('blocks unrated content when blockUnrated is true', () => {
assert.equal(shouldFilterMovie('NR', 'PG-13', true), true);
assert.equal(shouldFilterMovie(undefined, 'PG-13', true), true);
});

it('fails closed on an invalid maxRating', () => {
assert.equal(shouldFilterMovie('G', 'NOT-A-RATING', false), true);
});
});

describe('shouldFilterTv', () => {
it('allows a rating within the cap', () => {
assert.equal(shouldFilterTv('TV-PG', 'TV-14'), false);
});

it('blocks a rating above the cap', () => {
assert.equal(shouldFilterTv('TV-MA', 'TV-14'), true);
});

it('allows unrated content when blockUnrated is false', () => {
assert.equal(shouldFilterTv('Unrated', 'TV-14', false), false);
});

it('blocks unrated content when blockUnrated is true', () => {
assert.equal(shouldFilterTv('Unrated', 'TV-14', true), true);
assert.equal(shouldFilterTv(null, 'TV-14', true), true);
});

it('fails closed on an invalid maxRating', () => {
assert.equal(shouldFilterTv('TV-G', 'NOT-A-RATING', false), true);
});
});

describe('getAllowedRatings', () => {
it('returns ratings up to the cap for movies', () => {
assert.deepEqual(getAllowedRatings('movie', { maxMovieRating: 'PG' }), [
'G',
'PG',
]);
});

it('returns ratings up to the cap for tv', () => {
assert.deepEqual(getAllowedRatings('tv', { maxTvRating: 'TV-14' }), [
'TV-Y',
'TV-Y7',
'TV-G',
'TV-PG',
'TV-14',
]);
});

it('returns the full ratings list when there is no cap but unrated is blocked', () => {
assert.deepEqual(getAllowedRatings('movie', { blockUnrated: true }), [
...MOVIE_RATINGS,
]);
assert.deepEqual(getAllowedRatings('tv', { blockUnrated: true }), [
...TV_RATINGS,
]);
});

it('returns undefined when there is no cap and unrated is allowed', () => {
assert.equal(getAllowedRatings('movie', {}), undefined);
assert.equal(getAllowedRatings('tv', {}), undefined);
});

it('fails closed to the most restrictive rating on an invalid cap', () => {
assert.deepEqual(
getAllowedRatings('movie', { maxMovieRating: 'NOT-A-RATING' }),
[MOVIE_RATINGS[0]]
);
assert.deepEqual(getAllowedRatings('tv', { maxTvRating: 'NOT-A-RATING' }), [
TV_RATINGS[0],
]);
});
});
Loading