feat: per-user parental controls (content rating limits)#2415
feat: per-user parental controls (content rating limits)#2415ProgenyAlpha wants to merge 1 commit intoseerr-team:developfrom
Conversation
d4bda48 to
f8ee51d
Compare
|
Isnt this the same as |
I wasn't aware of #2275 because it wasn't linked to any of the issues, so it didn't come up when I scoped out existing work before starting. But probably wouldn't have matter as I technically started this back in December 2025 and wanted to use it for an extensive period of time before submitting it. That said, the implementations are architecturally different. #2275 makes live TMDB API calls per-item at request time to fetch certifications, which adds latency to every discovery and search page and scales poorly with TMDB rate limits. This PR uses a static rating hierarchy; filtering is instant with zero external API overhead. A few other things this PR brings to the table:
Happy to collaborate or consolidate, the static rating approach could drop right into #2275 as a performance improvement if the maintainers prefer that path. |
|
Nice work — this PR looks cleaner and will perform better than mine, especially with the backfill. I do have a few suggestions and one gap to call out; I'd love to see a hybrid that combines your architecture with a couple of features from my branch. High-level takeaways What I'd bring from my PR Enhanced rating fallback (applies when blockUnrated=true) Detailed logging Bulk edit for parental controls Architecture note Missing coverage /movies/studio/:studioId I'm excited about this — I can't really let friends and family use the app until something like this is in place. If you want, I can open a follow-up PR that merges the rating fallback and bulk edit into this branch. |
|
Hey, wanted to follow up — after digging deeper into your branch I realize my earlier comment overstated the route coverage gap. You're actually filtering 10 out of 11 discover routes, which is great. The only one missing filtering is /trending. Apologies for the inaccuracy there. I still think there are a few things from my branch that could complement yours nicely — bulk edit (setting parental controls on multiple users at once), smarter certification lookup (excluding NR from unrated director's cuts so the theatrical rating wins, plus international fallback when there's no US rating), and an optional "block adult content" toggle. These all slot into your existing architecture without extra API overhead. Happy to put together a PR into your branch if you're interested. |
|
Hey! Sorry, saw your email but it's been a busy week so I didn't get a chance to reply. Just saw your PR, so I'll dig into your comments and review everything this weekend. Thanks for putting this together! |
f8ee51d to
4621c8d
Compare
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds per-user parental controls: DB migrations, new content-rating constants and filtering helpers, discovery/search enforcement with optional backfill, admin API endpoints to get/update settings, bulk-edit and user settings UI, a Next.js settings page, and new localization keys. Changes
Sequence Diagram(s)sequenceDiagram
participant Admin as Admin
participant API as Overseerr API
participant DB as Database
participant TMDB as TMDB
Admin->>API: PUT /user (bulk) with parental control fields
API->>API: Validate ratings (MOVIE_RATINGS/TV_RATINGS)
API->>DB: Load/create & save user.settings (maxMovieRating,maxTvRating,blockUnrated,blockAdult)
DB-->>API: Persisted settings
API-->>Admin: 200 OK
Note over API,TMDB: Per-user limits applied during discovery/search
participant User as Target User
participant Discover as Discover Route
User->>Discover: GET /discover/movies
Discover->>DB: Fetch user's content limits
DB-->>Discover: maxMovieRating, blockUnrated, blockAdult
Discover->>TMDB: TMDB search
TMDB-->>Discover: Results page
Discover->>TMDB: Fetch details/certifications for results
TMDB-->>Discover: Details with ratings
Discover->>Discover: Apply shouldFilterMovie/shouldFilterTv per item
alt Filtered count < threshold
Discover->>TMDB: Fetch next page (backfill)
TMDB-->>Discover: Additional results
Discover->>Discover: Re-apply filters
end
Discover-->>User: Filtered results
sequenceDiagram
participant Browser as Admin Browser
participant NextJS as Next.js Page
participant API as Overseerr API
participant DB as Database
Browser->>NextJS: Load /users/[id]/settings/parental-controls
NextJS->>NextJS: Check MANAGE_USERS permission
NextJS-->>Browser: Render Parental Controls page
Browser->>API: GET /api/v1/user/{id}/settings/parental-controls
API->>DB: Load user.settings
DB-->>API: Settings JSON
API-->>Browser: 200 + settings
Browser->>Browser: Formik initializes with values
Browser->>API: POST /api/v1/user/{id}/settings/parental-controls
API->>API: Validate ratings
API->>DB: Update/create user.settings
DB-->>API: Updated record
API-->>Browser: 200 + updated settings
Browser->>Browser: Show success toast & revalidate
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/components/UserList/BulkEditModal.tsx (1)
47-73:⚠️ Potential issue | 🟠 MajorAvoid unintentionally clearing parental controls during bulk edits.
Right now the modal always sends
maxMovieRating,maxTvRating,blockUnrated, andblockAdultwith default''/false, which will wipe existing restrictions even when the admin only edits permissions. Please only send these fields when the admin actually changes them (or prefill and compare).✅ Suggested fix (track “touched” fields and conditionally include in payload)
- const [currentMaxMovieRating, setCurrentMaxMovieRating] = useState< - string | undefined - >(undefined); - const [currentMaxTvRating, setCurrentMaxTvRating] = useState< - string | undefined - >(undefined); - const [currentBlockUnrated, setCurrentBlockUnrated] = useState(false); - const [currentBlockAdult, setCurrentBlockAdult] = useState(false); + const [currentMaxMovieRating, setCurrentMaxMovieRating] = useState< + string | undefined + >(undefined); + const [currentMaxTvRating, setCurrentMaxTvRating] = useState< + string | undefined + >(undefined); + const [currentBlockUnrated, setCurrentBlockUnrated] = useState(false); + const [currentBlockAdult, setCurrentBlockAdult] = useState(false); + const [maxMovieRatingTouched, setMaxMovieRatingTouched] = useState(false); + const [maxTvRatingTouched, setMaxTvRatingTouched] = useState(false); + const [blockUnratedTouched, setBlockUnratedTouched] = useState(false); + const [blockAdultTouched, setBlockAdultTouched] = useState(false); const { data: updated } = await axios.put<User[]>(`/api/v1/user`, { ids: selectedUserIds, permissions: currentPermission, - maxMovieRating: currentMaxMovieRating || '', - maxTvRating: currentMaxTvRating || '', - blockUnrated: currentBlockUnrated, - blockAdult: currentBlockAdult, + ...(maxMovieRatingTouched + ? { maxMovieRating: currentMaxMovieRating || '' } + : {}), + ...(maxTvRatingTouched ? { maxTvRating: currentMaxTvRating || '' } : {}), + ...(blockUnratedTouched ? { blockUnrated: currentBlockUnrated } : {}), + ...(blockAdultTouched ? { blockAdult: currentBlockAdult } : {}), });- onChange={(e) => - setCurrentMaxMovieRating(e.target.value || undefined) - } + onChange={(e) => { + setMaxMovieRatingTouched(true); + setCurrentMaxMovieRating(e.target.value || undefined); + }} - onChange={(e) => - setCurrentMaxTvRating(e.target.value || undefined) - } + onChange={(e) => { + setMaxTvRatingTouched(true); + setCurrentMaxTvRating(e.target.value || undefined); + }} - onChange={(e) => setCurrentBlockUnrated(e.target.checked)} + onChange={(e) => { + setBlockUnratedTouched(true); + setCurrentBlockUnrated(e.target.checked); + }} - onChange={(e) => setCurrentBlockAdult(e.target.checked)} + onChange={(e) => { + setBlockAdultTouched(true); + setCurrentBlockAdult(e.target.checked); + }}Also applies to: 128-205
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/UserList/BulkEditModal.tsx` around lines 47 - 73, The bulk-edit currently always sends maxMovieRating/maxTvRating/blockUnrated/blockAdult (defaulting to ''/false) and can unintentionally clear parental controls; change the component to track whether each parental-control field was touched (e.g., add touchedMaxMovieRating/touchedMaxTvRating/touchedBlockUnrated/touchedBlockAdult or make block states undefined initially) and update the form controls to mark those touched flags when the admin changes them; then in updateUsers build the PUT payload starting with ids: selectedUserIds and permissions: currentPermission and only attach maxMovieRating/currentMaxTvRating/currentBlockUnrated/currentBlockAdult if their corresponding touched flag is true (or value !== undefined) so unchanged fields are omitted from the request; ensure references to currentMaxMovieRating, currentMaxTvRating, currentBlockUnrated, currentBlockAdult and updateUsers are updated accordingly.server/routes/discover.ts (1)
1338-1381:⚠️ Potential issue | 🟡 MinorMissing parental controls filtering on keyword movies endpoint.
This endpoint does not apply content rating filtering, which could allow users to bypass parental controls by navigating to keyword-based movie listings.
🔒 Proposed fix to add filtering
discoverRoutes.get<{ keywordId: string }>( '/keyword/:keywordId/movies', async (req, res, next) => { const tmdb = new TheMovieDb(); + const ratingLimits = getUserContentRatingLimits(req.user); try { const data = await tmdb.getMoviesByKeyword({ keywordId: Number(req.params.keywordId), page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, }); + // Post-filter for parental controls + const filteredResults = await postFilterDiscoverMovies( + data.results, + tmdb, + ratingLimits, + undefined, + false // no pre-filter applied + ); + const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => result.id) + filteredResults.map((result) => result.id) ); return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, - results: data.results.map((result) => + results: filteredResults.map((result) => mapMovieResult( result, media.find( (med) => med.tmdbId === result.id && med.mediaType === MediaType.MOVIE ) ) ), });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/routes/discover.ts` around lines 1338 - 1381, The keyword movies handler (discoverRoutes.get -> tmdb.getMoviesByKeyword) currently returns TMDB results without applying parental controls; update the handler to filter data.results using the same parental-controls helper used elsewhere (apply before mapping and before computing results sent to the client) based on req.user (e.g., the helper used by other discover/search endpoints), then call Media.getRelatedMedia and map via mapMovieResult only for the filtered IDs (MediaType.MOVIE); ensure the response results array and counts reflect the filtered set (or explicitly document preserving TMDB counts) and keep the existing error handling.
🧹 Nitpick comments (2)
server/routes/search.ts (1)
66-125: Skip certification lookups when onlyblockAdultis active.If no rating/unrated limits are set, you can return the adult-pre-filtered list immediately and avoid extra TMDB calls.
⚡️ Suggested optimization
- const settled = await Promise.allSettled( - preFiltered.map((r) => getCertification(r, tmdb)) - ); + if (!maxMovieRating && !maxTvRating && !blockUnrated) { + return preFiltered; + } + + const settled = await Promise.allSettled( + preFiltered.map((r) => getCertification(r, tmdb)) + );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/routes/search.ts` around lines 66 - 125, The function filterSearchBatch does unnecessary TMDB certification lookups when there are no rating/unrated constraints—add an early return that returns preFiltered immediately if there are no rating limits and blockUnrated is false (i.e. when !maxMovieRating && !maxTvRating && !blockUnrated); place this check before calling Promise.allSettled so getCertification is not invoked, referencing the existing preFiltered variable and the getCertification calls inside filterSearchBatch.server/routes/discover.ts (1)
268-300: TV filtering lacks international rating fallback unlike movies.The movie filtering at lines 181-192 falls back to international ratings if no US rating is found, but the TV filtering only checks for US ratings. This inconsistency may result in more TV content being treated as unrated compared to movies.
Consider adding similar international fallback logic for TV shows if consistent behavior across media types is desired. This could help reduce false positives for international TV content when
blockUnratedis enabled.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/routes/discover.ts` around lines 268 - 300, filterTvBatch currently only uses the US rating from details.content_ratings for TV shows, so add the same international-fallback behavior used in movie filtering: when tmdb.getTvShow(...) returns details and no US rating is found, fall back to the first available rating from details.content_ratings.results (or a suitable non-US entry) to compute cert before calling shouldFilterTv; update the destructuring/assignment where you derive cert (from details.content_ratings?.results?.find(...)) to try US first then fallback to an international rating, and keep the logger.debug call (logger.debug, tvId/show.id, tvTitle/title, certification, maxRating) unchanged so blocked shows still log with the chosen certification.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/constants/contentRatings.ts`:
- Around line 1-6: The top header comment in contentRatings.ts incorrectly
states "Lower index = more restrictive" while the rating arrays are ordered from
least restrictive to most restrictive; update that comment to say something like
"Lower index = less restrictive (suitable for older audiences)" or "Lists are
ordered from least restrictive to most restrictive" so it matches the ordering
used by the rating arrays (e.g., TV and movie rating constants) in this file.
In `@server/routes/search.ts`:
- Around line 212-245: The current computation of filterRatio can exceed 1 when
backfill adds results, inflating totalPages/totalResults; change the filterRatio
calculation in this block to clamp it to a maximum of 1. Specifically, update
the filterRatio assignment (which uses originalCount and filteredCount) to use
Math.min(1, filteredCount / originalCount) when originalCount > 0 (leave it as 1
when originalCount === 0), so the totals computed after calling
filterSearchResultsByRating and Media.getRelatedMedia cannot exceed TMDB's
original totals.
In `@server/routes/user/index.ts`:
- Around line 493-524: The bulk update loop in users.map updates parental
controls without protecting admin users; before applying any parental-controls
fields (maxMovieRating, maxTvRating, blockUnrated, blockAdult) check the user's
permissions (e.g., user.permissions includes 'MANAGE_USERS' or the constant used
elsewhere) and either skip applying those settings or throw an error consistent
with usersettings.ts behavior; locate the block inside the async map where
UserSettings is created/modified and add the same guard as in usersettings.ts
(reject or no-op for users with MANAGE_USERS) before mutating settings and
calling userRepository.save(user).
---
Outside diff comments:
In `@server/routes/discover.ts`:
- Around line 1338-1381: The keyword movies handler (discoverRoutes.get ->
tmdb.getMoviesByKeyword) currently returns TMDB results without applying
parental controls; update the handler to filter data.results using the same
parental-controls helper used elsewhere (apply before mapping and before
computing results sent to the client) based on req.user (e.g., the helper used
by other discover/search endpoints), then call Media.getRelatedMedia and map via
mapMovieResult only for the filtered IDs (MediaType.MOVIE); ensure the response
results array and counts reflect the filtered set (or explicitly document
preserving TMDB counts) and keep the existing error handling.
In `@src/components/UserList/BulkEditModal.tsx`:
- Around line 47-73: The bulk-edit currently always sends
maxMovieRating/maxTvRating/blockUnrated/blockAdult (defaulting to ''/false) and
can unintentionally clear parental controls; change the component to track
whether each parental-control field was touched (e.g., add
touchedMaxMovieRating/touchedMaxTvRating/touchedBlockUnrated/touchedBlockAdult
or make block states undefined initially) and update the form controls to mark
those touched flags when the admin changes them; then in updateUsers build the
PUT payload starting with ids: selectedUserIds and permissions:
currentPermission and only attach
maxMovieRating/currentMaxTvRating/currentBlockUnrated/currentBlockAdult if their
corresponding touched flag is true (or value !== undefined) so unchanged fields
are omitted from the request; ensure references to currentMaxMovieRating,
currentMaxTvRating, currentBlockUnrated, currentBlockAdult and updateUsers are
updated accordingly.
---
Nitpick comments:
In `@server/routes/discover.ts`:
- Around line 268-300: filterTvBatch currently only uses the US rating from
details.content_ratings for TV shows, so add the same international-fallback
behavior used in movie filtering: when tmdb.getTvShow(...) returns details and
no US rating is found, fall back to the first available rating from
details.content_ratings.results (or a suitable non-US entry) to compute cert
before calling shouldFilterTv; update the destructuring/assignment where you
derive cert (from details.content_ratings?.results?.find(...)) to try US first
then fallback to an international rating, and keep the logger.debug call
(logger.debug, tvId/show.id, tvTitle/title, certification, maxRating) unchanged
so blocked shows still log with the chosen certification.
In `@server/routes/search.ts`:
- Around line 66-125: The function filterSearchBatch does unnecessary TMDB
certification lookups when there are no rating/unrated constraints—add an early
return that returns preFiltered immediately if there are no rating limits and
blockUnrated is false (i.e. when !maxMovieRating && !maxTvRating &&
!blockUnrated); place this check before calling Promise.allSettled so
getCertification is not invoked, referencing the existing preFiltered variable
and the getCertification calls inside filterSearchBatch.
Update: Merged contributions from #2275 + cleanupI've rebased onto the latest Bug fix (critical)The post-filter functions ( New features (from @mcfalld)
Cleanup
Performance noteAdded a Big thanks to @mcfalld for the thorough review, the critical bug catch, and the feature additions. The static rating hierarchy from this PR combined with his enhanced cert lookup and bulk edit makes for a much more complete solution than either PR alone. One thought: would the team be open to adding a small feature announcement (info banner or similar) so admins know parental controls are available after updating? Happy to implement whatever pattern you'd prefer, or leave it to the release notes. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (4)
server/routes/search.ts (2)
190-191: Consider validating page number to avoid unexpected behavior.
searchPagedefaults to1ifNaN, but doesn't guard against negative values or zero. While TMDB likely handles invalid page numbers gracefully, explicit validation is defensive.🛡️ Optional defensive validation
- const searchPage = Number(req.query.page) || 1; + const searchPage = Math.max(1, Number(req.query.page) || 1);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/routes/search.ts` around lines 190 - 191, The parsed searchPage from req.query.page can be NaN, zero or negative; after parsing Number(req.query.page) ensure it is a valid integer >= 1 (e.g., use Number.parseInt/Math.floor and then clamp to at least 1) before using it in the search flow so invalid values default to 1; update the logic around searchPage (the variable assigned from req.query.page) to validate and normalize the value defensively.
74-77: Verify adult filtering covers all result types with theadultproperty.The pre-filter checks
'adult' in rbut then casts toTmdbMovieResult. Per the interfaces,TmdbPersonResultandTmdbCollectionResultalso haveadultproperties. The current implementation works because'adult' in ris checked first, but the cast is misleading.♻️ Suggested clarification (optional)
- const preFiltered = blockAdult - ? results.filter((r) => !('adult' in r && (r as TmdbMovieResult).adult)) - : results; + const preFiltered = blockAdult + ? results.filter((r) => !('adult' in r && r.adult)) + : results;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/routes/search.ts` around lines 74 - 77, Pre-filtering currently checks 'adult' in r then casts to TmdbMovieResult which is misleading because TmdbPersonResult and TmdbCollectionResult also have adult; remove the cast and either use a union cast or a small type guard: replace (r as TmdbMovieResult).adult with (r as TmdbMovieResult | TmdbPersonResult | TmdbCollectionResult).adult or implement a hasAdult(r): r is { adult: boolean } type guard, and use that in the preFiltered computation (refer to variable preFiltered and the results array).src/components/UserList/BulkEditModal.tsx (1)
148-170: Consider showing a hint when selected users have different rating limits.The select dropdowns start empty, but selected users may have varying limits. While the
touchedFieldspattern prevents accidental overwrites, admins have no visibility into the current state of selected users' parental controls.For the permissions section, common values are computed and displayed. A similar approach (or at least a helper message) could improve UX.
This is a nice-to-have enhancement for future iteration, not a blocker.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/UserList/BulkEditModal.tsx` around lines 148 - 170, Add a UI hint for mixed max movie ratings similar to the permissions section by computing the common value across selected users (e.g., create a helper like computeCommonMaxMovieRating or reuse existing common-value logic) and use that to populate or annotate the select when currentMaxMovieRating is undefined and the field is not yet touched; update the select display logic around currentMaxMovieRating and the markTouched usage so that when values differ you show a helper message such as "Multiple values selected" (or the common rating if one exists) next to the select, and ensure getMovieRatingOptions() remains the option source while keeping markTouched('maxMovieRating') and setCurrentMaxMovieRating unchanged.server/routes/user/index.ts (1)
513-517: Clarify behavior: empty string clears the rating but validation logic differs.At line 464, validation skips if
maxMovieRatingis falsy (empty string). At line 514,settings.maxMovieRating = req.body.maxMovieRating || undefinedconverts empty string toundefined. This works but the coupling is implicit.The validation uses truthy check (
if (req.body.maxMovieRating && ...)) while the assignment uses|| undefined. Both handle empty string as "clear", but through different mechanisms.Consider adding a brief comment clarifying that empty string is intentionally used to clear ratings, or use explicit
=== ''checks for clarity.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/routes/user/index.ts` around lines 513 - 517, The code treats an empty string as a signal to clear ratings but does so inconsistently (validation uses a truthy check while assignment uses `|| undefined`), so make the behavior explicit: update the validation and assignment for `req.body.maxMovieRating` and `req.body.maxTvRating` to check for `''` (empty string) and `undefined` explicitly (e.g., if `req.body.maxMovieRating === ''` then set `settings.maxMovieRating = undefined`, else if `req.body.maxMovieRating !== undefined` validate and assign the provided value), and add a short comment near `settings.maxMovieRating`/`settings.maxTvRating` explaining that an empty string is intentionally used to clear the rating.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/components/UserList/BulkEditModal.tsx`:
- Around line 194-223: The blockUnrated/blockAdult checkboxes (controlled by
currentBlockUnrated/currentBlockAdult and updated via
setCurrentBlockUnrated/setCurrentBlockAdult with markTouched) default to false
and can unintentionally overwrite differing user settings; compute and set an
initial/common/indeterminate state for these controls based on the selected
users (the same logic used for permissions) or switch to a
tri-state/indeterminate UI so the checkbox reflects "mixed" values, and only
call markTouched when the admin explicitly changes the value; update the
initialization logic where currentBlockUnrated/currentBlockAdult are derived and
the onChange handlers to respect and clear indeterminate state instead of
forcing false for all users.
---
Nitpick comments:
In `@server/routes/search.ts`:
- Around line 190-191: The parsed searchPage from req.query.page can be NaN,
zero or negative; after parsing Number(req.query.page) ensure it is a valid
integer >= 1 (e.g., use Number.parseInt/Math.floor and then clamp to at least 1)
before using it in the search flow so invalid values default to 1; update the
logic around searchPage (the variable assigned from req.query.page) to validate
and normalize the value defensively.
- Around line 74-77: Pre-filtering currently checks 'adult' in r then casts to
TmdbMovieResult which is misleading because TmdbPersonResult and
TmdbCollectionResult also have adult; remove the cast and either use a union
cast or a small type guard: replace (r as TmdbMovieResult).adult with (r as
TmdbMovieResult | TmdbPersonResult | TmdbCollectionResult).adult or implement a
hasAdult(r): r is { adult: boolean } type guard, and use that in the preFiltered
computation (refer to variable preFiltered and the results array).
In `@server/routes/user/index.ts`:
- Around line 513-517: The code treats an empty string as a signal to clear
ratings but does so inconsistently (validation uses a truthy check while
assignment uses `|| undefined`), so make the behavior explicit: update the
validation and assignment for `req.body.maxMovieRating` and
`req.body.maxTvRating` to check for `''` (empty string) and `undefined`
explicitly (e.g., if `req.body.maxMovieRating === ''` then set
`settings.maxMovieRating = undefined`, else if `req.body.maxMovieRating !==
undefined` validate and assign the provided value), and add a short comment near
`settings.maxMovieRating`/`settings.maxTvRating` explaining that an empty string
is intentionally used to clear the rating.
In `@src/components/UserList/BulkEditModal.tsx`:
- Around line 148-170: Add a UI hint for mixed max movie ratings similar to the
permissions section by computing the common value across selected users (e.g.,
create a helper like computeCommonMaxMovieRating or reuse existing common-value
logic) and use that to populate or annotate the select when
currentMaxMovieRating is undefined and the field is not yet touched; update the
select display logic around currentMaxMovieRating and the markTouched usage so
that when values differ you show a helper message such as "Multiple values
selected" (or the common rating if one exists) next to the select, and ensure
getMovieRatingOptions() remains the option source while keeping
markTouched('maxMovieRating') and setCurrentMaxMovieRating unchanged.
CodeRabbit Review — All Findings AddressedRound 1 (7 findings → all fixed in
|
| # | Finding | Severity | Resolution |
|---|---|---|---|
| 1 | BulkEditModal clears parental controls on permission-only edits | 🟠 Major | Added touchedFields tracking — parental control fields are only sent in the PUT payload when the admin actually modifies them |
| 2 | Keyword movies endpoint bypasses parental controls | 🟡 Minor | Added postFilterDiscoverMovies() call to /keyword/:keywordId/movies |
| 3 | Bulk edit missing admin user protection | 🟠 Major | Added MANAGE_USERS permission check — parental controls are skipped for admin users, matching the individual endpoint behavior in usersettings.ts |
| 4 | Search: unnecessary cert lookups when only blockAdult active |
Nitpick | Added early return in filterSearchBatch — skips expensive TMDB certification API calls when no rating/unrated constraints are set |
| 5 | TV filtering lacks international rating fallback | Nitpick | Added getTvCertFromDetails() with international fallback, matching the existing movie certification lookup behavior |
| 6 | contentRatings.ts comment inversion |
🟡 Minor | Fixed header comment: "Lower index = less restrictive" |
| 7 | filterRatio exceeds 1.0 after backfill |
🟡 Minor | Clamped with Math.min(1, filteredCount / originalCount) |
Round 2 — Fixed in
|
| # | Finding | Fix |
|---|---|---|
| 1 | Checkbox indeterminate state for mixed settings | BulkEditModal now fetches parental controls for all selected users on mount. Checkboxes show indeterminate (semi-filled) state when values differ, with hint text: "Enabled for some but not all users" |
| 2 | Mixed rating value hints | Select dropdowns show "Varies across selected users" when selected users have different rating limits |
| 3 | searchPage negative/zero validation |
Clamped with Math.max(1, ...) |
| 4 | Misleading adult cast |
Replaced (r as TmdbMovieResult).adult with (r as { adult?: boolean }).adult — no longer implies only movies have the field |
| 5 | Empty string rating clearing | Added clarifying comment: // Empty string = "No Restriction" from the UI dropdown; convert to undefined for DB storage |
Build and lint both pass clean.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
server/routes/search.ts (1)
74-77: Consider simplifying the adult type guard.The type cast is correct but verbose. A simpler approach using a type guard function could improve readability.
♻️ Optional simplification
- const preFiltered = blockAdult - ? results.filter((r) => !('adult' in r && (r as { adult?: boolean }).adult)) - : results; + const preFiltered = blockAdult + ? results.filter((r) => { + if ('adult' in r) { + return !(r as TmdbMovieResult | TmdbPersonResult | TmdbCollectionResult).adult; + } + return true; + }) + : results;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/routes/search.ts` around lines 74 - 77, The filter for preFiltered is using a verbose cast; introduce a small type guard like hasAdultFlag(value): value is { adult?: boolean } and then replace the predicate with results.filter(r => !(hasAdultFlag(r) && r.adult)) so you avoid the (r as { adult?: boolean }) cast while keeping the same logic; update the local symbols preFiltered, blockAdult, results to use the new hasAdultFlag guard in search.ts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@server/routes/search.ts`:
- Around line 74-77: The filter for preFiltered is using a verbose cast;
introduce a small type guard like hasAdultFlag(value): value is { adult?:
boolean } and then replace the predicate with results.filter(r =>
!(hasAdultFlag(r) && r.adult)) so you avoid the (r as { adult?: boolean }) cast
while keeping the same logic; update the local symbols preFiltered, blockAdult,
results to use the new hasAdultFlag guard in search.ts.
Follow-up fix (
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/components/UserList/BulkEditModal.tsx`:
- Around line 92-151: Reset the "mixed" flags and touched fields whenever the
selection changes to avoid stale UI state: at the start of fetchParentalControls
(or in a useEffect watching selectedUserIds) call the setters to clear mixed
flags (setMixedMovieRating(false), setMixedTvRating(false),
setMixedBlockUnrated(false), setMixedBlockAdult(false)) and reset touchedFields
via setTouchedFields({}) (and optionally clear current values like
setCurrentMaxMovieRating(undefined) / setCurrentMaxTvRating(undefined) if you
want a clean initial state) before fetching; this ensures previous mixed/touched
indicators don't persist across selection changes.
|
@mcfalld Heads up, your bug fix for the blockUnrated gating issue sent me down a rabbit hole. Once I started looking at the post-filter logic, I realized the content rating enforcement was only covering discover and search routes. Everything else: detail pages, recommendations, similar, collections, person credits, request submission..had zero rating checks. A restricted user could just navigate directly to /movie/550 and see whatever they wanted. So I ended up doing a bigger refactor:
Also fixed a TypeORM issue where clearing a rating limit wasn't actually persisting to the DB (undefined vs null), and cleaned up the dropdown defaults to use NC-17/TV-MA as "no restriction" instead of an empty string. Net result is about 130 fewer lines in discover.ts and rating enforcement everywhere it was missing. The two new commits on the PR cover all of it. Thanks for taking the time to test and review, your catch on the blockUnrated bug is what kicked off finding all these other gaps. |
|
I'd also noticed the 'No Restriction' not saving thing. Good catch.
|
|
Thanks for testing and reviewing, really appreciate it.
We also caught foreign films leaking past the filters because they had no US certification (NR/unrated) but carried a G-equivalent rating under foreign cert authorities. We locked cert extraction to US-only to prevent that. If TMDB ever adds native filtering by certification to their trending/upcoming endpoints, we could get much better result density. For now, client-side filtering after fetch is the only option.
{
"compilerOptions": {
"target": "ES2021",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"strictPropertyInitialization": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useUnknownInCatchVariables": false,
"incremental": true,
"baseUrl": "src",
"downlevelIteration": true,
"paths": {
"@server/*": ["../server/*"],
"@app/*": ["*"]
}
},
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}What errors are you seeing? Could be a path alias or strict mode thing. |
|
getting 2 errors: server/middleware/ratingCheck.ts:115:31 - error TS2352: Conversion of type 'TmdbTvDetails' to type 'Record<string, unknown>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. 115 if (limits.blockAdult && (details as Record<string, unknown>).adult) { server/routes/discover.ts:1515:7 - error TS2322: Type '{ id: number; ratingKey: string; title: string; mediaType: string; tmdbId: number; }[]' is not assignable to type 'WatchlistItem[]'. 1515 results: watchlistResults, Errors Files |
|
Good catches, both fixed in the latest push.
Out of curiosity, which IDE are you using? Wondering how your setup is picking up the server-side types since our tsconfig only includes |
|
I'm using VS Code. then '$env:NODE_ENV="production"; node dist/index.js' Then I see all the logs in my VS Code console. But I'm doing this on a gaming PC not my server, so it's two separate instances. |
|
Gotcha, the fix should be live so let me know if it persists. I'm running it via Docker so I wasn't catching the server-side type errors from |
|
@ThermicWaffle Really appreciate you taking the time to look through this! Great questions, let me walk through each one.
Thanks again for the thoughtful insight! |
|
Thank you for the thoughtful response! I think I'll leave the decision on these things to @fallenbagel . I didn't think of it blocking indie/international films, good catch! I imagine TMDB doesn't capture ratings from other countries? If it does, could be cool to add in a later PR. Another thought I had, why TMDB over TVDB? The scope creep explanations make sense. Thanks for the hard work on this you guys, I'm excited to finally filter out this content from my searches and suggestions!! |
|
Thanks! To answer your questions: International ratings: TMDB actually does have certification data for a bunch of countries (US, GB, AU, DE, BR, etc.). We just scope to US ratings right now since that's the hierarchy we built. Supporting multi-country rating maps is totally doable in a follow-up. The backend already stores the full certification response, so we'd just need additional rating hierarchies and a user/server-level country preference. Why TMDB over TVDB: I thought about using TVDB and unless I missed something, TVDB doesn't provide content rating or certification data that we can filter on. TMDB does, and Seerr already uses it as the primary metadata source for discovery and search, so the certification data is already in the responses we're working with. No extra API calls needed. |
|
This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged. |
Merge Conflict ResolutionUpstream What changed upstream:
How we resolved it:
Build passes clean ( |
|
Nice work man, I'll pull it tonight at some point and test it again. |
Major Update: Pagination, Rate Limiting & Filtering Overhaul (85cb67f)10 files changed, +645/-443 lines — this is a significant rework of how content filtering interacts with TMDB's API and how paginated results flow to the frontend. BackgroundTesting parental controls under strict settings (PG / TV-G) exposed fundamental issues with how filtering, pagination, and TMDB API calls work together:
What ChangedCursor-based pagination (5 server files + 2 frontend files)All paginated endpoints ( Global AIMD concurrency limiter (
|
It was bothering me how sparse everything looked, you should see significant improvement in the results and behavior and now infinite scroll works as intended. Thanks for bringing it up, seeing it bother you made me rework the entire process for the better. |
|
This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged. |
fc52266 to
5db9b83
Compare
|
Merge conflicts resolved and build verified. Ready for review. @fallenbagel @OwsleyJr — would appreciate a review when you get a chance. |
|
This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged. |
|
Merge conflicts with Ready for review when you get a chance 🙏 |
|
Thank you for working on this! I'm really excited for it! |
|
Hey, is this due to be pushed to a stable version anytime soon? |
Not until it's properly reviewed, which is not. |
Not trying to be pushy. I'm genuinely curious, what does properly reviewed entail? |
Us maintainers reviewing the code for its quality, architectural design, maintainability etc. |
|
This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged. |
Adds admin-enforced per-user content rating limits. Admins set maximum movie (MPAA) and TV (US Parental Guidelines) ratings per user, plus block-unrated and block-adult toggles for fail-closed filtering. - New UserSettings columns: maxMovieRating, maxTvRating, blockUnrated, blockAdult (Postgres + SQLite migrations) - Admin-only parental controls settings tab + bulk edit modal with mixed-value handling - Discover: pre-filter via TMDB certification.lte/country, post-filter for unrated with page backfill - Search: parallel cert lookup, filter, backfill - Person credits: rating filter on combined cast/crew - Rating hierarchies: G < PG < PG-13 < R < NC-17; TV-Y < TV-Y7 < TV-G < TV-PG < TV-14 < TV-MA - Fail-closed: cert lookup failure or unknown rating blocks content Fixes seerr-team#354 Fixes seerr-team#501 Closes seerr-team#2275
9267279 to
3184148
Compare

Description
Adds admin-enforced per-user content rating limits (parental controls). Admins can set maximum movie (MPAA) and TV (US Parental Guidelines) ratings per user, plus a "block unrated" toggle for fail-closed filtering. Restricted users see no indication that filtering exists.
Screenshot
Admin view: User > Settings > Parental Controls
How It Works
Admin Sets Limits
A new Parental Controls tab appears in user settings (admin-only — restricted users cannot see or modify their own limits). Admins choose:
Limits are stored as new columns on
UserSettings(maxMovieRating,maxTvRating,blockUnrated) with TypeORM migrations for both PostgreSQL and SQLite.Discover Filtering (Two-Layer)
certification.lte/certification_countryquery params remove rated content above the limit at the API level — no extra requests neededblockUnratedusers, results that slipped through (unrated content TMDB doesn't filter) are caught server-side by fetching each item's US certification and checking against the hierarchySearch Filtering
Search results don't support TMDB's certification params, so all filtering is server-side. Certifications are fetched in parallel via
Promise.allSettledfor each result, then filtered against the user's limits.Backfill
When post-filtering drops a page below 15 results, the next TMDB page is automatically fetched and filtered to prevent sparse/empty pages. This applies to both discover and search routes.
Fail-Closed Design
blockUnratedis true, allowed when falseRating Hierarchies
Single source of truth in
server/constants/contentRatings.ts:Scoped to US ratings — TMDB's certification data is most complete for the US market.
Files Changed
server/constants/contentRatings.tsserver/entity/UserSettings.tsmaxMovieRating,maxTvRating,blockUnratedserver/migration/server/routes/discover.tscertificationLte, post-filter for unrated, backfillserver/routes/search.tsserver/routes/user/usersettings.tssrc/.../UserParentalControlsSettings/src/i18n/locale/en.jsonTesting
blockUnratedhides content without US certificationpnpm buildpasses clean (bothbuild:nextandbuild:server)AI Disclosure
This PR was developed with Claude Code (Claude Opus 4.6), with human review, testing, and architectural direction at every step. All code was verified against existing codebase patterns and deployed before submission.
Checklist
pnpm buildpnpm i18n:extractSummary by CodeRabbit