From bf8f0eecbd38d65e7144ead79f80c3a4d3d235be Mon Sep 17 00:00:00 2001 From: Derrick Date: Sun, 4 Jan 2026 03:29:14 -0600 Subject: [PATCH 1/3] feat(content-filtering): add per-user content rating filters Adds support for per-user content rating restrictions across all discovery endpoints. Admins can now configure maximum allowed movie and TV ratings for each user, with unrated content handled via an NR option. The filtering system prioritizes US certifications and falls back to the most restrictive international rating when needed. Includes database migration for new user settings fields, async TMDB certification lookup, and admin UI controls with bulk edit support. --- .gitignore | 1 + server/entity/UserSettings.ts | 6 + .../interfaces/api/userSettingsInterfaces.ts | 2 + .../1736000000000-AddContentRatingFilters.ts | 35 ++ server/routes/collection.ts | 13 +- server/routes/discover.ts | 65 +++- server/routes/media.ts | 53 ++- server/routes/movie.ts | 19 +- server/routes/person.ts | 79 ++++- server/routes/search.ts | 24 +- server/routes/tv.ts | 13 +- server/routes/user/index.ts | 31 +- server/routes/user/usersettings.ts | 48 ++- server/utils/contentFiltering.ts | 314 ++++++++++++++++++ src/components/UserList/BulkEditModal.tsx | 65 ++++ .../UserSettings/UserPermissions/index.tsx | 79 ++++- src/hooks/useUser.ts | 2 + 17 files changed, 788 insertions(+), 61 deletions(-) create mode 100644 server/migration/sqlite/1736000000000-AddContentRatingFilters.ts create mode 100644 server/utils/contentFiltering.ts diff --git a/.gitignore b/.gitignore index d294bc0914..7526f6426f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ yarn-error.log* config/db/*.sqlite3* config/settings.json config/settings.old.json +config-test/ # logs config/logs/*.log* diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 82671fe3b3..363be90ccc 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -72,6 +72,12 @@ export class UserSettings { @Column({ nullable: true }) public watchlistSyncTv?: boolean; + @Column({ nullable: true }) + public maxMovieRating?: string; + + @Column({ nullable: true }) + public maxTvRating?: string; + @Column({ type: 'text', nullable: true, diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 327764618e..dd2bae28d6 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -18,6 +18,8 @@ export interface UserSettingsGeneralResponse { globalTvQuotaDays?: number; watchlistSyncMovies?: boolean; watchlistSyncTv?: boolean; + maxMovieRating?: string; + maxTvRating?: string; } export type NotificationAgentTypes = Record; diff --git a/server/migration/sqlite/1736000000000-AddContentRatingFilters.ts b/server/migration/sqlite/1736000000000-AddContentRatingFilters.ts new file mode 100644 index 0000000000..b054f5773f --- /dev/null +++ b/server/migration/sqlite/1736000000000-AddContentRatingFilters.ts @@ -0,0 +1,35 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddContentRatingFilters1736000000000 + implements MigrationInterface +{ + name = 'AddContentRatingFilters1736000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Add maxMovieRating and maxTvRating columns to user_settings + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, "discoverRegion" varchar, "streamingRegion" varchar, "telegramMessageThreadId" varchar, "maxMovieRating" varchar, "maxTvRating" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" 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", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound", "discoverRegion", "streamingRegion", "telegramMessageThreadId") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound", "discoverRegion", "streamingRegion", "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 { + // Remove maxMovieRating and maxTvRating columns + 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, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, "discoverRegion" varchar, "streamingRegion" varchar, "telegramMessageThreadId" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" 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", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound", "discoverRegion", "streamingRegion", "telegramMessageThreadId") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound", "discoverRegion", "streamingRegion", "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 8b1cd9efb5..a3d03e3b46 100644 --- a/server/routes/collection.ts +++ b/server/routes/collection.ts @@ -2,6 +2,7 @@ import TheMovieDb from '@server/api/themoviedb'; import Media from '@server/entity/Media'; import logger from '@server/logger'; import { mapCollection } from '@server/models/Collection'; +import { filterMoviesByRating } from '@server/utils/contentFiltering'; import { Router } from 'express'; const collectionRoutes = Router(); @@ -20,7 +21,17 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => { collection.parts.map((part) => part.id) ); - return res.status(200).json(mapCollection(collection, media)); + // Filter collection parts based on content rating + const filteredParts = await filterMoviesByRating( + collection.parts, + req.user + ); + const filteredCollection = { + ...collection, + parts: filteredParts, + }; + + return res.status(200).json(mapCollection(filteredCollection, 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 c6dab52a6a..de54635019 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -21,6 +21,10 @@ import { mapTvResult, } from '@server/models/Search'; import { mapNetwork } from '@server/models/Tv'; +import { + filterMoviesByRating, + filterTvByRating, +} from '@server/utils/contentFiltering'; import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; import { sortBy } from 'lodash'; @@ -127,6 +131,9 @@ discoverRoutes.get('/movies', async (req, res, next) => { data.results.map((result) => result.id) ); + // Apply content rating filters + const filteredResults = await filterMoviesByRating(data.results, req.user); + let keywordData: TmdbKeyword[] = []; if (keywords) { const splitKeywords = keywords.split(','); @@ -145,9 +152,9 @@ discoverRoutes.get('/movies', async (req, res, next) => { return res.status(200).json({ page: data.page, totalPages: data.total_pages, - totalResults: data.total_results, + totalResults: filteredResults.length, keywords: keywordData, - results: data.results.map((result) => + results: filteredResults.map((result) => mapMovieResult( result, media.find( @@ -297,9 +304,14 @@ discoverRoutes.get<{ studioId: string }>( studio: req.params.studioId as string, }); + const filteredResults = await filterMoviesByRating( + data.results, + req.user + ); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); return res.status(200).json({ @@ -307,7 +319,7 @@ discoverRoutes.get<{ studioId: string }>( totalPages: data.total_pages, totalResults: data.total_results, studio: mapProductionCompany(studio), - results: data.results.map((result) => + results: filteredResults.map((result) => mapMovieResult( result, media.find( @@ -352,11 +364,13 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => { data.results.map((result) => result.id) ); + const filteredResults = await filterMoviesByRating(data.results, req.user); + return res.status(200).json({ page: data.page, totalPages: data.total_pages, - totalResults: data.total_results, - results: data.results.map((result) => + totalResults: filteredResults.length, + results: filteredResults.map((result) => mapMovieResult( result, media.find( @@ -420,6 +434,9 @@ discoverRoutes.get('/tv', async (req, res, next) => { data.results.map((result) => result.id) ); + // Apply content rating filters + const filteredResults = await filterTvByRating(data.results, req.user); + let keywordData: TmdbKeyword[] = []; if (keywords) { const splitKeywords = keywords.split(','); @@ -438,9 +455,9 @@ discoverRoutes.get('/tv', async (req, res, next) => { return res.status(200).json({ page: data.page, totalPages: data.total_pages, - totalResults: data.total_results, + totalResults: filteredResults.length, keywords: keywordData, - results: data.results.map((result) => + results: filteredResults.map((result) => mapTvResult( result, media.find( @@ -589,9 +606,11 @@ discoverRoutes.get<{ networkId: string }>( network: Number(req.params.networkId), }); + const filteredResults = await filterTvByRating(data.results, req.user); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); return res.status(200).json({ @@ -599,7 +618,7 @@ discoverRoutes.get<{ networkId: string }>( totalPages: data.total_pages, totalResults: data.total_results, network: mapNetwork(network), - results: data.results.map((result) => + results: filteredResults.map((result) => mapTvResult( result, media.find( @@ -644,11 +663,13 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => { data.results.map((result) => result.id) ); + const filteredResults = await filterTvByRating(data.results, req.user); + return res.status(200).json({ page: data.page, totalPages: data.total_pages, - totalResults: data.total_results, - results: data.results.map((result) => + totalResults: filteredResults.length, + results: filteredResults.map((result) => mapTvResult( result, media.find( @@ -683,11 +704,27 @@ discoverRoutes.get('/trending', async (req, res, next) => { data.results.map((result) => result.id) ); + // Filter results based on media type + const filteredResults = []; + for (const result of data.results) { + if (isMovie(result)) { + const filtered = await filterMoviesByRating([result], req.user); + if (filtered.length > 0) filteredResults.push(result); + } else if (!isPerson(result) && !isCollection(result)) { + // It's a TV show + const filtered = await filterTvByRating([result], req.user); + if (filtered.length > 0) filteredResults.push(result); + } else { + // Keep persons and collections + filteredResults.push(result); + } + } + return res.status(200).json({ page: data.page, totalPages: data.total_pages, - totalResults: data.total_results, - results: data.results.map((result) => + totalResults: filteredResults.length, + results: filteredResults.map((result) => isMovie(result) ? mapMovieResult( result, diff --git a/server/routes/media.ts b/server/routes/media.ts index 8f52efae86..145d31f714 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -2,6 +2,10 @@ import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import TautulliAPI from '@server/api/tautulli'; import TheMovieDb from '@server/api/themoviedb'; +import type { + TmdbMovieResult, + TmdbTvResult, +} from '@server/api/themoviedb/interfaces'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; @@ -15,6 +19,10 @@ import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; +import { + filterMoviesByRating, + filterTvByRating, +} from '@server/utils/contentFiltering'; import { Router } from 'express'; import type { FindOneOptions } from 'typeorm'; import { In } from 'typeorm'; @@ -69,7 +77,7 @@ mediaRoutes.get('/', async (req, res, next) => { } try { - const [media, mediaCount] = await mediaRepository.findAndCount({ + const [media] = await mediaRepository.findAndCount({ order: sortFilter, where: statusFilter && { status: statusFilter, @@ -77,14 +85,51 @@ mediaRoutes.get('/', async (req, res, next) => { take: pageSize, skip, }); + + // Filter media based on content ratings + let filteredMedia = media; + if (req.user?.settings?.maxMovieRating || req.user?.settings?.maxTvRating) { + const filtered: Media[] = []; + + for (const item of media) { + if (item.mediaType === MediaType.MOVIE) { + const mockResult = { + id: item.tmdbId, + adult: false, + media_type: item.mediaType, + } as Partial & { id: number }; + const allowed = await filterMoviesByRating( + [mockResult as TmdbMovieResult], + req.user + ); + if (allowed.length > 0) filtered.push(item); + } else if (item.mediaType === MediaType.TV) { + const mockResult = { + id: item.tmdbId, + adult: false, + media_type: item.mediaType, + } as Partial & { id: number }; + const allowed = await filterTvByRating( + [mockResult as TmdbTvResult], + req.user + ); + if (allowed.length > 0) filtered.push(item); + } else { + filtered.push(item); + } + } + + filteredMedia = filtered; + } + return res.status(200).json({ pageInfo: { - pages: Math.ceil(mediaCount / pageSize), + pages: Math.ceil(filteredMedia.length / pageSize), pageSize, - results: mediaCount, + results: filteredMedia.length, page: Math.ceil(skip / pageSize) + 1, }, - results: media, + results: filteredMedia, } as MediaResultsResponse); } catch (e) { next({ status: 500, message: e.message }); diff --git a/server/routes/movie.ts b/server/routes/movie.ts index 80b8a30058..8ad50b38f2 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -9,6 +9,7 @@ import { Watchlist } from '@server/entity/Watchlist'; import logger from '@server/logger'; import { mapMovieDetails } from '@server/models/Movie'; import { mapMovieResult } from '@server/models/Search'; +import { filterMoviesByRating } from '@server/utils/contentFiltering'; import { Router } from 'express'; const movieRoutes = Router(); @@ -70,11 +71,16 @@ movieRoutes.get('/:id/recommendations', async (req, res, next) => { results.results.map((result) => result.id) ); + const filteredResults = await filterMoviesByRating( + results.results, + req.user + ); + return res.status(200).json({ page: results.page, totalPages: results.total_pages, - totalResults: results.total_results, - results: results.results.map((result) => + totalResults: filteredResults.length, + results: filteredResults.map((result) => mapMovieResult( result, media.find( @@ -112,11 +118,16 @@ movieRoutes.get('/:id/similar', async (req, res, next) => { results.results.map((result) => result.id) ); + const filteredResults = await filterMoviesByRating( + results.results, + req.user + ); + return res.status(200).json({ page: results.page, totalPages: results.total_pages, - totalResults: results.total_results, - results: results.results.map((result) => + totalResults: filteredResults.length, + results: filteredResults.map((result) => mapMovieResult( result, media.find( diff --git a/server/routes/person.ts b/server/routes/person.ts index 7462328c0b..1ac3754701 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -1,4 +1,8 @@ import TheMovieDb from '@server/api/themoviedb'; +import type { + TmdbMovieResult, + TmdbTvResult, +} from '@server/api/themoviedb/interfaces'; import Media from '@server/entity/Media'; import logger from '@server/logger'; import { @@ -6,6 +10,10 @@ import { mapCrewCredits, mapPersonDetails, } from '@server/models/Person'; +import { + filterMoviesByRating, + filterTvByRating, +} from '@server/utils/contentFiltering'; import { Router } from 'express'; const personRoutes = Router(); @@ -51,29 +59,64 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => { combinedCredits.crew.map((result) => result.id) ); + // Filter cast and crew based on content ratings + const filteredCast = []; + for (const result of combinedCredits.cast) { + if (result.media_type === 'movie') { + const filtered = await filterMoviesByRating( + [result as TmdbMovieResult], + req.user + ); + if (filtered.length > 0) filteredCast.push(result); + } else if (result.media_type === 'tv') { + const filtered = await filterTvByRating( + [result as TmdbTvResult], + req.user + ); + if (filtered.length > 0) filteredCast.push(result); + } else { + filteredCast.push(result); + } + } + + const filteredCrew = []; + for (const result of combinedCredits.crew) { + if (result.media_type === 'movie') { + const filtered = await filterMoviesByRating( + [result as TmdbMovieResult], + req.user + ); + if (filtered.length > 0) filteredCrew.push(result); + } else if (result.media_type === 'tv') { + const filtered = await filterTvByRating( + [result as TmdbTvResult], + req.user + ); + if (filtered.length > 0) filteredCrew.push(result); + } else { + filteredCrew.push(result); + } + } + return res.status(200).json({ - cast: combinedCredits.cast - .map((result) => - mapCastCredits( - result, - castMedia.find( - (med) => - med.tmdbId === result.id && med.mediaType === result.media_type - ) + cast: filteredCast.map((result) => + mapCastCredits( + result, + castMedia.find( + (med) => + med.tmdbId === result.id && med.mediaType === result.media_type ) ) - .filter((item) => !item.adult), - crew: combinedCredits.crew - .map((result) => - mapCrewCredits( - result, - crewMedia.find( - (med) => - med.tmdbId === result.id && med.mediaType === result.media_type - ) + ), + crew: filteredCrew.map((result) => + mapCrewCredits( + result, + crewMedia.find( + (med) => + med.tmdbId === result.id && med.mediaType === result.media_type ) ) - .filter((item) => !item.adult), + ), id: combinedCredits.id, }); } catch (e) { diff --git a/server/routes/search.ts b/server/routes/search.ts index ee2fd9eb89..e46cc1a225 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -4,6 +4,11 @@ import Media from '@server/entity/Media'; import { findSearchProvider } from '@server/lib/search'; import logger from '@server/logger'; import { mapSearchResults } from '@server/models/Search'; +import { + filterMoviesByRating, + filterTvByRating, +} from '@server/utils/contentFiltering'; +import { isMovie } from '@server/utils/typeHelpers'; import { Router } from 'express'; const searchRoutes = Router(); @@ -38,11 +43,26 @@ searchRoutes.get('/', async (req, res, next) => { results.results.map((result) => result.id) ); + // Apply content filtering based on user's rating restrictions + const filteredResults = []; + for (const result of results.results) { + if (isMovie(result)) { + const filtered = await filterMoviesByRating([result], req.user); + if (filtered.length > 0) filteredResults.push(result); + } else if (result.media_type === 'tv') { + const filtered = await filterTvByRating([result], req.user); + if (filtered.length > 0) filteredResults.push(result); + } else { + // Keep persons and collections + filteredResults.push(result); + } + } + return res.status(200).json({ page: results.page, totalPages: results.total_pages, - totalResults: results.total_results, - results: mapSearchResults(results.results, media), + totalResults: filteredResults.length, + 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 f5632398e6..929a2b23a5 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -10,6 +10,7 @@ import { Watchlist } from '@server/entity/Watchlist'; import logger from '@server/logger'; import { mapTvResult } from '@server/models/Search'; import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; +import { filterTvByRating } from '@server/utils/contentFiltering'; import { Router } from 'express'; const tvRoutes = Router(); @@ -113,11 +114,13 @@ tvRoutes.get('/:id/recommendations', async (req, res, next) => { results.results.map((result) => result.id) ); + const filteredResults = await filterTvByRating(results.results, req.user); + return res.status(200).json({ page: results.page, totalPages: results.total_pages, - totalResults: results.total_results, - results: results.results.map((result) => + totalResults: filteredResults.length, + results: filteredResults.map((result) => mapTvResult( result, media.find( @@ -154,11 +157,13 @@ tvRoutes.get('/:id/similar', async (req, res, next) => { results.results.map((result) => result.id) ); + const filteredResults = await filterTvByRating(results.results, req.user); + return res.status(200).json({ page: results.page, totalPages: results.total_pages, - totalResults: results.total_results, - results: results.results.map((result) => + totalResults: filteredResults.length, + results: filteredResults.map((result) => mapTvResult( result, media.find( diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 107327bc13..31d2b7a2d9 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -9,6 +9,7 @@ import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; import { UserPushSubscription } from '@server/entity/UserPushSubscription'; +import { UserSettings } from '@server/entity/UserSettings'; import { Watchlist } from '@server/entity/Watchlist'; import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces'; import type { @@ -437,7 +438,12 @@ export const canMakePermissionsChange = ( router.put< Record, Partial[], - { ids: string[]; permissions: number } + { + ids: string[]; + permissions: number; + maxMovieRating?: string; + maxTvRating?: string; + } >('/', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { try { const isOwner = req.user?.id === 1; @@ -457,14 +463,29 @@ router.put< isOwner ? req.body.ids : req.body.ids.filter((id) => Number(id) !== 1) ), }, + relations: ['settings'], }); const updatedUsers = await Promise.all( users.map(async (user) => { - return userRepository.save({ - ...user, - ...{ permissions: req.body.permissions }, - }); + // Update permissions + user.permissions = req.body.permissions; + + // Update content ratings if provided + if (req.body.maxMovieRating !== undefined) { + if (!user.settings) { + user.settings = new UserSettings(); + } + user.settings.maxMovieRating = req.body.maxMovieRating || undefined; + } + if (req.body.maxTvRating !== undefined) { + if (!user.settings) { + user.settings = new UserSettings(); + } + user.settings.maxTvRating = req.body.maxTvRating || undefined; + } + + return userRepository.save(user); }) ); diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 50ea7c5a5f..2a88da5b4b 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -88,6 +88,8 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( globalTvQuotaLimit: defaultQuotas.tv.quotaLimit, watchlistSyncMovies: user.settings?.watchlistSyncMovies, watchlistSyncTv: user.settings?.watchlistSyncTv, + maxMovieRating: user.settings?.maxMovieRating, + maxTvRating: user.settings?.maxTvRating, }); } catch (e) { next({ status: 500, message: e.message }); @@ -154,6 +156,8 @@ userSettingsRoutes.post< originalLanguage: req.body.originalLanguage, watchlistSyncMovies: req.body.watchlistSyncMovies, watchlistSyncTv: req.body.watchlistSyncTv, + maxMovieRating: req.body.maxMovieRating, + maxTvRating: req.body.maxTvRating, }); } else { user.settings.discordId = req.body.discordId; @@ -163,6 +167,11 @@ userSettingsRoutes.post< user.settings.originalLanguage = req.body.originalLanguage; user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies; user.settings.watchlistSyncTv = req.body.watchlistSyncTv; + // Only allow MANAGE_USERS permission to update content ratings + if (req.user?.hasPermission(Permission.MANAGE_USERS)) { + user.settings.maxMovieRating = req.body.maxMovieRating; + user.settings.maxTvRating = req.body.maxTvRating; + } } const savedUser = await userRepository.save(user); @@ -177,6 +186,8 @@ userSettingsRoutes.post< watchlistSyncMovies: savedUser.settings?.watchlistSyncMovies, watchlistSyncTv: savedUser.settings?.watchlistSyncTv, email: savedUser.email, + maxMovieRating: savedUser.settings?.maxMovieRating, + maxTvRating: savedUser.settings?.maxTvRating, }); } catch (e) { if (e.errorCode) { @@ -662,7 +673,10 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( } ); -userSettingsRoutes.get<{ id: string }, { permissions?: number }>( +userSettingsRoutes.get< + { id: string }, + { permissions?: number; maxMovieRating?: string; maxTvRating?: string } +>( '/permissions', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { @@ -671,13 +685,18 @@ userSettingsRoutes.get<{ id: string }, { permissions?: number }>( try { const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, + relations: ['settings'], }); if (!user) { return next({ status: 404, message: 'User not found.' }); } - return res.status(200).json({ permissions: user.permissions }); + return res.status(200).json({ + permissions: user.permissions, + maxMovieRating: user.settings?.maxMovieRating, + maxTvRating: user.settings?.maxTvRating, + }); } catch (e) { next({ status: 500, message: e.message }); } @@ -686,8 +705,8 @@ userSettingsRoutes.get<{ id: string }, { permissions?: number }>( userSettingsRoutes.post< { id: string }, - { permissions?: number }, - { permissions: number } + { permissions?: number; maxMovieRating?: string; maxTvRating?: string }, + { permissions: number; maxMovieRating?: string; maxTvRating?: string } >( '/permissions', isAuthenticated(Permission.MANAGE_USERS), @@ -697,6 +716,7 @@ userSettingsRoutes.post< try { const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, + relations: ['settings'], }); if (!user) { @@ -719,9 +739,27 @@ userSettingsRoutes.post< } user.permissions = req.body.permissions; + // Update content ratings if provided + if (req.body.maxMovieRating !== undefined) { + if (!user.settings) { + user.settings = new UserSettings(); + } + user.settings.maxMovieRating = req.body.maxMovieRating || undefined; + } + if (req.body.maxTvRating !== undefined) { + if (!user.settings) { + user.settings = new UserSettings(); + } + user.settings.maxTvRating = req.body.maxTvRating || undefined; + } + await userRepository.save(user); - return res.status(200).json({ permissions: user.permissions }); + return res.status(200).json({ + permissions: user.permissions, + maxMovieRating: user.settings?.maxMovieRating, + maxTvRating: user.settings?.maxTvRating, + }); } catch (e) { next({ status: 500, message: e.message }); } diff --git a/server/utils/contentFiltering.ts b/server/utils/contentFiltering.ts new file mode 100644 index 0000000000..f61b38c72a --- /dev/null +++ b/server/utils/contentFiltering.ts @@ -0,0 +1,314 @@ +import TheMovieDb from '@server/api/themoviedb'; +import type { + TmdbMovieResult, + TmdbTvResult, +} from '@server/api/themoviedb/interfaces'; +import type { User } from '@server/entity/User'; +import logger from '@server/logger'; + +// Movie rating hierarchy (MPAA system) +const MOVIE_RATINGS = ['G', 'PG', 'PG-13', 'R', 'NC-17', 'NR'] as const; + +// TV rating hierarchy (US TV Parental Guidelines) +const TV_RATINGS = [ + 'TV-Y', + 'TV-Y7', + 'TV-G', + 'TV-PG', + 'TV-14', + 'TV-MA', + 'NR', +] as const; + +/** + * Fetches movie certification from TMDB API + * Prioritizes US rating, falls back to most restrictive rating from all countries + */ +async function getMovieCertification( + movieId: number +): Promise { + try { + const tmdb = new TheMovieDb(); + const details = await tmdb.getMovie({ movieId }); + + // First, try to get US certification + // Check ALL US release dates and find the most restrictive rated version + const usRelease = details.release_dates?.results?.find( + (r) => r.iso_3166_1 === 'US' + ); + const usCertifications: string[] = []; + + if (usRelease?.release_dates) { + for (const releaseDate of usRelease.release_dates) { + const cert = releaseDate.certification; + if (cert && cert !== '' && cert !== 'NR') { + // Exclude NR to avoid picking unrated director's cuts over theatrical releases + usCertifications.push(cert); + } + } + } + + // If we found US ratings, use the most restrictive one + if (usCertifications.length > 0) { + let mostRestrictive = usCertifications[0]; + let highestIndex = MOVIE_RATINGS.indexOf( + mostRestrictive as (typeof MOVIE_RATINGS)[number] + ); + + for (const cert of usCertifications) { + const index = MOVIE_RATINGS.indexOf( + cert as (typeof MOVIE_RATINGS)[number] + ); + if (index > highestIndex) { + highestIndex = index; + mostRestrictive = cert; + } + } + + logger.debug('Fetched movie certification', { + label: 'Content Filtering', + movieId, + movieTitle: details.title, + certification: mostRestrictive, + source: 'US', + allUSRatings: usCertifications.join(', '), + }); + return mostRestrictive; + } + + // If no US rating, collect all valid certifications from all countries + const allCertifications: string[] = []; + for (const release of details.release_dates?.results || []) { + for (const releaseDate of release.release_dates || []) { + const cert = releaseDate.certification; + if ( + cert && + cert !== '' && + cert !== 'NR' && + (MOVIE_RATINGS as readonly string[]).includes(cert) + ) { + // Exclude NR here too + allCertifications.push(cert); + } + } + } + + // If no certifications found anywhere, return undefined + if (allCertifications.length === 0) { + logger.debug('Fetched movie certification', { + label: 'Content Filtering', + movieId, + movieTitle: details.title, + certification: 'None', + }); + return undefined; + } + + // Find the most restrictive rating (highest in the hierarchy) + let mostRestrictive = allCertifications[0]; + let highestIndex = MOVIE_RATINGS.indexOf( + mostRestrictive as (typeof MOVIE_RATINGS)[number] + ); + + for (const cert of allCertifications) { + const index = MOVIE_RATINGS.indexOf( + cert as (typeof MOVIE_RATINGS)[number] + ); + if (index > highestIndex) { + highestIndex = index; + mostRestrictive = cert; + } + } + + logger.debug('Fetched movie certification', { + label: 'Content Filtering', + movieId, + movieTitle: details.title, + certification: mostRestrictive, + source: 'international (most restrictive)', + allRatings: allCertifications.join(', '), + }); + + return mostRestrictive; + } catch (error) { + logger.warn('Failed to fetch movie certification', { + label: 'Content Filtering', + movieId, + error: error.message, + }); + return undefined; + } +} + +/** + * Fetches TV content rating from TMDB API + */ +async function getTvCertification(tvId: number): Promise { + try { + const tmdb = new TheMovieDb(); + const details = await tmdb.getTvShow({ tvId }); + + // Get US content rating + const usRating = details.content_ratings?.results?.find( + (r) => r.iso_3166_1 === 'US' + )?.rating; + + return usRating || undefined; + } catch (error) { + logger.warn('Failed to fetch TV certification', { + label: 'Content Filtering', + tvId, + error: error.message, + }); + return undefined; + } +} + +/** + * Determines if a movie rating is allowed based on user's max rating setting + */ +function isMovieRatingAllowed( + contentRating: string | undefined, + maxRating: string | undefined +): boolean { + if (!maxRating) { + return true; // No restriction if maxRating not set + } + + if (!contentRating || contentRating === '') { + // If max rating is NR, allow unrated content + if (maxRating === 'NR') { + return true; + } + // Otherwise block unrated content when restrictions are enabled + return false; + } + + const maxIndex = MOVIE_RATINGS.indexOf( + maxRating as (typeof MOVIE_RATINGS)[number] + ); + const contentIndex = MOVIE_RATINGS.indexOf( + contentRating as (typeof MOVIE_RATINGS)[number] + ); + + // If either rating not found in our hierarchy, block it for safety + if (maxIndex === -1 || contentIndex === -1) { + return false; + } + + return contentIndex <= maxIndex; +} + +/** + * Determines if a TV rating is allowed based on user's max rating setting + */ +function isTvRatingAllowed( + contentRating: string | undefined, + maxRating: string | undefined +): boolean { + if (!maxRating) { + return true; // No restriction if maxRating not set + } + + if (!contentRating || contentRating === '') { + // If max rating is NR, allow unrated content + if (maxRating === 'NR') { + return true; + } + // Otherwise block unrated content when restrictions are enabled + return false; + } + + const maxIndex = TV_RATINGS.indexOf(maxRating as (typeof TV_RATINGS)[number]); + const contentIndex = TV_RATINGS.indexOf( + contentRating as (typeof TV_RATINGS)[number] + ); + + // If either rating not found in our hierarchy, block it for safety + if (maxIndex === -1 || contentIndex === -1) { + return false; + } + + return contentIndex <= maxIndex; +} + +/** + * Filters movie results based on user's content rating restrictions + * Fetches certification for each movie - this will be slower but accurate + */ +export async function filterMoviesByRating( + results: TmdbMovieResult[], + user?: User +): Promise { + const maxMovieRating = user?.settings?.maxMovieRating; + + // Always filter adult content + const nonAdultResults = results.filter((movie) => !movie.adult); + + if (!maxMovieRating) { + return nonAdultResults; + } + + logger.debug('Filtering movies by rating', { + label: 'Content Filtering', + maxRating: maxMovieRating, + movieCount: nonAdultResults.length, + }); + + // Fetch certifications and filter + const filtered: TmdbMovieResult[] = []; + + for (const movie of nonAdultResults) { + const certification = await getMovieCertification(movie.id); + + if (isMovieRatingAllowed(certification, maxMovieRating)) { + filtered.push(movie); + } else { + logger.debug('Blocked movie by rating', { + label: 'Content Filtering', + movieId: movie.id, + movieTitle: movie.title, + certification: certification || 'None', + maxAllowed: maxMovieRating, + }); + } + } + + logger.debug('Filtering complete', { + label: 'Content Filtering', + originalCount: nonAdultResults.length, + filteredCount: filtered.length, + }); + + return filtered; +} + +/** + * Filters TV results based on user's content rating restrictions + * Fetches content ratings for each show - this will be slower but accurate + */ +export async function filterTvByRating( + results: TmdbTvResult[], + user?: User +): Promise { + const maxTvRating = user?.settings?.maxTvRating; + + if (!maxTvRating) { + return results; + } + + // Fetch content ratings and filter + const filtered: TmdbTvResult[] = []; + + for (const show of results) { + const certification = await getTvCertification(show.id); + + if (isTvRatingAllowed(certification, maxTvRating)) { + filtered.push(show); + } + } + + return filtered; +} + +export { isMovieRatingAllowed, isTvRatingAllowed, MOVIE_RATINGS, TV_RATINGS }; diff --git a/src/components/UserList/BulkEditModal.tsx b/src/components/UserList/BulkEditModal.tsx index d5f72ab952..81407a0a40 100644 --- a/src/components/UserList/BulkEditModal.tsx +++ b/src/components/UserList/BulkEditModal.tsx @@ -35,6 +35,12 @@ const BulkEditModal = ({ const intl = useIntl(); const { addToast } = useToasts(); const [currentPermission, setCurrentPermission] = useState(0); + const [currentMaxMovieRating, setCurrentMaxMovieRating] = useState< + string | undefined + >(undefined); + const [currentMaxTvRating, setCurrentMaxTvRating] = useState< + string | undefined + >(undefined); const [isSaving, setIsSaving] = useState(false); useEffect(() => { @@ -49,6 +55,8 @@ const BulkEditModal = ({ const { data: updated } = await axios.put(`/api/v1/user`, { ids: selectedUserIds, permissions: currentPermission, + maxMovieRating: currentMaxMovieRating || '', + maxTvRating: currentMaxTvRating || '', }); if (onComplete) { onComplete(updated); @@ -84,6 +92,12 @@ const BulkEditModal = ({ if (allPermissionsEqual) { setCurrentPermission(allPermissionsEqual); } + + // Set initial content rating values from first selected user + if (selectedUsers.length > 0 && selectedUsers[0].settings) { + setCurrentMaxMovieRating(selectedUsers[0].settings.maxMovieRating); + setCurrentMaxTvRating(selectedUsers[0].settings.maxTvRating); + } } }, [users, selectedUserIds]); @@ -104,6 +118,57 @@ const BulkEditModal = ({ onUpdate={(newPermission) => setCurrentPermission(newPermission)} /> + {hasPermission( + Permission.MANAGE_USERS, + currentUser?.permissions ?? 0 + ) && ( +
+

Content Filtering

+
+ + +
+
+ + +
+
+ )} ); }; diff --git a/src/components/UserProfile/UserSettings/UserPermissions/index.tsx b/src/components/UserProfile/UserSettings/UserPermissions/index.tsx index 3398ae7eaa..d69f4da045 100644 --- a/src/components/UserProfile/UserSettings/UserPermissions/index.tsx +++ b/src/components/UserProfile/UserSettings/UserPermissions/index.tsx @@ -9,7 +9,7 @@ import ErrorPage from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; import axios from 'axios'; -import { Form, Formik } from 'formik'; +import { Field, Form, Formik } from 'formik'; import { useRouter } from 'next/router'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; @@ -22,6 +22,13 @@ const messages = defineMessages( toastSettingsFailure: 'Something went wrong while saving settings.', permissions: 'Permissions', unauthorizedDescription: 'You cannot modify your own permissions.', + contentFiltering: 'Content Filtering', + maxMovieRating: 'Maximum Movie Rating', + maxMovieRatingTip: + 'Restrict content to this rating or lower (e.g., PG-13 allows G, PG, PG-13)', + maxTvRating: 'Maximum TV Rating', + maxTvRatingTip: + 'Restrict TV content to this rating or lower (e.g., TV-PG allows TV-Y, TV-Y7, TV-G, TV-PG)', } ); @@ -37,9 +44,11 @@ const UserPermissions = () => { data, error, mutate: revalidate, - } = useSWR<{ permissions?: number }>( - user ? `/api/v1/user/${user?.id}/settings/permissions` : null - ); + } = useSWR<{ + permissions?: number; + maxMovieRating?: string; + maxTvRating?: string; + }>(user ? `/api/v1/user/${user?.id}/settings/permissions` : null); if (!data && !error) { return ; @@ -80,12 +89,16 @@ const UserPermissions = () => { { try { await axios.post(`/api/v1/user/${user?.id}/settings/permissions`, { permissions: values.currentPermissions ?? 0, + maxMovieRating: values.maxMovieRating || undefined, + maxTvRating: values.maxTvRating || undefined, }); addToast(intl.formatMessage(messages.toastSettingsSuccess), { @@ -116,6 +129,64 @@ const UserPermissions = () => { } /> +
+

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

+
+
+
+ +
+
+ + + + + + + + + +
+
+
+
+ +
+
+ + + + + + + + + + +
+
+
+
diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index d948f2de85..da0dae8d64 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -37,6 +37,8 @@ export interface UserSettings { notificationTypes: Partial; watchlistSyncMovies?: boolean; watchlistSyncTv?: boolean; + maxMovieRating?: string; + maxTvRating?: string; } interface UserHookResponse { From 10dfd155c6a8fd0d2790b4f3ef1687c21355788f Mon Sep 17 00:00:00 2001 From: Derrick Date: Sun, 4 Jan 2026 03:58:36 -0600 Subject: [PATCH 2/3] feat: extract translation keys for content rating filters --- src/i18n/locale/en.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 34963834d2..0a54f6819e 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -105,7 +105,6 @@ "components.Discover.StudioSlider.studios": "Studios", "components.Discover.TvGenreList.seriesgenres": "Series Genres", "components.Discover.TvGenreSlider.tvgenres": "Series Genres", - "components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series", "components.Discover.createnewslider": "Create New Slider", "components.Discover.customizediscover": "Customize Discover", "components.Discover.discover": "Discover", @@ -139,6 +138,7 @@ "components.Discover.upcomingtv": "Upcoming Series", "components.Discover.updatefailed": "Something went wrong updating the discover customization settings.", "components.Discover.updatesuccess": "Updated discover customization settings.", + "components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series", "components.DownloadBlock.estimatedtime": "Estimated {time}", "components.DownloadBlock.formattedTitle": "{title}: Season {seasonNumber} Episode {episodeNumber}", "components.IssueDetails.IssueComment.areyousuredelete": "Are you sure you want to delete this comment?", @@ -1256,7 +1256,7 @@ "components.Setup.librarieserror": "Validation failed. Please toggle the libraries again to continue.", "components.Setup.servertype": "Choose Server Type", "components.Setup.setup": "Setup", - "components.Setup.signin": "Sign In", + "components.Setup.signin": "Sign in to your account", "components.Setup.signinMessage": "Get started by signing in", "components.Setup.signinWithEmby": "Enter your Emby details", "components.Setup.signinWithJellyfin": "Enter your Jellyfin details", @@ -1511,6 +1511,11 @@ "components.UserProfile.UserSettings.UserPasswordChange.validationCurrentPassword": "You must provide your current password", "components.UserProfile.UserSettings.UserPasswordChange.validationNewPassword": "You must provide a new password", "components.UserProfile.UserSettings.UserPasswordChange.validationNewPasswordLength": "Password is too short; should be a minimum of 8 characters", + "components.UserProfile.UserSettings.UserPermissions.contentFiltering": "Content Filtering", + "components.UserProfile.UserSettings.UserPermissions.maxMovieRating": "Maximum Movie Rating", + "components.UserProfile.UserSettings.UserPermissions.maxMovieRatingTip": "Restrict content to this rating or lower (e.g., PG-13 allows G, PG, PG-13)", + "components.UserProfile.UserSettings.UserPermissions.maxTvRating": "Maximum TV Rating", + "components.UserProfile.UserSettings.UserPermissions.maxTvRatingTip": "Restrict TV content to this rating or lower (e.g., TV-PG allows TV-Y, TV-Y7, TV-G, TV-PG)", "components.UserProfile.UserSettings.UserPermissions.permissions": "Permissions", "components.UserProfile.UserSettings.UserPermissions.toastSettingsFailure": "Something went wrong while saving settings.", "components.UserProfile.UserSettings.UserPermissions.toastSettingsSuccess": "Permissions saved successfully!", From 2e99f4e604ffe6a33022dca023548d81daa7bcee Mon Sep 17 00:00:00 2001 From: Derrick Date: Sun, 4 Jan 2026 04:21:23 -0600 Subject: [PATCH 3/3] feat: postgresql migration --- .../1736000000000-AddContentRatingFilters.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 server/migration/postgres/1736000000000-AddContentRatingFilters.ts diff --git a/server/migration/postgres/1736000000000-AddContentRatingFilters.ts b/server/migration/postgres/1736000000000-AddContentRatingFilters.ts new file mode 100644 index 0000000000..f66e8ec03b --- /dev/null +++ b/server/migration/postgres/1736000000000-AddContentRatingFilters.ts @@ -0,0 +1,25 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddContentRatingFilters1736000000000 + implements MigrationInterface +{ + name = 'AddContentRatingFilters1736000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "maxMovieRating" character varying` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "maxTvRating" character varying` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "maxTvRating"` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "maxMovieRating"` + ); + } +}