Skip to content

feat: Add parental controls and global content exclusions#3127

Open
MagicalPotato0001 wants to merge 1 commit into
seerr-team:developfrom
MagicalPotato0001:parental-controls
Open

feat: Add parental controls and global content exclusions#3127
MagicalPotato0001 wants to merge 1 commit into
seerr-team:developfrom
MagicalPotato0001:parental-controls

Conversation

@MagicalPotato0001
Copy link
Copy Markdown

@MagicalPotato0001 MagicalPotato0001 commented Jun 6, 2026

Title

Add parental controls and global content exclusions

Description

Adds per-user parental controls and app-wide content exclusion settings.

This change adds parental control settings to user profiles, allowing admins to set maximum movie/series ratings and optionally block unrated content.

It also adds global Seerr settings for excluding specific ratings and TMDB keyword tags from discover/movie/series browsing independently from the blocklist. This allows admins to allow ratings like R while still hiding titles with specific tags. Manual search remains unaffected by the global exclusions.

AI Assistance Disclosure

This pull request was developed with assistance from AI tooling(codex). The changes were reviewed and tested locally before submission. I have experience as a react/nextjs developer, I was planning on making it mostly myself but decided to try out codex since this feature was very needed for me.

How Has This Been Tested?

Tested locally on Windows with Node/pnpm project dependencies installed.

Ran:

  • pnpm typecheck:server
  • pnpm typecheck:client
  • pnpm build
  • Prettier checks on touched files

Screenshots / Logs (if applicable)

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy)
  • I have updated the documentation accordingly.
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract (not sure about this)
  • Database migration (if required)

Summary by CodeRabbit

  • New Features

    • Added parental controls system allowing users to filter content by certification levels.
    • Added ability to block unrated media and set region-specific certification limits for movies and TV.
    • Added global content exclusion settings for certifications and tags across the platform.
    • Parental controls now applied to search, discovery, and content browsing.
  • Chores

    • Updated Node.js version requirement to 22.19.0.
    • Improved pagination logic for content discovery.

…over pages

Add parental controls and global content exclusions
@MagicalPotato0001 MagicalPotato0001 requested a review from a team as a code owner June 6, 2026 22:01
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 6, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces a comprehensive parental controls and global media filtering system. It adds per-user parental control settings (certification bounds, unrated blocking) and admin-level global exclusions (rating/certification and tag filters) enforced across discovery, search, movie, TV, and request endpoints using TMDb certification and keyword data.

Changes

Parental Controls & Global Filtering System

Layer / File(s) Summary
Entity schema and database migrations
server/entity/UserSettings.ts, server/interfaces/api/userSettingsInterfaces.ts, server/migration/postgres/..., server/migration/sqlite/...
Adds five parental-control fields to UserSettings entity and updates API response interface; creates PostgreSQL and SQLite migrations with appropriate defaults and rollback logic.
Core parental controls and filtering module
server/lib/parentalControls.ts
Introduces 552-line module with user-level parental control decision logic (certification bounds, unrated blocking, adult-content gating), plus three global filtering systems: certification/rating exclusions, and tag/keyword exclusions; includes helper functions to fetch TMDb details per inferred media type and filter search responses.
Global settings configuration schema
server/lib/settings/index.ts
Extends MainSettings interface and defaults with fields for excluded movie/TV certifications, region, and excluded tags.
Request validation and route error handling
server/entity/MediaRequest.ts, server/routes/request.ts
Adds parental-control eligibility check in MediaRequest.request before entity persistence; extends request route to map ParentalControlRestrictedError to 403 Forbidden.
Search, movie, TV, and discover route filtering
server/routes/search.ts, server/routes/movie.ts, server/routes/tv.ts, server/routes/discover.ts
Integrates parental controls and global exclusion filtering across all discovery endpoints (trending, keyword, genre-slider, watchlist), movie detail/recommendations/similar, TV detail/season/recommendations/similar, and general search, applying sequential filtering pipelines before response mapping.
User settings persistence API route
server/routes/user/usersettings.ts
Extends /main GET/POST endpoints to return and persist five parental-control fields (parentalControlsEnabled, parentalControlsRegion, maxMovieCertification, maxTvCertification, blockUnrated) in user settings.
User-facing parental controls UI
src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx
Adds permission-gated parental controls section in user settings form with toggle for enabled/blockUnrated, region selector, and separate certification selectors for movies and TV media.
Admin global settings UI and component enhancements
src/components/Settings/SettingsMain/index.tsx, src/components/BlocklistedTagsSelector/index.tsx
Extends SettingsMain with excluded certification and tag selectors for global filtering; enhances BlocklistedTagsSelector with reusable fieldName prop for flexible Formik integration.
Pagination fix and configuration updates
src/hooks/useDiscover.ts, package.json, next-env.d.ts
Improves pagination termination in useDiscover by comparing fetched page count against totalPages instead of result-count heuristic; pins Node.js to 22.19.0 via volta; updates Next.js TypeScript reference to dev-specific types.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant MediaRequest
  participant ParentalControls
  participant TMDb
  User->>MediaRequest: Request media
  MediaRequest->>ParentalControls: isMediaAllowedByParentalControls
  ParentalControls->>TMDb: Fetch media details
  TMDb-->>ParentalControls: Movie/TV certification data
  ParentalControls->>ParentalControls: Check user cert bounds & adult status
  ParentalControls-->>MediaRequest: Allow or throw ParentalControlRestrictedError
  alt Allowed
    MediaRequest->>MediaRequest: Create media entity and request
  else Blocked
    MediaRequest-->>User: 403 Forbidden
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • fallenbagel
  • gauthier-th
  • 0xSysR3ll

Poem

🐰 Whiskers twitch with joy today,
Parents gain a safer way,
Filter rules now guard the way,
Content screened—hip hip hooray! 🎬✨

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding parental controls and global content exclusion features.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
server/routes/user/usersettings.ts (1)

135-152: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Enforce permission check for parental control field updates.

The parental control fields are currently accepted from req.body without verifying that the requester has MANAGE_USERS permission. This allows users to modify their own parental controls via direct API calls, even though the UI only exposes these settings to users with MANAGE_USERS permission (see UserGeneralSettings/index.tsx line 437).

Parental controls should follow the same authorization pattern as quota fields (lines 116–124): only users with MANAGE_USERS permission should be able to configure parental controls on other users, and users should not be able to modify their own parental controls.

🔒 Proposed fix to add permission guard
     user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies;
     user.settings.watchlistSyncTv = req.body.watchlistSyncTv;
-    user.settings.parentalControlsEnabled = req.body.parentalControlsEnabled;
-    user.settings.parentalControlsRegion = req.body.parentalControlsRegion;
-    user.settings.maxMovieCertification = req.body.maxMovieCertification;
-    user.settings.maxTvCertification = req.body.maxTvCertification;
-    user.settings.blockUnrated = req.body.blockUnrated;
+
+    // Update parental controls only if requester has MANAGE_USERS permission
+    // and target user is not an admin (similar to quota logic at lines 116-124)
+    if (
+      req.user?.hasPermission(Permission.MANAGE_USERS) &&
+      !user.hasPermission(Permission.MANAGE_USERS) &&
+      req.user?.id !== user.id
+    ) {
+      user.settings.parentalControlsEnabled = req.body.parentalControlsEnabled;
+      user.settings.parentalControlsRegion = req.body.parentalControlsRegion;
+      user.settings.maxMovieCertification = req.body.maxMovieCertification;
+      user.settings.maxTvCertification = req.body.maxTvCertification;
+      user.settings.blockUnrated = req.body.blockUnrated;
+    }
   }

Apply the same guard when creating new UserSettings at lines 135–139.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/routes/user/usersettings.ts` around lines 135 - 152, When creating or
updating UserSettings, guard assignment of parental control fields
(parentalControlsEnabled, parentalControlsRegion, maxMovieCertification,
maxTvCertification, blockUnrated) with the same MANAGE_USERS permission check
used for quota fields: only set these properties on the new UserSettings object
or on user.settings when hasManageUsersPermission(req) (or the existing
permission helper used for quota) returns true; if the caller lacks
MANAGE_USERS, skip assigning those parental control fields so users cannot
modify their own parental controls.
src/components/BlocklistedTagsSelector/index.tsx (1)

116-184: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Hardcoded key causes duplicate React keys across instances.

Line 167 hardcodes the key as keyword-select-blocklistedTags, but this component is now reused for multiple fields (excludedMovieTags, excludedTvTags, and blocklistedTags in SettingsMain). All three instances will have identical React keys, which can cause reconciliation issues.

Pass fieldName as a prop to ControlledKeywordSelector and use it in the key to ensure uniqueness.

Proposed fix

Update the BaseSelectorMultiProps type to include fieldName:

 type BaseSelectorMultiProps = {
   defaultValue?: string;
   value: MultiValue<SingleVal> | null;
   onChange: (value: MultiValue<SingleVal> | null) => void;
   components?: Partial<typeof components>;
+  fieldName: string;
 };

Pass fieldName from BlocklistedTagsSelector to ControlledKeywordSelector:

       <ControlledKeywordSelector
         value={selectorValue}
         onChange={update}
         defaultValue={defaultValue}
+        fieldName={fieldName}
         components={{

Use fieldName in the key:

     <AsyncSelect
-      key={`keyword-select-blocklistedTags`}
+      key={`keyword-select-${fieldName}`}
       inputId="data"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/BlocklistedTagsSelector/index.tsx` around lines 116 - 184,
ControlledKeywordSelector currently hardcodes
key="keyword-select-blocklistedTags" causing duplicate React keys when used for
excludedMovieTags, excludedTvTags and blocklistedTags; add a fieldName string to
BaseSelectorMultiProps, accept fieldName in ControlledKeywordSelector props,
pass fieldName from BlocklistedTagsSelector when rendering the controlled
selector, and use it to build a unique key (e.g. `keyword-select-${fieldName}`)
instead of the hardcoded value so each instance gets a distinct React key.
🧹 Nitpick comments (1)
src/components/Settings/SettingsMain/index.tsx (1)

441-494: 💤 Low value

Certification exclusions are hardcoded to US region only.

The onChange handlers (lines 464, 490) always set excludedCertificationRegion to 'US', and there is no UI control for users to select a different region. This means administrators can only exclude US certifications, not certifications from other regions.

This appears intentional (the component is named USCertificationSelector), but it's a design limitation to be aware of. If you plan to support other regions in the future, consider making the region configurable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Settings/SettingsMain/index.tsx` around lines 441 - 494, The
onChange handlers for USCertificationSelector (used for
excludedMovieCertifications and excludedTvCertifications) hardcode
setFieldValue('excludedCertificationRegion','US'); change this so the region is
not hardcoded: either read a region value from the selector callback (e.g.,
params.region) or add a region prop/UI control and pass that into the selector,
then call setFieldValue('excludedCertificationRegion', regionValue) instead;
ensure USCertificationSelector and its call sites (type="movie"/"tv",
certification prop) are updated to surface or accept the region so
administrators can select non-US regions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/lib/parentalControls.ts`:
- Around line 199-243: The three filter functions
(filterResultsByParentalControls, filterResultsByGlobalRatingExclusions,
filterResultsByGlobalTagExclusions) repeatedly call
TheMovieDb.getMovie/getTvShow for the same items, causing redundant TMDb API
calls; fix by consolidating into a single-pass filter (e.g.,
filterResultsByAllExclusions) or add a per-request details cache: iterate
results once using getResultMediaType, fetch details once via
TheMovieDb.getMovie/getTvShow, then call isMediaAllowedByParentalControls,
isMediaAllowedByGlobalRatingExclusions and isMediaAllowedByGlobalTagExclusions
against that single details object (or pass cached details into existing filter
helpers) and return filtered results; ensure early-return when no filters are
active and preserve type T in the result.
- Around line 218-240: Wrap each TMDb fetch inside the Promise.all map with
try/catch so a single failed fetch (from tmdb.getMovie or tmdb.getTvShow) does
not reject the whole Promise.all; inside the map used to produce allowedResults,
catch errors around the getResultMediaType branch and on error log/ignore the
item and return null so downstream isMediaAllowedByParentalControls is skipped
for failed fetches, preserving the existing return types and still filtering out
disallowed or failed items.

In `@server/lib/settings/index.ts`:
- Around line 427-428: The current defaults set excludedMovieCertifications and
excludedTvCertifications to 'NR', which hides all "Not Rated" content by
default; change these defaults to empty strings so no certifications are
excluded by default (update the values of excludedMovieCertifications and
excludedTvCertifications in settings/index.ts from 'NR' to ''), and adjust any
related docstrings, migration/defaults comments, or unit tests that assume 'NR'
is the default to reflect the new empty-string default.

In `@server/routes/discover.ts`:
- Around line 1307-1318: The response currently computes pagination using the
unfiltered watchlist size (watchlist.totalSize), which yields incorrect
totalResults/totalPages after applying filteredWatchlist; update the response to
derive totals from the filtered results instead: set totalResults to
filteredWatchlist.length and totalPages to Math.ceil(filteredWatchlist.length /
itemsPerPage) while keeping the results mapping (filteredWatchlist.map(...)) and
the page value as-is (page) so pagination reflects the filtered data.

In `@src/components/Settings/SettingsMain/index.tsx`:
- Around line 190-192: The UI currently falls back to the string 'NR' for
certification fields, which duplicates the old server default; update the
SettingsMain component to use empty-string fallbacks so they match the server
change: replace the expressions for excludedMovieCertifications and
excludedTvCertifications in src/components/Settings/SettingsMain/index.tsx (the
data?.excludedMovieCertifications and data?.excludedTvCertifications usages) to
use ?? '' instead of ?? 'NR' so the UI and server defaults stay consistent.

In `@src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx`:
- Around line 467-510: The two CertificationSelector instances (type="movie" and
type="tv") update parentalControlsRegion independently which can leave
maxMovieCertification or maxTvCertification inconsistent with the current
region; update the onChange handlers in both CertificationSelector usages so
when params.certificationCountry is present and different from
values.parentalControlsRegion you call setFieldValue('parentalControlsRegion',
params.certificationCountry ?? 'US') and also reset both certification fields
(setFieldValue('maxMovieCertification', undefined) and
setFieldValue('maxTvCertification', undefined) or to your form's cleared value)
before setting the new specific certification, ensuring parentalControlsRegion,
maxMovieCertification and maxTvCertification cannot become out-of-sync.

---

Outside diff comments:
In `@server/routes/user/usersettings.ts`:
- Around line 135-152: When creating or updating UserSettings, guard assignment
of parental control fields (parentalControlsEnabled, parentalControlsRegion,
maxMovieCertification, maxTvCertification, blockUnrated) with the same
MANAGE_USERS permission check used for quota fields: only set these properties
on the new UserSettings object or on user.settings when
hasManageUsersPermission(req) (or the existing permission helper used for quota)
returns true; if the caller lacks MANAGE_USERS, skip assigning those parental
control fields so users cannot modify their own parental controls.

In `@src/components/BlocklistedTagsSelector/index.tsx`:
- Around line 116-184: ControlledKeywordSelector currently hardcodes
key="keyword-select-blocklistedTags" causing duplicate React keys when used for
excludedMovieTags, excludedTvTags and blocklistedTags; add a fieldName string to
BaseSelectorMultiProps, accept fieldName in ControlledKeywordSelector props,
pass fieldName from BlocklistedTagsSelector when rendering the controlled
selector, and use it to build a unique key (e.g. `keyword-select-${fieldName}`)
instead of the hardcoded value so each instance gets a distinct React key.

---

Nitpick comments:
In `@src/components/Settings/SettingsMain/index.tsx`:
- Around line 441-494: The onChange handlers for USCertificationSelector (used
for excludedMovieCertifications and excludedTvCertifications) hardcode
setFieldValue('excludedCertificationRegion','US'); change this so the region is
not hardcoded: either read a region value from the selector callback (e.g.,
params.region) or add a region prop/UI control and pass that into the selector,
then call setFieldValue('excludedCertificationRegion', regionValue) instead;
ensure USCertificationSelector and its call sites (type="movie"/"tv",
certification prop) are updated to surface or accept the region so
administrators can select non-US regions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d45b2564-3e11-4500-9e2e-a5235be8477d

📥 Commits

Reviewing files that changed from the base of the PR and between 0a305f6 and 7966253.

📒 Files selected for processing (19)
  • next-env.d.ts
  • package.json
  • server/entity/MediaRequest.ts
  • server/entity/UserSettings.ts
  • server/interfaces/api/userSettingsInterfaces.ts
  • server/lib/parentalControls.ts
  • server/lib/settings/index.ts
  • server/migration/postgres/1780776000000-AddParentalControlsToUserSettings.ts
  • server/migration/sqlite/1780776000000-AddParentalControlsToUserSettings.ts
  • server/routes/discover.ts
  • server/routes/movie.ts
  • server/routes/request.ts
  • server/routes/search.ts
  • server/routes/tv.ts
  • server/routes/user/usersettings.ts
  • src/components/BlocklistedTagsSelector/index.tsx
  • src/components/Settings/SettingsMain/index.tsx
  • src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx
  • src/hooks/useDiscover.ts

Comment on lines +199 to +243
export const filterResultsByParentalControls = async <
T extends FilterableMediaResult,
>({
user,
results,
tmdb = new TheMovieDb(),
mediaType,
language,
}: {
user?: User;
results: T[];
tmdb?: TheMovieDb;
mediaType?: CertificationMediaType;
language?: string;
}): Promise<T[]> => {
if (!isParentalControlsEnabled(user)) {
return results;
}

const allowedResults: (T | null)[] = await Promise.all(
results.map(async (result) => {
const resultMediaType = getResultMediaType(result, mediaType);

if (!resultMediaType) {
return result;
}

const details =
resultMediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: result.id, language })
: await tmdb.getTvShow({ tvId: result.id, language });

return (await isMediaAllowedByParentalControls({
user,
mediaType: resultMediaType,
media: details,
tmdb,
}))
? (result as T)
: null;
})
);

return allowedResults.filter((result): result is T => !!result);
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Performance concern: Redundant TMDb API calls across filter functions.

Each of the three filter functions (filterResultsByParentalControls, filterResultsByGlobalRatingExclusions, filterResultsByGlobalTagExclusions) independently fetches TMDb details for every result. When these filters are chained in routes (as shown in discover.ts), the same TMDb details are fetched up to three times per item.

For a typical page of 20 results, this could result in 60 TMDb API calls instead of 20.

Consider consolidating the filtering into a single pass that fetches details once and applies all three checks, or caching the TMDb responses within the request lifecycle.

♻️ Proposed approach: Unified filter function
export const filterResultsByAllExclusions = async <
  T extends FilterableMediaResult,
>({
  user,
  results,
  tmdb = new TheMovieDb(),
  mediaType,
  language,
}: {
  user?: User;
  results: T[];
  tmdb?: TheMovieDb;
  mediaType?: CertificationMediaType;
  language?: string;
}): Promise<T[]> => {
  const parentalEnabled = isParentalControlsEnabled(user);
  const hasGlobalRatingExclusions = 
    !!getSettings().main.excludedMovieCertifications ||
    !!getSettings().main.excludedTvCertifications;
  const hasGlobalTagExclusions = 
    getGlobalExcludedKeywordIds(MediaType.MOVIE).length > 0 ||
    getGlobalExcludedKeywordIds(MediaType.TV).length > 0;

  // Early return if no filtering needed
  if (!parentalEnabled && !hasGlobalRatingExclusions && !hasGlobalTagExclusions) {
    return results;
  }

  const allowedResults = await Promise.all(
    results.map(async (result) => {
      const resultMediaType = getResultMediaType(result, mediaType);
      if (!resultMediaType) return result;

      // Fetch details once
      const details =
        resultMediaType === MediaType.MOVIE
          ? await tmdb.getMovie({ movieId: result.id, language })
          : await tmdb.getTvShow({ tvId: result.id, language });

      // Apply all checks
      if (parentalEnabled) {
        const allowed = await isMediaAllowedByParentalControls({
          user, mediaType: resultMediaType, media: details, tmdb
        });
        if (!allowed) return null;
      }

      if (!isMediaAllowedByGlobalRatingExclusions({ mediaType: resultMediaType, media: details })) {
        return null;
      }

      if (!isMediaAllowedByGlobalTagExclusions({ mediaType: resultMediaType, media: details })) {
        return null;
      }

      return result;
    })
  );

  return allowedResults.filter((result): result is T => !!result);
};

Also applies to: 349-394, 481-524

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/lib/parentalControls.ts` around lines 199 - 243, The three filter
functions (filterResultsByParentalControls,
filterResultsByGlobalRatingExclusions, filterResultsByGlobalTagExclusions)
repeatedly call TheMovieDb.getMovie/getTvShow for the same items, causing
redundant TMDb API calls; fix by consolidating into a single-pass filter (e.g.,
filterResultsByAllExclusions) or add a per-request details cache: iterate
results once using getResultMediaType, fetch details once via
TheMovieDb.getMovie/getTvShow, then call isMediaAllowedByParentalControls,
isMediaAllowedByGlobalRatingExclusions and isMediaAllowedByGlobalTagExclusions
against that single details object (or pass cached details into existing filter
helpers) and return filtered results; ensure early-return when no filters are
active and preserve type T in the result.

Comment on lines +218 to +240
const allowedResults: (T | null)[] = await Promise.all(
results.map(async (result) => {
const resultMediaType = getResultMediaType(result, mediaType);

if (!resultMediaType) {
return result;
}

const details =
resultMediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: result.id, language })
: await tmdb.getTvShow({ tvId: result.id, language });

return (await isMediaAllowedByParentalControls({
user,
mediaType: resultMediaType,
media: details,
tmdb,
}))
? (result as T)
: null;
})
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing error handling for individual TMDb fetches.

Promise.all will reject entirely if any single TMDb fetch fails (e.g., a deleted movie returns 404). This could cause entire discovery pages to fail due to one problematic item.

Consider wrapping individual fetches with error handling to gracefully exclude failed items rather than failing the entire batch.

🛡️ Proposed fix with error handling
   const allowedResults: (T | null)[] = await Promise.all(
     results.map(async (result) => {
+      try {
         const resultMediaType = getResultMediaType(result, mediaType);

         if (!resultMediaType) {
           return result;
         }

         const details =
           resultMediaType === MediaType.MOVIE
             ? await tmdb.getMovie({ movieId: result.id, language })
             : await tmdb.getTvShow({ tvId: result.id, language });

         return (await isMediaAllowedByParentalControls({
           user,
           mediaType: resultMediaType,
           media: details,
           tmdb,
         }))
           ? (result as T)
           : null;
+      } catch {
+        // If we can't fetch details, exclude the item to be safe
+        return null;
+      }
     })
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const allowedResults: (T | null)[] = await Promise.all(
results.map(async (result) => {
const resultMediaType = getResultMediaType(result, mediaType);
if (!resultMediaType) {
return result;
}
const details =
resultMediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: result.id, language })
: await tmdb.getTvShow({ tvId: result.id, language });
return (await isMediaAllowedByParentalControls({
user,
mediaType: resultMediaType,
media: details,
tmdb,
}))
? (result as T)
: null;
})
);
const allowedResults: (T | null)[] = await Promise.all(
results.map(async (result) => {
try {
const resultMediaType = getResultMediaType(result, mediaType);
if (!resultMediaType) {
return result;
}
const details =
resultMediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: result.id, language })
: await tmdb.getTvShow({ tvId: result.id, language });
return (await isMediaAllowedByParentalControls({
user,
mediaType: resultMediaType,
media: details,
tmdb,
}))
? (result as T)
: null;
} catch {
// If we can't fetch details, exclude the item to be safe
return null;
}
})
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/lib/parentalControls.ts` around lines 218 - 240, Wrap each TMDb fetch
inside the Promise.all map with try/catch so a single failed fetch (from
tmdb.getMovie or tmdb.getTvShow) does not reject the whole Promise.all; inside
the map used to produce allowedResults, catch errors around the
getResultMediaType branch and on error log/ignore the item and return null so
downstream isMediaAllowedByParentalControls is skipped for failed fetches,
preserving the existing return types and still filtering out disallowed or
failed items.

Comment on lines +427 to +428
excludedMovieCertifications: 'NR',
excludedTvCertifications: 'NR',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reconsider defaulting to exclude 'NR' content globally.

These defaults exclude all "Not Rated" content from discover/movie/series pages for every user in new installations. Many movies and TV shows lack ratings (international content, older content, independent films), so this could hide substantial content by default and surprise administrators.

Consider defaulting to empty strings (exclude nothing) and letting administrators explicitly opt-in to global exclusions.

Suggested default values
-        excludedMovieCertifications: 'NR',
-        excludedTvCertifications: 'NR',
+        excludedMovieCertifications: '',
+        excludedTvCertifications: '',
         excludedCertificationRegion: 'US',
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
excludedMovieCertifications: 'NR',
excludedTvCertifications: 'NR',
excludedMovieCertifications: '',
excludedTvCertifications: '',
excludedCertificationRegion: 'US',
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/lib/settings/index.ts` around lines 427 - 428, The current defaults
set excludedMovieCertifications and excludedTvCertifications to 'NR', which
hides all "Not Rated" content by default; change these defaults to empty strings
so no certifications are excluded by default (update the values of
excludedMovieCertifications and excludedTvCertifications in settings/index.ts
from 'NR' to ''), and adjust any related docstrings, migration/defaults
comments, or unit tests that assume 'NR' is the default to reflect the new
empty-string default.

Comment thread server/routes/discover.ts
Comment on lines 1307 to 1318
return res.json({
page,
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({
results: filteredWatchlist.map((item) => ({
id: item.tmdbId,
ratingKey: item.ratingKey,
title: item.title,
mediaType: item.type === 'show' ? 'tv' : 'movie',
mediaType: item.mediaType,
tmdbId: item.tmdbId,
})),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Pagination metadata does not reflect filtered results.

After filtering the watchlist, totalPages and totalResults still use watchlist.totalSize (the original unfiltered count). This causes incorrect pagination: users may see "page 1 of 5" but only receive 3 results because 17 were filtered out.

Consider recalculating pagination based on filtered results, or documenting this as expected behavior.

🐛 Proposed fix
+    const filteredTotal = filteredWatchlist.length + offset; // Approximate, or track actual filtered total

     return res.json({
       page,
-      totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
-      totalResults: watchlist.totalSize,
+      totalPages: Math.ceil(filteredWatchlist.length / itemsPerPage) || 1,
+      totalResults: filteredWatchlist.length,
       results: filteredWatchlist.map((item) => ({

Note: A complete solution would require filtering the entire watchlist to get accurate totals, which has performance implications. Consider whether approximate pagination is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/routes/discover.ts` around lines 1307 - 1318, The response currently
computes pagination using the unfiltered watchlist size (watchlist.totalSize),
which yields incorrect totalResults/totalPages after applying filteredWatchlist;
update the response to derive totals from the filtered results instead: set
totalResults to filteredWatchlist.length and totalPages to
Math.ceil(filteredWatchlist.length / itemsPerPage) while keeping the results
mapping (filteredWatchlist.map(...)) and the page value as-is (page) so
pagination reflects the filtered data.

Comment on lines +190 to +192
excludedMovieCertifications:
data?.excludedMovieCertifications ?? 'NR',
excludedTvCertifications: data?.excludedTvCertifications ?? 'NR',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

UI defaults propagate the 'NR' exclusion.

The UI fallback values also default to 'NR' for certifications when server data is missing, which compounds the concern raised in server/lib/settings/index.ts (lines 427-428). If you change the server defaults to empty strings, update these UI fallbacks to match.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Settings/SettingsMain/index.tsx` around lines 190 - 192, The
UI currently falls back to the string 'NR' for certification fields, which
duplicates the old server default; update the SettingsMain component to use
empty-string fallbacks so they match the server change: replace the expressions
for excludedMovieCertifications and excludedTvCertifications in
src/components/Settings/SettingsMain/index.tsx (the
data?.excludedMovieCertifications and data?.excludedTvCertifications usages) to
use ?? '' instead of ?? 'NR' so the UI and server defaults stay consistent.

Comment on lines +467 to +510
<div className="form-row">
<label className="text-label">
{intl.formatMessage(messages.maxMovieCertification)}
</label>
<div className="form-input-area">
<CertificationSelector
type="movie"
certificationCountry={values.parentalControlsRegion}
certification={values.maxMovieCertification}
onChange={(params) => {
setFieldValue(
'parentalControlsRegion',
params.certificationCountry ?? 'US'
);
setFieldValue(
'maxMovieCertification',
params.certification
);
}}
/>
</div>
</div>
<div className="form-row">
<label className="text-label">
{intl.formatMessage(messages.maxTvCertification)}
</label>
<div className="form-input-area">
<CertificationSelector
type="tv"
certificationCountry={values.parentalControlsRegion}
certification={values.maxTvCertification}
onChange={(params) => {
setFieldValue(
'parentalControlsRegion',
params.certificationCountry ?? 'US'
);
setFieldValue(
'maxTvCertification',
params.certification
);
}}
/>
</div>
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent region/certification mismatch across movie and TV selectors.

Both CertificationSelector components share a single parentalControlsRegion field but update it independently. This can create inconsistent state:

  1. User selects US region with PG-13 for movies
  2. User selects GB region with 12A for TV
  3. Result: parentalControlsRegion='GB', maxMovieCertification='PG-13', maxTvCertification='12A'

Now maxMovieCertification holds a US certification (PG-13) while parentalControlsRegion is 'GB'. This mismatch may cause validation errors or unexpected filtering behavior in the enforcement layer.

Consider either:

  • Resetting both certification values when the region changes in either selector, or
  • Using a single shared region selector above both certification pickers to enforce a consistent region
🔧 Example fix: reset certifications on region change
 <CertificationSelector
   type="movie"
   certificationCountry={values.parentalControlsRegion}
   certification={values.maxMovieCertification}
   onChange={(params) => {
+    // If region changed, reset both certifications
+    if (params.certificationCountry !== values.parentalControlsRegion) {
+      setFieldValue('maxMovieCertification', undefined);
+      setFieldValue('maxTvCertification', undefined);
+    } else {
+      setFieldValue('maxMovieCertification', params.certification);
+    }
     setFieldValue(
       'parentalControlsRegion',
       params.certificationCountry ?? 'US'
     );
-    setFieldValue(
-      'maxMovieCertification',
-      params.certification
-    );
   }}
 />

Apply similar logic to the TV certification selector.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx` around
lines 467 - 510, The two CertificationSelector instances (type="movie" and
type="tv") update parentalControlsRegion independently which can leave
maxMovieCertification or maxTvCertification inconsistent with the current
region; update the onChange handlers in both CertificationSelector usages so
when params.certificationCountry is present and different from
values.parentalControlsRegion you call setFieldValue('parentalControlsRegion',
params.certificationCountry ?? 'US') and also reset both certification fields
(setFieldValue('maxMovieCertification', undefined) and
setFieldValue('maxTvCertification', undefined) or to your form's cleared value)
before setting the new specific certification, ensuring parentalControlsRegion,
maxMovieCertification and maxTvCertification cannot become out-of-sync.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant