diff --git a/docs/using-seerr/users/editing-users.md b/docs/using-seerr/users/editing-users.md index 8e0b556cc8..a24aaf5078 100644 --- a/docs/using-seerr/users/editing-users.md +++ b/docs/using-seerr/users/editing-users.md @@ -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 @@ -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. diff --git a/seerr-api.yml b/seerr-api.yml index b7fca1eddb..a225290278 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -4366,6 +4366,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 @@ -4382,6 +4384,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 @@ -5463,6 +5479,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 diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 4693b3e86e..daa625021d 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -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'; @@ -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', { @@ -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 ({ @@ -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('/discover/movie', { params: { sort_by: sortBy, @@ -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, }, }); @@ -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('/discover/tv', { params: { sort_by: sortBy, @@ -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, }, }); diff --git a/server/constants/contentRatings.test.ts b/server/constants/contentRatings.test.ts new file mode 100644 index 0000000000..a4cd671586 --- /dev/null +++ b/server/constants/contentRatings.test.ts @@ -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], + ]); + }); +}); diff --git a/server/constants/contentRatings.ts b/server/constants/contentRatings.ts new file mode 100644 index 0000000000..16e301983c --- /dev/null +++ b/server/constants/contentRatings.ts @@ -0,0 +1,99 @@ +export const MOVIE_RATINGS = ['G', 'PG', 'PG-13', 'R', 'NC-17'] as const; +export type MovieRating = (typeof MOVIE_RATINGS)[number]; + +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]; + +export const UNRATED_VALUES = ['NR', 'UR', 'Unrated', 'Not Rated', '']; + +export interface UserContentRatingLimits { + maxMovieRating?: string; + maxTvRating?: string; + blockUnrated?: boolean; +} + +// Fail-closed: unknown/missing ratings are blocked. +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); + + if (ratingIndex === -1) return blockUnrated; + if (maxIndex === -1) return true; + + return ratingIndex > maxIndex; +} + +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 true; + + return ratingIndex > maxIndex; +} + +// Returns the certification list a TMDB /discover query should be +// restricted to, or undefined when no query-side filter applies. +// Fails closed: an unrecognized maxRating collapses to the single +// most restrictive rating rather than allowing everything through. +export function getAllowedRatings( + mediaType: 'movie' | 'tv', + limits: UserContentRatingLimits +): string[] | undefined { + const ratings: readonly string[] = + mediaType === 'movie' ? MOVIE_RATINGS : TV_RATINGS; + const maxRating = + mediaType === 'movie' ? limits.maxMovieRating : limits.maxTvRating; + + if (!maxRating) { + return limits.blockUnrated ? [...ratings] : undefined; + } + + const maxIndex = ratings.indexOf(maxRating); + + if (maxIndex === -1) { + return [ratings[0]]; + } + + return ratings.slice(0, maxIndex + 1); +} + +export function getMovieRatingOptions(): { value: string; label: string }[] { + return MOVIE_RATINGS.map((rating) => ({ value: rating, label: rating })); +} + +export function getTvRatingOptions(): { value: string; label: string }[] { + return TV_RATINGS.map((rating) => ({ value: rating, label: rating })); +} diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 0c3a4f0fb6..f87809c805 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,6 +1,10 @@ import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; +import { + shouldFilterMovie, + shouldFilterTv, +} from '@server/constants/contentRatings'; import { MediaRequestStatus, MediaStatus, @@ -9,6 +13,11 @@ import { import { getRepository } from '@server/datasource'; import OverrideRule from '@server/entity/OverrideRule'; import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; +import { + getMovieCertification, + getTvCertification, + getUserContentRatingLimits, +} from '@server/lib/contentRating'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; @@ -38,6 +47,7 @@ export class QuotaRestrictedError extends Error {} export class DuplicateMediaRequestError extends Error {} export class NoSeasonsAvailableError extends Error {} export class BlocklistedMediaError extends Error {} +export class ContentRatingRestrictedError extends Error {} type MediaRequestOptions = { isAutoRequest?: boolean; @@ -155,6 +165,43 @@ export class MediaRequest { ? await tmdb.getMovie({ movieId: requestBody.mediaId }) : await tmdb.getTvShow({ tvId: requestBody.mediaId }); + const ratingLimits = getUserContentRatingLimits(requestUser); + if (ratingLimits) { + if ( + requestBody.mediaType === MediaType.MOVIE && + 'release_dates' in tmdbMedia + ) { + const cert = getMovieCertification(tmdbMedia); + if ( + shouldFilterMovie( + cert, + ratingLimits.maxMovieRating, + ratingLimits.blockUnrated + ) + ) { + throw new ContentRatingRestrictedError( + 'This content is restricted by your parental controls.' + ); + } + } else if ( + requestBody.mediaType === MediaType.TV && + 'content_ratings' in tmdbMedia + ) { + const cert = getTvCertification(tmdbMedia); + if ( + shouldFilterTv( + cert, + ratingLimits.maxTvRating, + ratingLimits.blockUnrated + ) + ) { + throw new ContentRatingRestrictedError( + 'This content is restricted by your parental controls.' + ); + } + } + } + let media = await mediaRepository.findOne({ where: { tmdbId: requestBody.mediaId, diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index d0f2ef1f7e..b7f0c86098 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -85,6 +85,16 @@ export class UserSettings { @Column({ nullable: true }) public watchlistSyncTv?: boolean; + // Admin-set only; user cannot view or change their own limits. + @Column({ type: 'varchar', nullable: true }) + public maxMovieRating?: string | null; + + @Column({ type: 'varchar', nullable: true }) + public maxTvRating?: string | null; + + @Column({ default: false }) + public blockUnrated?: boolean; + @Column({ type: 'text', nullable: true, diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 57e7b3f614..dbafaf7452 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -19,6 +19,12 @@ export interface UserSettingsGeneralResponse { watchlistSyncTv?: boolean; } +export interface UserSettingsParentalControlsResponse { + maxMovieRating?: string; + maxTvRating?: string; + blockUnrated: boolean; +} + export type NotificationAgentTypes = Record; export interface UserSettingsNotificationsResponse { emailEnabled?: boolean; diff --git a/server/lib/contentRating.test.ts b/server/lib/contentRating.test.ts new file mode 100644 index 0000000000..11b7deb51f --- /dev/null +++ b/server/lib/contentRating.test.ts @@ -0,0 +1,316 @@ +import ExternalAPI from '@server/api/externalapi'; +import { + coalescePages, + filterMixedResults, + filterMoviesByRating, + filterTvByRating, + getMovieCertification, + getTvCertification, +} from '@server/lib/contentRating'; +import assert from 'node:assert/strict'; +import { beforeEach, describe, it, mock } from 'node:test'; + +// Certification per TMDB id; a missing id makes the lookup reject. +const movieCerts: Record = { + 1: 'G', + 2: 'PG-13', + 3: 'R', +}; +const tvRatings: Record = { + 10: 'TV-Y', + 11: 'TV-14', + 12: 'TV-MA', + 13: '', +}; + +// get is a prototype method unlike getMovie, and replaces the cache lookup too +const externalApiGetMock = mock.method( + ExternalAPI.prototype as unknown as { + get: (endpoint: string) => Promise; + }, + 'get', + async (endpoint: string) => { + const [, type, id] = endpoint.match(/^\/(movie|tv)\/(\d+)$/) ?? []; + const cert = type === 'movie' ? movieCerts[Number(id)] : undefined; + const rating = type === 'tv' ? tvRatings[Number(id)] : undefined; + + if (cert === undefined && rating === undefined) { + throw new Error(`Unstubbed external endpoint: ${endpoint}`); + } + + return { + id: Number(id), + // Skips the localized trailer fallback call + videos: { results: [{ type: 'Trailer', key: 'trailer' }] }, + release_dates: { + results: [ + { + iso_3166_1: 'US', + release_dates: [{ certification: cert, type: 3 }], + }, + ], + }, + content_ratings: { + results: [{ iso_3166_1: 'US', rating }], + }, + }; + } +).mock; + +beforeEach(() => { + externalApiGetMock.resetCalls(); +}); + +describe('getMovieCertification', () => { + it('returns a single US certification', () => { + assert.equal( + getMovieCertification({ + release_dates: { + results: [ + { + iso_3166_1: 'US', + rating: '', + release_dates: [ + { certification: 'PG-13', release_date: '', type: 3 }, + ], + }, + ], + }, + }), + 'PG-13' + ); + }); + + it('picks the most restrictive of multiple US certifications', () => { + assert.equal( + getMovieCertification({ + release_dates: { + results: [ + { + iso_3166_1: 'US', + rating: '', + release_dates: [ + { certification: 'PG-13', release_date: '', type: 3 }, + { certification: 'R', release_date: '', type: 4 }, + ], + }, + ], + }, + }), + 'R' + ); + }); + + it('ignores an unrated release when a real certification also exists', () => { + assert.equal( + getMovieCertification({ + release_dates: { + results: [ + { + iso_3166_1: 'US', + rating: '', + release_dates: [ + { certification: 'NR', release_date: '', type: 5 }, + { certification: 'PG', release_date: '', type: 3 }, + ], + }, + ], + }, + }), + 'PG' + ); + }); + + it('returns undefined when there is no US entry', () => { + assert.equal( + getMovieCertification({ + release_dates: { + results: [ + { + iso_3166_1: 'GB', + rating: '', + release_dates: [ + { certification: '15', release_date: '', type: 3 }, + ], + }, + ], + }, + }), + undefined + ); + }); +}); + +describe('getTvCertification', () => { + it('returns the US content rating', () => { + assert.equal( + getTvCertification({ + content_ratings: { results: [{ iso_3166_1: 'US', rating: 'TV-14' }] }, + }), + 'TV-14' + ); + }); + + it('picks the most restrictive of multiple US entries', () => { + assert.equal( + getTvCertification({ + content_ratings: { + results: [ + { iso_3166_1: 'US', rating: 'TV-Y7' }, + { iso_3166_1: 'US', rating: 'TV-14' }, + ], + }, + }), + 'TV-14' + ); + }); + + it('ignores an unrated entry when a real rating also exists', () => { + assert.equal( + getTvCertification({ + content_ratings: { + results: [ + { iso_3166_1: 'US', rating: 'NR' }, + { iso_3166_1: 'US', rating: 'TV-PG' }, + ], + }, + }), + 'TV-PG' + ); + }); + + it('returns undefined for an unrated US entry', () => { + assert.equal( + getTvCertification({ + content_ratings: { results: [{ iso_3166_1: 'US', rating: 'NR' }] }, + }), + undefined + ); + }); + + it('returns undefined when there is no US entry', () => { + assert.equal( + getTvCertification({ + content_ratings: { results: [{ iso_3166_1: 'GB', rating: '12' }] }, + }), + undefined + ); + }); +}); + +describe('filterMoviesByRating', () => { + const movies = [{ id: 1 }, { id: 2 }, { id: 3 }]; + + it('returns the list untouched without limits and looks nothing up', async () => { + const result = await filterMoviesByRating(movies, undefined); + assert.deepEqual(result, movies); + assert.equal(externalApiGetMock.callCount(), 0); + }); + + it('drops titles above the cap and keeps the rest in order', async () => { + const result = await filterMoviesByRating(movies, { + maxMovieRating: 'PG-13', + }); + assert.deepEqual(result, [{ id: 1 }, { id: 2 }]); + assert.equal(externalApiGetMock.callCount(), 3); + }); + + it('drops a title whose certification lookup fails', async () => { + const result = await filterMoviesByRating([{ id: 1 }, { id: 999 }], { + maxMovieRating: 'R', + }); + assert.deepEqual(result, [{ id: 1 }]); + }); +}); + +describe('filterTvByRating', () => { + it('keeps an unrated show unless blockUnrated is set', async () => { + const shows = [{ id: 11 }, { id: 13 }]; + assert.deepEqual(await filterTvByRating(shows, { maxTvRating: 'TV-14' }), [ + { id: 11 }, + { id: 13 }, + ]); + assert.deepEqual( + await filterTvByRating(shows, { + maxTvRating: 'TV-14', + blockUnrated: true, + }), + [{ id: 11 }] + ); + }); +}); + +describe('filterMixedResults', () => { + it('applies each hierarchy by media type and passes people through', async () => { + const items = [ + { id: 3, media_type: 'movie' }, + { id: 2, media_type: 'movie' }, + { id: 12, media_type: 'tv' }, + { id: 10, media_type: 'tv' }, + { id: 500, media_type: 'person' }, + ]; + const result = await filterMixedResults(items, { + maxMovieRating: 'PG-13', + maxTvRating: 'TV-PG', + }); + assert.deepEqual(result, [ + { id: 2, media_type: 'movie' }, + { id: 10, media_type: 'tv' }, + { id: 500, media_type: 'person' }, + ]); + }); + + it('reads mediaType as well as media_type', async () => { + const result = await filterMixedResults( + [ + { id: 1, mediaType: 'movie' }, + { id: 12, mediaType: 'tv' }, + ], + { maxTvRating: 'TV-14' } + ); + assert.deepEqual(result, [{ id: 1, mediaType: 'movie' }]); + }); +}); + +describe('coalescePages', () => { + const upstream = (totalPages: number) => (page: number) => + Promise.resolve({ + page, + total_pages: totalPages, + total_results: totalPages * 20, + results: Array.from({ length: 20 }, (_, i) => (page - 1) * 20 + i), + }); + const noFilter = (r: number[]) => Promise.resolve(r); + + it('combines a fixed window of upstream pages per client page', async () => { + const pageOne = await coalescePages(1, upstream(10), noFilter); + assert.equal(pageOne.results.length, 40); + assert.equal(pageOne.results[0], 0); + assert.equal(pageOne.results[39], 39); + assert.equal(pageOne.totalPages, 5); + + const pageTwo = await coalescePages(2, upstream(10), noFilter); + assert.equal(pageTwo.results[0], 40); + assert.equal(pageTwo.results[39], 79); + }); + + it('does not overlap between consecutive client pages', async () => { + const a = await coalescePages(1, upstream(10), noFilter); + const b = await coalescePages(2, upstream(10), noFilter); + const seen = new Set(a.results); + assert.equal(b.results.some((r) => seen.has(r)), false); + }); + + it('stops at the upstream last page instead of over-fetching', async () => { + const last = await coalescePages(2, upstream(3), noFilter); + assert.equal(last.results.length, 20); + assert.equal(last.totalPages, 2); + }); + + it('applies the filter to the combined window', async () => { + const evens = (r: number[]) => Promise.resolve(r.filter((n) => n % 2 === 0)); + const page = await coalescePages(1, upstream(10), evens); + assert.equal(page.results.length, 20); + assert.equal(page.results.every((n) => n % 2 === 0), true); + }); +}); diff --git a/server/lib/contentRating.ts b/server/lib/contentRating.ts new file mode 100644 index 0000000000..127b8b8c38 --- /dev/null +++ b/server/lib/contentRating.ts @@ -0,0 +1,200 @@ +import TheMovieDb from '@server/api/themoviedb'; +import type { + TmdbMovieDetails, + TmdbTvDetails, +} from '@server/api/themoviedb/interfaces'; +import type { UserContentRatingLimits } from '@server/constants/contentRatings'; +import { + MOVIE_RATINGS, + TV_RATINGS, + UNRATED_VALUES, + shouldFilterMovie, + shouldFilterTv, + type MovieRating, + type TvRating, +} from '@server/constants/contentRatings'; +import type { User } from '@server/entity/User'; + +export function getUserContentRatingLimits( + user?: User +): UserContentRatingLimits | undefined { + const maxMovieRating = user?.settings?.maxMovieRating ?? undefined; + const maxTvRating = user?.settings?.maxTvRating ?? undefined; + const blockUnrated = user?.settings?.blockUnrated ?? false; + + if (!maxMovieRating && !maxTvRating && !blockUnrated) { + return undefined; + } + + return { maxMovieRating, maxTvRating, blockUnrated }; +} + +// Most restrictive US release certification, excluding unrated-style values +// when a real certification also exists (an unrated cut shouldn't override +// the theatrical rating). +export function getMovieCertification( + details: Pick +): string | undefined { + const usCerts = details.release_dates?.results + .find((r) => r.iso_3166_1 === 'US') + ?.release_dates.map((rd) => rd.certification) + .filter((cert) => !UNRATED_VALUES.includes(cert)); + + return usCerts?.reduce( + (worst, cert) => + worst === undefined || + MOVIE_RATINGS.indexOf(cert as MovieRating) > + MOVIE_RATINGS.indexOf(worst as MovieRating) + ? cert + : worst, + undefined + ); +} + +// A show can carry multiple US ratings (e.g. different seasons or networks); +// the most restrictive one wins. +export function getTvCertification( + details: Pick +): string | undefined { + const usRatings = details.content_ratings?.results + .filter((r) => r.iso_3166_1 === 'US') + .map((r) => r.rating) + .filter((rating) => rating && !UNRATED_VALUES.includes(rating)); + + return usRatings?.reduce( + (worst, rating) => + worst === undefined || + TV_RATINGS.indexOf(rating as TvRating) > + TV_RATINGS.indexOf(worst as TvRating) + ? rating + : worst, + undefined + ); +} + +// Shared so the rate limiter is global rather than per-call. +let lookupTmdb: TheMovieDb | undefined; + +async function filterList( + items: T[], + limits: UserContentRatingLimits | undefined, + getCert: (id: number, tmdb: TheMovieDb) => Promise, + isBlocked: (cert: string | undefined) => boolean +): Promise { + if (!limits) return items; + + const tmdb = (lookupTmdb ??= new TheMovieDb()); + const settled = await Promise.allSettled( + items.map(async (item) => ({ item, cert: await getCert(item.id, tmdb) })) + ); + + // A rejected lookup fails closed: the item is dropped. + return settled.flatMap((outcome) => + outcome.status === 'fulfilled' && !isBlocked(outcome.value.cert) + ? [outcome.value.item] + : [] + ); +} + +export function filterMoviesByRating( + items: T[], + limits: UserContentRatingLimits | undefined +): Promise { + return filterList( + items, + limits, + async (id, tmdb) => + getMovieCertification(await tmdb.getMovie({ movieId: id })), + (cert) => + shouldFilterMovie(cert, limits?.maxMovieRating, limits?.blockUnrated) + ); +} + +export function filterTvByRating( + items: T[], + limits: UserContentRatingLimits | undefined +): Promise { + return filterList( + items, + limits, + async (id, tmdb) => getTvCertification(await tmdb.getTvShow({ tvId: id })), + (cert) => shouldFilterTv(cert, limits?.maxTvRating, limits?.blockUnrated) + ); +} + +function mediaTypeOf(item: unknown): string | undefined { + const record = item as { media_type?: string; mediaType?: string }; + return record.media_type ?? record.mediaType; +} + +// Splits a mixed result list (movie/tv/person, e.g. trending or search) by +// media type. Person entries pass through untouched; anything that isn't +// movie/tv/person is dropped rather than let an unrecognized shape through. +export async function filterMixedResults( + items: T[], + limits: UserContentRatingLimits | undefined +): Promise { + if (!limits) return items; + + const movies = items.filter((item) => mediaTypeOf(item) === 'movie'); + const tv = items.filter((item) => mediaTypeOf(item) === 'tv'); + + const [allowedMovies, allowedTv] = await Promise.all([ + filterMoviesByRating(movies, limits), + filterTvByRating(tv, limits), + ]); + const allowed = new Set([...allowedMovies, ...allowedTv]); + + return items.filter( + (item) => mediaTypeOf(item) === 'person' || allowed.has(item) + ); +} + +export const COALESCE_FACTOR = 2; + +export interface CoalescedPage { + page: number; + totalPages: number; + totalResults: number; + results: T[]; +} + +// Search and trending have no TMDB-side certification filter, so filtering +// leaves pages sparse. Client page N is built from a fixed window of upstream +// pages ((N-1)*k+1 .. N*k), which keeps pages full with no cursor to track and +// no overlap. totalResults stays as TMDB reported it, an upper bound either way. +export async function coalescePages( + clientPage: number, + fetchPage: (page: number) => Promise<{ + page: number; + total_pages: number; + total_results: number; + results: T[]; + }>, + filterResults: (results: T[]) => Promise +): Promise> { + const first = (clientPage - 1) * COALESCE_FACTOR + 1; + const firstData = await fetchPage(first); + const upstreamTotal = firstData.total_pages; + + const restPages = []; + for ( + let p = first + 1; + p <= clientPage * COALESCE_FACTOR && p <= upstreamTotal; + p++ + ) { + restPages.push(p); + } + const rest = await Promise.all(restPages.map((p) => fetchPage(p))); + + const combined = ([firstData, ...rest] as { results: T[] }[]).flatMap( + (d) => d.results + ); + + return { + page: clientPage, + totalPages: Math.ceil(upstreamTotal / COALESCE_FACTOR), + totalResults: firstData.total_results, + results: await filterResults(combined), + }; +} diff --git a/server/migration/postgres/1788464882569-AddParentalControlColumns.ts b/server/migration/postgres/1788464882569-AddParentalControlColumns.ts new file mode 100644 index 0000000000..f572e9cc3c --- /dev/null +++ b/server/migration/postgres/1788464882569-AddParentalControlColumns.ts @@ -0,0 +1,29 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddParentalControlColumns1788464882569 implements MigrationInterface { + name = 'AddParentalControlColumns1788464882569'; + + 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` + ); + 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"` + ); + 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/sqlite/1788464887214-AddParentalControlColumns.ts b/server/migration/sqlite/1788464887214-AddParentalControlColumns.ts new file mode 100644 index 0000000000..4fb1ee698b --- /dev/null +++ b/server/migration/sqlite/1788464887214-AddParentalControlColumns.ts @@ -0,0 +1,31 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddParentalControlColumns1788464887214 implements MigrationInterface { + name = 'AddParentalControlColumns1788464887214'; + + 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, "discordIds" text, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, "maxMovieRating" varchar, "maxTvRating" varchar, "blockUnrated" boolean DEFAULT (0), 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", "discordIds", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordIds", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId" 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, "discordIds" text, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" 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 "user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordIds", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordIds", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/routes/collection.ts b/server/routes/collection.ts index 0dc461974d..d386a64286 100644 --- a/server/routes/collection.ts +++ b/server/routes/collection.ts @@ -1,6 +1,10 @@ import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; import Media from '@server/entity/Media'; +import { + filterMoviesByRating, + getUserContentRatingLimits, +} from '@server/lib/contentRating'; import logger from '@server/logger'; import { mapCollection } from '@server/models/Collection'; import { Router } from 'express'; @@ -16,16 +20,19 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => { language: (req.query.language as string) ?? req.locale, }); + const limits = getUserContentRatingLimits(req.user); + const parts = await filterMoviesByRating(collection.parts, limits); + const media = await Media.getRelatedMedia( req.user, - collection.parts.map((part) => ({ + parts.map((part) => ({ tmdbId: part.id, mediaType: MediaType.MOVIE, })), { includeActiveRequest: true } ); - return res.status(200).json(mapCollection(collection, media)); + return res.status(200).json(mapCollection({ ...collection, parts }, media)); } catch (e) { logger.debug('Something went wrong retrieving collection', { label: 'API', diff --git a/server/routes/discover.ts b/server/routes/discover.ts index a949764e6c..663fa8bc95 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -11,8 +11,16 @@ import { User } from '@server/entity/User'; import { Watchlist } from '@server/entity/Watchlist'; import type { GenreSliderItem, + WatchlistItem, WatchlistResponse, } from '@server/interfaces/api/discoverInterfaces'; +import { + coalescePages, + filterMixedResults, + filterMoviesByRating, + filterTvByRating, + getUserContentRatingLimits, +} from '@server/lib/contentRating'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { mapProductionCompany } from '@server/models/Movie'; @@ -48,6 +56,11 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { return new TheMovieDb({ discoverRegion, originalLanguage, + contentRatingLimits: { + maxMovieRating: user?.settings?.maxMovieRating ?? undefined, + maxTvRating: user?.settings?.maxTvRating ?? undefined, + blockUnrated: user?.settings?.blockUnrated, + }, }); }; @@ -139,9 +152,12 @@ discoverRoutes.get('/movies', async (req, res, next) => { certificationCountry: query.certificationCountry, }); + const limits = getUserContentRatingLimits(req.user); + const results = await filterMoviesByRating(data.results, limits); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => ({ + results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })), @@ -168,7 +184,7 @@ discoverRoutes.get('/movies', async (req, res, next) => { totalPages: data.total_pages, totalResults: data.total_results, keywords: keywordData, - results: data.results.map((result) => + results: results.map((result) => mapMovieResult( result, media.find( @@ -212,9 +228,12 @@ discoverRoutes.get<{ language: string }>( originalLanguage: req.params.language, }); + const limits = getUserContentRatingLimits(req.user); + const results = await filterMoviesByRating(data.results, limits); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => ({ + results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })), @@ -226,7 +245,7 @@ discoverRoutes.get<{ language: string }>( totalPages: data.total_pages, totalResults: data.total_results, language, - results: data.results.map((result) => + results: results.map((result) => mapMovieResult( result, media.find( @@ -274,9 +293,12 @@ discoverRoutes.get<{ genreId: string }>( genre: req.params.genreId as string, }); + const limits = getUserContentRatingLimits(req.user); + const results = await filterMoviesByRating(data.results, limits); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => ({ + results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })), @@ -288,7 +310,7 @@ discoverRoutes.get<{ genreId: string }>( totalPages: data.total_pages, totalResults: data.total_results, genre, - results: data.results.map((result) => + results: results.map((result) => mapMovieResult( result, media.find( @@ -315,7 +337,9 @@ discoverRoutes.get<{ genreId: string }>( discoverRoutes.get<{ studioId: string }>( '/movies/studio/:studioId', async (req, res, next) => { - const tmdb = new TheMovieDb(); + const tmdb = new TheMovieDb({ + contentRatingLimits: getUserContentRatingLimits(req.user), + }); try { const studio = await tmdb.getStudio(Number(req.params.studioId)); @@ -326,9 +350,12 @@ discoverRoutes.get<{ studioId: string }>( studio: req.params.studioId as string, }); + const limits = getUserContentRatingLimits(req.user); + const results = await filterMoviesByRating(data.results, limits); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => ({ + results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })), @@ -340,7 +367,7 @@ discoverRoutes.get<{ studioId: string }>( totalPages: data.total_pages, totalResults: data.total_results, studio: mapProductionCompany(studio), - results: data.results.map((result) => + results: results.map((result) => mapMovieResult( result, media.find( @@ -380,9 +407,12 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => { primaryReleaseDateGte: date, }); + const limits = getUserContentRatingLimits(req.user); + const results = await filterMoviesByRating(data.results, limits); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => ({ + results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })), @@ -393,7 +423,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => { page: data.page, totalPages: data.total_pages, totalResults: data.total_results, - results: data.results.map((result) => + results: results.map((result) => mapMovieResult( result, media.find( @@ -452,9 +482,12 @@ discoverRoutes.get('/tv', async (req, res, next) => { certificationCountry: query.certificationCountry, }); + const limits = getUserContentRatingLimits(req.user); + const results = await filterTvByRating(data.results, limits); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => ({ + results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })), @@ -481,7 +514,7 @@ discoverRoutes.get('/tv', async (req, res, next) => { totalPages: data.total_pages, totalResults: data.total_results, keywords: keywordData, - results: data.results.map((result) => + results: results.map((result) => mapTvResult( result, media.find( @@ -524,9 +557,12 @@ discoverRoutes.get<{ language: string }>( originalLanguage: req.params.language, }); + const limits = getUserContentRatingLimits(req.user); + const results = await filterTvByRating(data.results, limits); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => ({ + results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })), @@ -538,7 +574,7 @@ discoverRoutes.get<{ language: string }>( totalPages: data.total_pages, totalResults: data.total_results, language, - results: data.results.map((result) => + results: results.map((result) => mapTvResult( result, media.find( @@ -586,9 +622,12 @@ discoverRoutes.get<{ genreId: string }>( genre: req.params.genreId, }); + const limits = getUserContentRatingLimits(req.user); + const results = await filterTvByRating(data.results, limits); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => ({ + results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })), @@ -600,7 +639,7 @@ discoverRoutes.get<{ genreId: string }>( totalPages: data.total_pages, totalResults: data.total_results, genre, - results: data.results.map((result) => + results: results.map((result) => mapTvResult( result, media.find( @@ -627,7 +666,9 @@ discoverRoutes.get<{ genreId: string }>( discoverRoutes.get<{ networkId: string }>( '/tv/network/:networkId', async (req, res, next) => { - const tmdb = new TheMovieDb(); + const tmdb = new TheMovieDb({ + contentRatingLimits: getUserContentRatingLimits(req.user), + }); try { const network = await tmdb.getNetwork(Number(req.params.networkId)); @@ -638,9 +679,12 @@ discoverRoutes.get<{ networkId: string }>( network: Number(req.params.networkId), }); + const limits = getUserContentRatingLimits(req.user); + const results = await filterTvByRating(data.results, limits); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => ({ + results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })), @@ -652,7 +696,7 @@ discoverRoutes.get<{ networkId: string }>( totalPages: data.total_pages, totalResults: data.total_results, network: mapNetwork(network), - results: data.results.map((result) => + results: results.map((result) => mapTvResult( result, media.find( @@ -692,9 +736,12 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => { firstAirDateGte: date, }); + const limits = getUserContentRatingLimits(req.user); + const results = await filterTvByRating(data.results, limits); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => ({ + results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })), @@ -705,7 +752,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => { page: data.page, totalPages: data.total_pages, totalResults: data.total_results, - results: data.results.map((result) => + results: results.map((result) => mapTvResult( result, media.find( @@ -737,18 +784,21 @@ discoverRoutes.get('/trending', async (req, res, next) => { const page = Number(req.query.page); const trendingFetchers = { - movie: async () => ({ - data: await tmdb.getMovieTrending({ page, language, timeWindow }), + movie: { + fetch: (p: number) => + tmdb.getMovieTrending({ page: p, language, timeWindow }), mapper: mapMovieResult, type: MediaType.MOVIE, - }), - tv: async () => ({ - data: await tmdb.getTvTrending({ page, language, timeWindow }), + }, + tv: { + fetch: (p: number) => + tmdb.getTvTrending({ page: p, language, timeWindow }), mapper: mapTvResult, type: MediaType.TV, - }), - all: async () => ({ - data: await tmdb.getAllTrending({ page, language, timeWindow }), + }, + all: { + fetch: (p: number) => + tmdb.getAllTrending({ page: p, language, timeWindow }), mapper: (result: any, media?: Media) => { if (isMovie(result)) { return mapMovieResult(result, media); @@ -761,14 +811,37 @@ discoverRoutes.get('/trending', async (req, res, next) => { } }, type: null, - }), + }, } as const; - const { data, mapper, type } = await trendingFetchers[mediaType](); + const { fetch: fetchPage, mapper, type } = trendingFetchers[mediaType]; + + const limits = getUserContentRatingLimits(req.user); + let pageOut: number; + let totalPages: number; + let totalResults: number; + let results; + if (limits) { + // Trending has no TMDB-side certification filter; coalesce pages + // so filtering doesn't leave them sparse. + const coalesced = await coalescePages(page || 1, fetchPage, (r) => + filterMixedResults(r, limits) + ); + pageOut = coalesced.page; + totalPages = coalesced.totalPages; + totalResults = coalesced.totalResults; + results = coalesced.results; + } else { + const data = await fetchPage(page); + pageOut = data.page; + totalPages = data.total_pages; + totalResults = data.total_results; + results = data.results; + } const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => ({ + results.map((result) => ({ tmdbId: result.id, mediaType: isMovie(result) ? MediaType.MOVIE : MediaType.TV, })), @@ -776,10 +849,10 @@ discoverRoutes.get('/trending', async (req, res, next) => { ); return res.status(200).json({ - page: data.page, - totalPages: data.total_pages, - totalResults: data.total_results, - results: data.results.map((result) => { + page: pageOut, + totalPages, + totalResults, + results: results.map((result) => { // - If "type" is set (case: "movie" or "tv"), the mediaType must also match. // - If "type" is not set (case: "all"), only filter by tmdbId. const selectedMedia = media.find( @@ -814,9 +887,12 @@ discoverRoutes.get<{ keywordId: string }>( language: (req.query.language as string) ?? req.locale, }); + const limits = getUserContentRatingLimits(req.user); + const results = await filterMoviesByRating(data.results, limits); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => ({ + results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })), @@ -827,7 +903,7 @@ discoverRoutes.get<{ keywordId: string }>( page: data.page, totalPages: data.total_pages, totalResults: data.total_results, - results: data.results.map((result) => + results: results.map((result) => mapMovieResult( result, media.find( @@ -964,11 +1040,23 @@ discoverRoutes.get, WatchlistResponse>( skip: offset, }); if (total) { + const limits = getUserContentRatingLimits(req.user); + const allowed = await filterMixedResults( + result.map((w) => ({ id: w.tmdbId, mediaType: w.mediaType })), + limits + ); + const allowedIds = new Set( + allowed.map((a) => `${a.mediaType}:${a.id}`) + ); + const results = limits + ? result.filter((w) => allowedIds.has(`${w.mediaType}:${w.tmdbId}`)) + : result; + return res.json({ page: page, totalPages: Math.ceil(total / itemsPerPage), totalResults: total, - results: result, + results, }); } } @@ -987,17 +1075,23 @@ discoverRoutes.get, WatchlistResponse>( const watchlist = await plexTV.getWatchlist({ offset }); - return res.json({ - page, - totalPages: Math.ceil(watchlist.totalSize / itemsPerPage), - totalResults: watchlist.totalSize, - results: watchlist.items.map((item) => ({ + const limits = getUserContentRatingLimits(req.user); + const results = await filterMixedResults( + watchlist.items.map((item) => ({ id: item.tmdbId, ratingKey: item.ratingKey, title: item.title, mediaType: item.type === 'show' ? 'tv' : 'movie', tmdbId: item.tmdbId, })), + limits + ); + + return res.json({ + page, + totalPages: Math.ceil(watchlist.totalSize / itemsPerPage), + totalResults: watchlist.totalSize, + results, }); } ); diff --git a/server/routes/movie.ts b/server/routes/movie.ts index d96cc7e22d..bef9feaaa1 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -2,10 +2,16 @@ import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy'; import RottenTomatoes from '@server/api/rating/rottentomatoes'; import { type RatingResponse } from '@server/api/ratings'; import TheMovieDb from '@server/api/themoviedb'; +import { shouldFilterMovie } from '@server/constants/contentRatings'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { Watchlist } from '@server/entity/Watchlist'; +import { + filterMoviesByRating, + getMovieCertification, + getUserContentRatingLimits, +} from '@server/lib/contentRating'; import logger from '@server/logger'; import { mapMovieDetails } from '@server/models/Movie'; import { mapMovieResult } from '@server/models/Search'; @@ -22,6 +28,21 @@ movieRoutes.get('/:id', async (req, res, next) => { language: (req.query.language as string) ?? req.locale, }); + const limits = getUserContentRatingLimits(req.user); + if ( + limits && + shouldFilterMovie( + getMovieCertification(tmdbMovie), + limits.maxMovieRating, + limits.blockUnrated + ) + ) { + return res.status(403).json({ + status: 403, + message: 'Content restricted by parental controls.', + }); + } + const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE); const onUserWatchlist = await getRepository(Watchlist).exist({ @@ -66,9 +87,12 @@ movieRoutes.get('/:id/recommendations', async (req, res, next) => { language: (req.query.language as string) ?? req.locale, }); + const limits = getUserContentRatingLimits(req.user); + const filteredResults = await filterMoviesByRating(results.results, limits); + const media = await Media.getRelatedMedia( req.user, - results.results.map((result) => ({ + filteredResults.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })), @@ -79,7 +103,7 @@ movieRoutes.get('/:id/recommendations', async (req, res, next) => { page: results.page, totalPages: results.total_pages, totalResults: results.total_results, - results: results.results.map((result) => + results: filteredResults.map((result) => mapMovieResult( result, media.find( @@ -112,9 +136,12 @@ movieRoutes.get('/:id/similar', async (req, res, next) => { language: (req.query.language as string) ?? req.locale, }); + const limits = getUserContentRatingLimits(req.user); + const filteredResults = await filterMoviesByRating(results.results, limits); + const media = await Media.getRelatedMedia( req.user, - results.results.map((result) => ({ + filteredResults.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })), @@ -125,7 +152,7 @@ movieRoutes.get('/:id/similar', async (req, res, next) => { page: results.page, totalPages: results.total_pages, totalResults: results.total_results, - results: results.results.map((result) => + results: filteredResults.map((result) => mapMovieResult( result, media.find( @@ -160,6 +187,21 @@ movieRoutes.get('/:id/ratings', async (req, res, next) => { movieId: Number(req.params.id), }); + const limits = getUserContentRatingLimits(req.user); + if ( + limits && + shouldFilterMovie( + getMovieCertification(movie), + limits.maxMovieRating, + limits.blockUnrated + ) + ) { + return res.status(403).json({ + status: 403, + message: 'Content restricted by parental controls.', + }); + } + const rtratings = await rtapi.getMovieRatings( movie.title, Number(movie.release_date.slice(0, 4)) @@ -199,6 +241,21 @@ movieRoutes.get('/:id/ratingscombined', async (req, res, next) => { movieId: Number(req.params.id), }); + const limits = getUserContentRatingLimits(req.user); + if ( + limits && + shouldFilterMovie( + getMovieCertification(movie), + limits.maxMovieRating, + limits.blockUnrated + ) + ) { + return res.status(403).json({ + status: 403, + message: 'Content restricted by parental controls.', + }); + } + const rtratings = await rtapi.getMovieRatings( movie.title, Number(movie.release_date.slice(0, 4)) diff --git a/server/routes/person.ts b/server/routes/person.ts index 03566a4502..2ed1a51207 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -1,5 +1,9 @@ import TheMovieDb from '@server/api/themoviedb'; import Media from '@server/entity/Media'; +import { + filterMixedResults, + getUserContentRatingLimits, +} from '@server/lib/contentRating'; import logger from '@server/logger'; import { mapCastCredits, @@ -41,6 +45,20 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => { language: (req.query.language as string) ?? req.locale, }); + // Credit entries carry media_type, so the mixed filter applies + // per-title rating limits directly to a filmography. + const limits = getUserContentRatingLimits(req.user); + if (limits) { + combinedCredits.cast = await filterMixedResults( + combinedCredits.cast, + limits + ); + combinedCredits.crew = await filterMixedResults( + combinedCredits.crew, + limits + ); + } + const castMedia = await Media.getRelatedMedia( req.user, combinedCredits.cast diff --git a/server/routes/request.ts b/server/routes/request.ts index 9ee5697395..f0a1cab36d 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -9,6 +9,7 @@ import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { BlocklistedMediaError, + ContentRatingRestrictedError, DuplicateMediaRequestError, MediaRequest, NoSeasonsAvailableError, @@ -327,6 +328,7 @@ requestRoutes.post( case NoSeasonsAvailableError: return next({ status: 202, message: error.message }); case BlocklistedMediaError: + case ContentRatingRestrictedError: return next({ status: 403, message: error.message }); default: return next({ status: 500, message: error.message }); diff --git a/server/routes/search.ts b/server/routes/search.ts index cccb38f286..316ccce297 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,6 +1,11 @@ import TheMovieDb from '@server/api/themoviedb'; import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces'; import Media from '@server/entity/Media'; +import { + coalescePages, + filterMixedResults, + getUserContentRatingLimits, +} from '@server/lib/contentRating'; import { findSearchProvider } from '@server/lib/search'; import logger from '@server/logger'; import { mapSearchResults } from '@server/models/Search'; @@ -11,41 +16,72 @@ 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 limits = getUserContentRatingLimits(req.user); try { + let page: number; + let totalPages: number; + let totalResults: number; + let filteredResults: TmdbSearchMultiResponse['results']; + if (searchProvider) { const [id] = queryString .toLowerCase() .match(searchProvider.pattern) as RegExpMatchArray; - results = await searchProvider.search({ + const results = await searchProvider.search({ id, language: (req.query.language as string) ?? req.locale, query: queryString, }); + page = results.page; + totalPages = results.total_pages; + totalResults = results.total_results; + filteredResults = await filterMixedResults(results.results, limits); + } else if (limits) { + // TMDB search has no certification params, so filtering thins + // pages. Coalesce a fixed window of upstream pages per client page + // to keep them near full. + const tmdb = new TheMovieDb(); + const coalesced = await coalescePages( + Number(req.query.page) || 1, + (p) => + tmdb.searchMulti({ + query: queryString, + page: p, + language: (req.query.language as string) ?? req.locale, + }), + (results) => filterMixedResults(results, limits) + ); + page = coalesced.page; + totalPages = coalesced.totalPages; + totalResults = coalesced.totalResults; + filteredResults = coalesced.results; } else { const tmdb = new TheMovieDb(); - - results = await tmdb.searchMulti({ + const results = await tmdb.searchMulti({ query: queryString, page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, }); + page = results.page; + totalPages = results.total_pages; + totalResults = results.total_results; + filteredResults = results.results; } const media = await Media.getRelatedMedia( req.user, - results.results.map((result) => ({ + filteredResults.map((result) => ({ tmdbId: result.id, mediaType: result.media_type, })) ); return res.status(200).json({ - page: results.page, - totalPages: results.total_pages, - totalResults: results.total_results, - results: mapSearchResults(results.results, media), + page, + totalPages, + totalResults, + results: mapSearchResults(filteredResults, media), }); } catch (e) { logger.debug('Something went wrong retrieving search results', { diff --git a/server/routes/tv.ts b/server/routes/tv.ts index e4336d6820..e3c515b7d8 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -3,10 +3,16 @@ import RottenTomatoes from '@server/api/rating/rottentomatoes'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; +import { shouldFilterTv } from '@server/constants/contentRatings'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { Watchlist } from '@server/entity/Watchlist'; +import { + filterTvByRating, + getTvCertification, + getUserContentRatingLimits, +} from '@server/lib/contentRating'; import logger from '@server/logger'; import { mapTvResult } from '@server/models/Search'; import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; @@ -21,6 +27,22 @@ tvRoutes.get('/:id', async (req, res, next) => { const tmdbTv = await tmdb.getTvShow({ tvId: Number(req.params.id), }); + + const limits = getUserContentRatingLimits(req.user); + if ( + limits && + shouldFilterTv( + getTvCertification(tmdbTv), + limits.maxTvRating, + limits.blockUnrated + ) + ) { + return res.status(403).json({ + status: 403, + message: 'Content restricted by parental controls.', + }); + } + const metadataProvider = tmdbTv.keywords.results.some( (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID ) @@ -72,6 +94,22 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { const tmdbTv = await tmdb.getTvShow({ tvId: Number(req.params.id), }); + + const limits = getUserContentRatingLimits(req.user); + if ( + limits && + shouldFilterTv( + getTvCertification(tmdbTv), + limits.maxTvRating, + limits.blockUnrated + ) + ) { + return res.status(403).json({ + status: 403, + message: 'Content restricted by parental controls.', + }); + } + const metadataProvider = tmdbTv.keywords.results.some( (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID ) @@ -109,9 +147,12 @@ tvRoutes.get('/:id/recommendations', async (req, res, next) => { language: (req.query.language as string) ?? req.locale, }); + const limits = getUserContentRatingLimits(req.user); + const filteredResults = await filterTvByRating(results.results, limits); + const media = await Media.getRelatedMedia( req.user, - results.results.map((result) => ({ + filteredResults.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })), @@ -122,7 +163,7 @@ tvRoutes.get('/:id/recommendations', async (req, res, next) => { page: results.page, totalPages: results.total_pages, totalResults: results.total_results, - results: results.results.map((result) => + results: filteredResults.map((result) => mapTvResult( result, media.find( @@ -154,9 +195,12 @@ tvRoutes.get('/:id/similar', async (req, res, next) => { language: (req.query.language as string) ?? req.locale, }); + const limits = getUserContentRatingLimits(req.user); + const filteredResults = await filterTvByRating(results.results, limits); + const media = await Media.getRelatedMedia( req.user, - results.results.map((result) => ({ + filteredResults.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })), @@ -167,7 +211,7 @@ tvRoutes.get('/:id/similar', async (req, res, next) => { page: results.page, totalPages: results.total_pages, totalResults: results.total_results, - results: results.results.map((result) => + results: filteredResults.map((result) => mapTvResult( result, media.find( @@ -198,6 +242,21 @@ tvRoutes.get('/:id/ratings', async (req, res, next) => { tvId: Number(req.params.id), }); + const limits = getUserContentRatingLimits(req.user); + if ( + limits && + shouldFilterTv( + getTvCertification(tv), + limits.maxTvRating, + limits.blockUnrated + ) + ) { + return res.status(403).json({ + status: 403, + message: 'Content restricted by parental controls.', + }); + } + const rtratings = await rtapi.getTVRatings( tv.name, tv.first_air_date ? Number(tv.first_air_date.slice(0, 4)) : undefined diff --git a/server/routes/user/index.test.ts b/server/routes/user/index.test.ts new file mode 100644 index 0000000000..81bdda65e5 --- /dev/null +++ b/server/routes/user/index.test.ts @@ -0,0 +1,50 @@ +import { validateBulkParentalControlFields } from '@server/routes/user'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +describe('validateBulkParentalControlFields', () => { + it('accepts an empty body', () => { + assert.equal(validateBulkParentalControlFields({}), null); + }); + + it('accepts valid movie and TV ratings with blockUnrated', () => { + assert.equal( + validateBulkParentalControlFields({ + maxMovieRating: 'PG-13', + maxTvRating: 'TV-14', + blockUnrated: true, + }), + null + ); + }); + + it('rejects an invalid movie rating', () => { + assert.match( + validateBulkParentalControlFields({ maxMovieRating: 'XX' }) ?? '', + /Invalid movie rating: XX/ + ); + }); + + it('rejects an invalid TV rating', () => { + assert.match( + validateBulkParentalControlFields({ maxTvRating: 'XX' }) ?? '', + /Invalid TV rating: XX/ + ); + }); + + it('rejects a non-boolean blockUnrated', () => { + assert.match( + validateBulkParentalControlFields({ + blockUnrated: 'yes' as unknown as boolean, + }) ?? '', + /blockUnrated must be a boolean/ + ); + }); + + it('allows an empty-string rating to clear the restriction', () => { + assert.equal( + validateBulkParentalControlFields({ maxMovieRating: '', maxTvRating: '' }), + null + ); + }); +}); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 0d5843f85a..4774b1e5ca 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -1,6 +1,8 @@ import JellyfinAPI from '@server/api/jellyfin'; import PlexTvAPI from '@server/api/plextv'; import TautulliAPI from '@server/api/tautulli'; +import type { MovieRating, TvRating } from '@server/constants/contentRatings'; +import { MOVIE_RATINGS, TV_RATINGS } from '@server/constants/contentRatings'; import { MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { UserType } from '@server/constants/user'; @@ -9,6 +11,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 { @@ -510,10 +513,42 @@ export const canMakePermissionsChange = ( // Only let the owner grant admin privileges !(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1); +export const validateBulkParentalControlFields = (body: { + maxMovieRating?: string; + maxTvRating?: string; + blockUnrated?: boolean; +}): string | null => { + if ( + body.maxMovieRating && + !MOVIE_RATINGS.includes(body.maxMovieRating as MovieRating) + ) { + return `Invalid movie rating: ${body.maxMovieRating}`; + } + if ( + body.maxTvRating && + !TV_RATINGS.includes(body.maxTvRating as TvRating) + ) { + return `Invalid TV rating: ${body.maxTvRating}`; + } + if ( + body.blockUnrated !== undefined && + typeof body.blockUnrated !== 'boolean' + ) { + return 'blockUnrated must be a boolean.'; + } + return null; +}; + router.put< Record, Partial[], - { ids: string[]; permissions: number } + { + ids: string[]; + permissions: number; + maxMovieRating?: string; + maxTvRating?: string; + blockUnrated?: boolean; + } >('/', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { try { const isOwner = req.user?.id === 1; @@ -525,6 +560,11 @@ router.put< }); } + const validationError = validateBulkParentalControlFields(req.body); + if (validationError) { + return next({ status: 400, message: validationError }); + } + const userRepository = getRepository(User); const users: User[] = await userRepository.find({ @@ -535,12 +575,38 @@ router.put< }, }); + // Only write parental-control fields that were explicitly provided. + const hasParentalControlFields = + req.body.maxMovieRating !== undefined || + req.body.maxTvRating !== undefined || + req.body.blockUnrated !== undefined; + const updatedUsers = await Promise.all( users.map(async (user) => { - return userRepository.save({ - ...user, - ...{ permissions: req.body.permissions }, - }); + user.permissions = req.body.permissions; + + // Skip the owner and other MANAGE_USERS holders, same as the + // single-user parental-controls endpoint. + if ( + hasParentalControlFields && + user.id !== 1 && + !hasPermission(Permission.MANAGE_USERS, user.permissions) + ) { + if (!user.settings) { + user.settings = new UserSettings({ user }); + } + if (req.body.maxMovieRating !== undefined) { + user.settings.maxMovieRating = req.body.maxMovieRating || null; + } + if (req.body.maxTvRating !== undefined) { + user.settings.maxTvRating = req.body.maxTvRating || null; + } + if (req.body.blockUnrated !== undefined) { + user.settings.blockUnrated = req.body.blockUnrated; + } + } + + return userRepository.save(user); }) ); diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index c724109634..1093bc478c 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,5 +1,7 @@ import JellyfinAPI from '@server/api/jellyfin'; import PlexTvAPI from '@server/api/plextv'; +import type { MovieRating, TvRating } from '@server/constants/contentRatings'; +import { MOVIE_RATINGS, TV_RATINGS } from '@server/constants/contentRatings'; import { ApiErrorCode } from '@server/constants/error'; import { MediaServerType } from '@server/constants/server'; import { UserType } from '@server/constants/user'; @@ -9,6 +11,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'; @@ -778,4 +781,123 @@ userSettingsRoutes.post< } ); +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, + }); + } 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.' }); + } + + if (user.id === 1) { + return next({ + status: 403, + message: + 'Cannot set parental controls for the primary administrator.', + }); + } + + if (user.hasPermission(Permission.MANAGE_USERS)) { + return next({ + status: 403, + message: + 'Cannot set parental controls for users with admin permissions.', + }); + } + + if ( + req.body.maxMovieRating && + !MOVIE_RATINGS.includes(req.body.maxMovieRating as MovieRating) + ) { + return next({ + status: 400, + message: `Invalid movie rating: ${req.body.maxMovieRating}`, + }); + } + if ( + req.body.maxTvRating && + !TV_RATINGS.includes(req.body.maxTvRating as TvRating) + ) { + return next({ + status: 400, + message: `Invalid TV rating: ${req.body.maxTvRating}`, + }); + } + if ( + req.body.blockUnrated !== undefined && + typeof req.body.blockUnrated !== 'boolean' + ) { + return next({ + status: 400, + message: 'blockUnrated must be a boolean.', + }); + } + + // Falsy → null to clear the column (TypeORM ignores undefined). + const movieRating = req.body.maxMovieRating || null; + const tvRating = req.body.maxTvRating || null; + + if (!user.settings) { + user.settings = new UserSettings({ + user: user, + maxMovieRating: movieRating, + maxTvRating: tvRating, + blockUnrated: req.body.blockUnrated ?? false, + }); + } else { + user.settings.maxMovieRating = movieRating; + user.settings.maxTvRating = tvRating; + user.settings.blockUnrated = req.body.blockUnrated ?? false; + } + + await userRepository.save(user); + + return res.status(200).json({ + maxMovieRating: user.settings.maxMovieRating ?? undefined, + maxTvRating: user.settings.maxTvRating ?? undefined, + blockUnrated: user.settings.blockUnrated ?? false, + }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + export default userSettingsRoutes; diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 80aded586e..6b48f2c9ff 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -182,7 +182,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { } if (!data) { - return ; + const statusCode = error?.response?.status === 403 ? 403 : 404; + return ; } const showAllStudios = data.productionCompanies.length <= minStudios + 1; diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index a265627a4b..1c27d4fcda 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -185,7 +185,8 @@ const TvDetails = ({ tv }: TvDetailsProps) => { } if (!data) { - return ; + const statusCode = error?.response?.status === 403 ? 403 : 404; + return ; } const mediaLinks: PlayButtonLink[] = []; diff --git a/src/components/UserList/BulkEditModal.tsx b/src/components/UserList/BulkEditModal.tsx index afbf60223f..2429e5e1bc 100644 --- a/src/components/UserList/BulkEditModal.tsx +++ b/src/components/UserList/BulkEditModal.tsx @@ -5,9 +5,15 @@ import type { User } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { + getMovieRatingOptions, + getTvRatingOptions, +} from '@server/constants/contentRatings'; +import type { UserSettingsParentalControlsResponse } from '@server/interfaces/api/userSettingsInterfaces'; import { hasPermission } from '@server/lib/permissions'; +import type { AxiosResponse } from 'axios'; import axios from 'axios'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; interface BulkEditProps { @@ -22,8 +28,24 @@ 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', + blockunrated: 'Block Unrated Content', + norestriction: 'No Restriction', + maxtvratingUnratedWarning: + 'Setting a TV rating limit hides shows that have no US TV rating. Most popular shows are rated; much of the wider catalog is not.', + variesacrossusers: 'Varies across selected users', }); +type ParentalControlField = 'maxMovieRating' | 'maxTvRating' | 'blockUnrated'; + +function commonValue(values: T[]): T | undefined { + if (values.length === 0) return undefined; + const first = values[0]; + return values.every((v) => v === first) ? first : undefined; +} + const BulkEditModal = ({ selectedUserIds, users, @@ -37,18 +59,132 @@ const BulkEditModal = ({ const [currentPermission, setCurrentPermission] = useState(0); const [isSaving, setIsSaving] = useState(false); + const [currentMaxMovieRating, setCurrentMaxMovieRating] = useState(''); + const [currentMaxTvRating, setCurrentMaxTvRating] = useState(''); + const [currentBlockUnrated, setCurrentBlockUnrated] = useState(false); + + const [mixedMovieRating, setMixedMovieRating] = useState(false); + const [mixedTvRating, setMixedTvRating] = useState(false); + const [mixedBlockUnrated, setMixedBlockUnrated] = useState(false); + + const [touchedFields, setTouchedFields] = useState>( + new Set() + ); + // Read when the fetch resolves so an edit made while it was in flight + // is not overwritten with the server value. + const touchedFieldsRef = useRef>(new Set()); + + const [isLoadingParentalControls, setIsLoadingParentalControls] = + useState(true); + + const blockUnratedRef = useRef(null); + + const markTouched = (field: ParentalControlField) => { + setTouchedFields((prev) => { + const next = new Set(prev).add(field); + touchedFieldsRef.current = next; + return next; + }); + }; + useEffect(() => { if (onSaving) { onSaving(isSaving); } }, [isSaving, onSaving]); + const fetchParentalControls = useCallback(async () => { + if (!selectedUserIds.length) { + setIsLoadingParentalControls(false); + return; + } + + setIsLoadingParentalControls(true); + setMixedMovieRating(false); + setMixedTvRating(false); + setMixedBlockUnrated(false); + setTouchedFields(new Set()); + touchedFieldsRef.current = new Set(); + + try { + const results = await Promise.allSettled( + selectedUserIds.map((id) => + axios.get( + `/api/v1/user/${id}/settings/parental-controls` + ) + ) + ); + + const settings = results + .filter( + ( + r + ): r is PromiseFulfilledResult< + AxiosResponse + > => r.status === 'fulfilled' + ) + .map((r) => r.value.data); + + if (settings.length === 0) return; + + const movieRatings = settings.map((s) => s.maxMovieRating ?? ''); + const tvRatings = settings.map((s) => s.maxTvRating ?? ''); + const blockUnrateds = settings.map((s) => s.blockUnrated ?? false); + + const commonMovie = commonValue(movieRatings); + const commonTv = commonValue(tvRatings); + const commonUnrated = commonValue(blockUnrateds); + + if (commonMovie === undefined) { + setMixedMovieRating(true); + } else if (!touchedFieldsRef.current.has('maxMovieRating')) { + setCurrentMaxMovieRating(commonMovie); + } + + if (commonTv === undefined) { + setMixedTvRating(true); + } else if (!touchedFieldsRef.current.has('maxTvRating')) { + setCurrentMaxTvRating(commonTv); + } + + if (commonUnrated === undefined) { + setMixedBlockUnrated(true); + } else if (!touchedFieldsRef.current.has('blockUnrated')) { + setCurrentBlockUnrated(commonUnrated); + } + } catch { + // Controls start at their defaults + } finally { + setIsLoadingParentalControls(false); + } + }, [selectedUserIds]); + + useEffect(() => { + fetchParentalControls(); + }, [fetchParentalControls]); + + useEffect(() => { + if (blockUnratedRef.current) { + blockUnratedRef.current.indeterminate = + mixedBlockUnrated && !touchedFields.has('blockUnrated'); + } + }, [mixedBlockUnrated, touchedFields]); + const updateUsers = async () => { try { setIsSaving(true); const { data: updated } = await axios.put(`/api/v1/user`, { ids: selectedUserIds, permissions: currentPermission, + ...(touchedFields.has('maxMovieRating') + ? { maxMovieRating: currentMaxMovieRating } + : {}), + ...(touchedFields.has('maxTvRating') + ? { maxTvRating: currentMaxTvRating } + : {}), + ...(touchedFields.has('blockUnrated') + ? { blockUnrated: currentBlockUnrated } + : {}), }); if (onComplete) { onComplete(updated); @@ -87,6 +223,12 @@ const BulkEditModal = ({ } }, [users, selectedUserIds]); + const showMixedMovieHint = + mixedMovieRating && !touchedFields.has('maxMovieRating'); + const showMixedTvHint = mixedTvRating && !touchedFields.has('maxTvRating'); + const showMixedUnratedHint = + mixedBlockUnrated && !touchedFields.has('blockUnrated'); + return ( setCurrentPermission(newPermission)} /> + {hasPermission( + Permission.MANAGE_USERS, + currentUser?.permissions ?? 0 + ) && ( +
+

+ {intl.formatMessage(messages.contentfiltering)} + {isLoadingParentalControls && ( + + {intl.formatMessage(globalMessages.loading)} + + )} +

+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ {intl.formatMessage(messages.maxtvratingUnratedWarning)} +
+
+
+
+ +
+ { + markTouched('blockUnrated'); + setCurrentBlockUnrated(e.target.checked); + }} + /> + {showMixedUnratedHint && ( + + {intl.formatMessage(messages.variesacrossusers)} + + )} +
+
+
+ )}
); }; diff --git a/src/components/UserProfile/UserSettings/UserParentalControlsSettings/index.tsx b/src/components/UserProfile/UserSettings/UserParentalControlsSettings/index.tsx new file mode 100644 index 0000000000..6c01a4e468 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserParentalControlsSettings/index.tsx @@ -0,0 +1,235 @@ +import Alert from '@app/components/Common/Alert'; +import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import useToasts from '@app/hooks/useToasts'; +import { Permission, 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 { + getMovieRatingOptions, + getTvRatingOptions, +} from '@server/constants/contentRatings'; +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 useSWR from 'swr'; + +const messages = defineMessages( + 'components.UserProfile.UserSettings.UserParentalControlsSettings', + { + parentalcontrols: 'Parental Controls', + parentalcontrolssettings: 'Content Rating Limits', + parentalcontrolsdescription: + 'Set maximum content ratings and rating restrictions for this user. Limits use the US rating systems: MPAA for movies and TV Parental Guidelines for TV shows.', + maxmovierating: 'Max Movie Rating', + maxmovieratingTip: + 'Movies above this rating will be hidden from this user (US MPAA ratings)', + maxtvrating: 'Max TV Rating', + maxtvratingTip: + 'TV shows above this rating will be hidden from this user (US TV Parental Guidelines)', + maxtvratingUnratedWarning: + 'Setting a TV rating limit hides shows that have no US TV rating. Most popular shows are rated; much of the wider catalog is not.', + norestriction: 'No Restriction', + blockunrated: 'Block Unrated Content', + blockunratedTip: + 'Block content that has no rating (NR, Unrated). When disabled, unrated content is allowed through.', + toastSettingsSuccess: 'Parental control settings saved successfully!', + toastSettingsFailure: 'Something went wrong while saving settings.', + unauthorizedDescription: + 'You do not have permission to modify parental controls for this user.', + } +); + +const UserParentalControlsSettings = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const router = useRouter(); + const { user, hasPermission } = 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 ; + } + + if (user?.id === 1 || hasPermission(Permission.MANAGE_USERS)) { + return ( + <> +
+

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

+
+ + + ); + } + + 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, + } + ); + + addToast(intl.formatMessage(messages.toastSettingsSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + addToast( + e?.response?.data?.message ?? + intl.formatMessage(messages.toastSettingsFailure), + { + autoDismiss: true, + appearance: 'error', + } + ); + } finally { + revalidate(); + } + }} + > + {({ isSubmitting, isValid }) => { + return ( +
+
+ +
+
+ + + {getMovieRatingOptions().map((rating) => ( + + ))} + +
+
+
+
+ +
+
+ + + {getTvRatingOptions().map((rating) => ( + + ))} + +
+
+ {intl.formatMessage(messages.maxtvratingUnratedWarning)} +
+
+
+
+ +
+ +
+
+
+
+ + + +
+
+
+ ); + }} +
+ + ); +}; + +export default UserParentalControlsSettings; diff --git a/src/components/UserProfile/UserSettings/index.tsx b/src/components/UserProfile/UserSettings/index.tsx index 8df54eb03b..083ffc2727 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,16 @@ 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, + 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/hooks/useDiscover.ts b/src/hooks/useDiscover.ts index 53259dbcf6..750f666d4b 100644 --- a/src/hooks/useDiscover.ts +++ b/src/hooks/useDiscover.ts @@ -156,7 +156,9 @@ const useDiscover = < const isEmpty = !isLoadingInitialData && titles?.length === 0; const isReachingEnd = isEmpty || - (!!data && (data[data?.length - 1]?.results.length ?? 0) < 20) || + (!!data && + (data[data?.length - 1]?.page ?? 0) >= + (data[data?.length - 1]?.totalPages ?? 0)) || (!!data && (data[data?.length - 1]?.totalResults ?? 0) <= size * 20) || (!!data && (data[data?.length - 1]?.totalResults ?? 0) < 41); diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index fb274705bf..819ac5e52e 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1410,7 +1410,9 @@ "components.UserList.ascending": "ascending", "components.UserList.autogeneratepassword": "Automatically Generate Password", "components.UserList.autogeneratepasswordTip": "Email a server-generated password to the user", + "components.UserList.blockunrated": "Block Unrated Content", "components.UserList.bulkedit": "Bulk Edit", + "components.UserList.contentfiltering": "Content Filtering", "components.UserList.create": "Create", "components.UserList.created": "Joined", "components.UserList.createlocaluser": "Create Local User", @@ -1433,10 +1435,14 @@ "components.UserList.importfromplexsynced": "Plex users synced successfully. Existing users were refreshed.", "components.UserList.localLoginDisabled": "The Enable Local Sign-In setting is currently disabled.", "components.UserList.localuser": "Local User", + "components.UserList.maxmovierating": "Max Movie Rating", + "components.UserList.maxtvrating": "Max TV Rating", + "components.UserList.maxtvratingUnratedWarning": "Setting a TV rating limit hides shows that have no US TV rating. Most popular shows are rated; much of the wider catalog is not.", "components.UserList.mediaServerUser": "{mediaServerName} User", "components.UserList.newJellyfinsigninenabled": "The Enable New {mediaServerName} Sign-In setting is currently enabled. {mediaServerName} users with library access do not need to be imported in order to sign in.", "components.UserList.newplexsigninenabled": "The Enable New Plex Sign-In setting is currently enabled. Plex users with library access do not need to be imported in order to sign in.", "components.UserList.noJellyfinuserstoimport": "There are no {mediaServerName} users to import.", + "components.UserList.norestriction": "No Restriction", "components.UserList.nouserstoimport": "There are no Plex users to import.", "components.UserList.owner": "Owner", "components.UserList.password": "Password", @@ -1468,6 +1474,7 @@ "components.UserList.validationEmail": "Email required", "components.UserList.validationUsername": "You must provide an username", "components.UserList.validationpasswordminchars": "Password is too short; should be a minimum of 8 characters", + "components.UserList.variesacrossusers": "Varies across selected users", "components.UserProfile.ProfileHeader.joindate": "Joined {joindate}", "components.UserProfile.ProfileHeader.profile": "View Profile", "components.UserProfile.ProfileHeader.settings": "Edit Settings", @@ -1597,6 +1604,20 @@ "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 (US 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 (US TV Parental Guidelines)", + "components.UserProfile.UserSettings.UserParentalControlsSettings.maxtvratingUnratedWarning": "Setting a TV rating limit hides shows that have no US TV rating. Most popular shows are rated; much of the wider catalog is not.", + "components.UserProfile.UserSettings.UserParentalControlsSettings.norestriction": "No Restriction", + "components.UserProfile.UserSettings.UserParentalControlsSettings.parentalcontrols": "Parental Controls", + "components.UserProfile.UserSettings.UserParentalControlsSettings.parentalcontrolsdescription": "Set maximum content ratings and rating restrictions for this user. Limits use the US rating systems: MPAA for movies and TV Parental Guidelines for TV shows.", + "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.UserParentalControlsSettings.unauthorizedDescription": "You do not have permission to modify parental controls for this user.", "components.UserProfile.UserSettings.UserPasswordChange.confirmpassword": "Confirm Password", "components.UserProfile.UserSettings.UserPasswordChange.currentpassword": "Current Password", "components.UserProfile.UserSettings.UserPasswordChange.localPasswordDescription": "This password is used for signing in with the {applicationTitle} local login form. It is separate from your media server password.", @@ -1621,6 +1642,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.", @@ -1706,6 +1728,7 @@ "i18n.usersettings": "User Settings", "i18n.view": "View", "pages.errormessagewithcode": "{statusCode} - {error}", + "pages.forbidden": "Forbidden", "pages.internalservererror": "Internal Server Error", "pages.oops": "Oops", "pages.pagenotfound": "Page Not Found", diff --git a/src/pages/_error.tsx b/src/pages/_error.tsx index 4057eca0c1..e0189c3cc3 100644 --- a/src/pages/_error.tsx +++ b/src/pages/_error.tsx @@ -12,6 +12,7 @@ interface ErrorProps { const messages = defineMessages('pages', { errormessagewithcode: '{statusCode} - {error}', + forbidden: 'Forbidden', internalservererror: 'Internal Server Error', serviceunavailable: 'Service Unavailable', somethingwentwrong: 'Something Went Wrong', @@ -24,6 +25,8 @@ const ErrorPage: NextPage = ({ statusCode }) => { const getErrorMessage = (statusCode?: number) => { switch (statusCode) { + case 403: + return intl.formatMessage(messages.forbidden); case 500: return intl.formatMessage(messages.internalservererror); case 503: diff --git a/src/pages/movie/[movieId]/index.tsx b/src/pages/movie/[movieId]/index.tsx index 24fd3a826f..53ca996747 100644 --- a/src/pages/movie/[movieId]/index.tsx +++ b/src/pages/movie/[movieId]/index.tsx @@ -15,20 +15,28 @@ const MoviePage: NextPage = ({ movie }) => { export const getServerSideProps: GetServerSideProps = async ( ctx ) => { - const response = await axios.get( - `http://${getHostAndPort()}/api/v1/movie/${ctx.query.movieId}`, - { - headers: ctx.req?.headers?.cookie - ? { cookie: ctx.req.headers.cookie } - : undefined, - } - ); + try { + const response = await axios.get( + `http://${getHostAndPort()}/api/v1/movie/${ctx.query.movieId}`, + { + headers: ctx.req?.headers?.cookie + ? { cookie: ctx.req.headers.cookie } + : undefined, + } + ); - return { - props: { - movie: response.data, - }, - }; + return { + props: { + movie: response.data, + }, + }; + } catch (e) { + if (axios.isAxiosError(e) && e.response?.status === 403) { + ctx.res.statusCode = 403; + return { props: {} }; + } + throw e; + } }; export default MoviePage; diff --git a/src/pages/tv/[tvId]/index.tsx b/src/pages/tv/[tvId]/index.tsx index 599a7985e0..71d8f30ccd 100644 --- a/src/pages/tv/[tvId]/index.tsx +++ b/src/pages/tv/[tvId]/index.tsx @@ -15,20 +15,28 @@ const TvPage: NextPage = ({ tv }) => { export const getServerSideProps: GetServerSideProps = async ( ctx ) => { - const response = await axios.get( - `http://${getHostAndPort()}/api/v1/tv/${ctx.query.tvId}`, - { - headers: ctx.req?.headers?.cookie - ? { cookie: ctx.req.headers.cookie } - : undefined, - } - ); + try { + const response = await axios.get( + `http://${getHostAndPort()}/api/v1/tv/${ctx.query.tvId}`, + { + headers: ctx.req?.headers?.cookie + ? { cookie: ctx.req.headers.cookie } + : undefined, + } + ); - return { - props: { - tv: response.data, - }, - }; + return { + props: { + tv: response.data, + }, + }; + } catch (e) { + if (axios.isAxiosError(e) && e.response?.status === 403) { + ctx.res.statusCode = 403; + return { props: {} }; + } + throw e; + } }; export default TvPage; 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;