feat(discover): add exclusionary filters with per-dimension include/exclude toggles #3166
feat(discover): add exclusionary filters with per-dimension include/exclude toggles #3166TheReaperJay wants to merge 6 commits into
Conversation
…xclude toggles
Adds per-dimension exclusion (negation) support to discover. Each filter
section now has an Included/Excluded slide switch; the active mode drives
whether the selector writes to the include or exclude URL param, and the
value transfers when toggling so users don't need to re-select.
Exclusion is implemented with three mechanisms, dispatched centrally in
buildDiscoverPlan():
- TMDB-native without_* params (genres, keywords, studio, providers)
- Post-filter on list-response fields (language on both, country on TV)
- Complement query where TMDB has no without_* param (movie country,
TV status)
A bounded over-fetch (max 3 extra pages) fills the requested page when a
post-filter drops items, and the response carries paginationIsEstimate so
the UI can label the total as an estimate.
A capabilities matrix (FILTER_CAPABILITIES) drives which sections render an
exclude toggle, so dimensions TMDB cannot exclude (e.g. watch providers)
show a plain heading instead of a non-functional toggle. The flat and
structured filter shapes are defined once and imported by both the route
handlers and the client.
Route handlers are simplified to parse → plan → fill → enrich → respond.
The TMDB wrapper gains without_* params, with_origin_country, and a country
param, reusing the existing nodeCache layer.
…om it Introduce a single source of truth for discover filter dimensions in server/discover/types.ts (DISCOVER_DIMENSIONS). The flat URL-param schema (server/discover/schema.ts), the structured DiscoverFilter type, and the client-side FILTER_CAPABILITIES matrix (src/components/Discover/capabilities.ts) are all derived from this registry. This means any future dimension only needs to be added in one place; downstream consumers will produce compile-time errors until they are wired up, ensuring that drift is explicitly identified rather than silently allowed to introduce bugs. It also removes the need for the client to maintain a parallel copy of the query schema in src/components/Discover/constants.ts. Imports are updated to use the existing @server/* and @app/* aliases, and both tsconfig.json files are restored to their develop state since no new path alias is required.
…e selector conventions The new per-dimension include/exclude toggles make the FilterSlideover fire several rapid router.replace calls in succession. The existing mergeQueryString helper read from router.query, which Next.js updates asynchronously, so later replaces were occasionally building on a stale query and overwriting earlier filter changes. Reading from router.asPath instead uses the URL that is actually in the address bar, so mixed include/exclude combinations stay intact. LanguageSelector is shared with user settings and therefore emits values using its own conventions: the pseudo-value "server" means "the user's default original language", "all" means no language filter, and multiple selections are joined with "|". The discover route now resolves "server" on the server using the same logic as createTmdbWithRegionLanguage, making it valid for both include and exclude modes. If the resolved setting is "all" or empty, exclude mode resolves to no codes so it does not hide every result. Finally, the flat query schema now splits multi-value params on either "," or "|" to match the native separators used by the existing Seerr selectors (pipe for language/status, comma for genre/keyword/country).
Adds the new translation keys introduced by the per-dimension include/exclude filter panel and the 'All X' selector placeholders.
Adds a short paragraph to the general settings docs explaining that the Discover filter panel supports Included and Excluded modes per dimension, and that streaming services currently remain include-only.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
✅ Files skipped from review due to trivial changes (1)
🚧 Files skipped from review as they are similar to previous changes (5)
📝 WalkthroughWalkthroughAdds per-dimension include/exclude toggling to the Discover filter panel. A new server-side pipeline translates a structured ChangesDiscover Include/Exclude Filter Pipeline
Sequence DiagramsequenceDiagram
participant Client as Discover Page
participant FilterSlideover
participant DiscoverRoute as /discover/movies or /discover/tv
participant buildDiscoverPlan
participant fillPage
participant applyPostFilter
participant TMDB as TMDB Discover API
Client->>FilterSlideover: user toggles dimension to Exclude
FilterSlideover->>Client: update URL query params (include↔exclude keys)
Client->>DiscoverRoute: GET /discover/movies?excludeGenres=...&country=...
DiscoverRoute->>DiscoverRoute: DiscoverFilterSchema.parse(query)
DiscoverRoute->>DiscoverRoute: resolveServerLanguage(codes)
DiscoverRoute->>DiscoverRoute: getAllCountryCodes(tmdb)
DiscoverRoute->>buildDiscoverPlan: DiscoverFilter + allCountryCodes
buildDiscoverPlan-->>DiscoverRoute: { discoverOptions, postFilter }
DiscoverRoute->>fillPage: tmdb.getDiscoverMovies, postFilter, page
loop until PAGE_SIZE filled or maxOverFetch exceeded
fillPage->>TMDB: getDiscoverMovies(discoverOptions, page++)
TMDB-->>fillPage: TmdbPage results
fillPage->>applyPostFilter: results, postFilter spec
applyPostFilter-->>fillPage: filtered results + dropped count
end
fillPage-->>DiscoverRoute: HonestPage (paginationIsEstimate)
DiscoverRoute-->>Client: { results, totalPages, paginationIsEstimate, keywords }
Client->>Client: show resultsFilteredHint banner if paginationIsEstimate
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested reviewers
Poem
🚥 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: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/components/Common/SlideCheckbox/index.tsx (1)
15-18:⚠️ Potential issue | 🟡 MinorSpacebar toggle won't trigger—
e.keyvalue mismatch.The spacebar emits
e.key === ' '(single space character), not'Space'. This prevents keyboard users from toggling with Space. AddpreventDefault()to prevent page scroll when Space is pressed.Proposed fix
onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === 'Space') { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); onClick(); } }}🤖 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/Common/SlideCheckbox/index.tsx` around lines 15 - 18, The onKeyDown event handler in the SlideCheckbox component has a keyboard detection issue where the spacebar check uses the wrong string value. The spacebar emits a single space character as e.key value, not the string 'Space', so the current condition will never match when Space is pressed. Update the condition to check for e.key === ' ' instead of e.key === 'Space', and add e.preventDefault() before calling onClick() to prevent the default page scroll behavior that occurs when Space is pressed on interactive elements.seerr-api.yml (1)
5578-5594:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
paginationIsEstimateis missing from both discover response schemas.Per the feature behavior, discover responses can now include an estimate flag when post-filter over-fetching is used. Omitting it from the OpenAPI contract creates schema drift for client generation and API consumers (Lines 5578 and 5945 response objects).
Suggested OpenAPI patch
/discover/movies: get: ... responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 + paginationIsEstimate: + type: boolean + example: false + description: True when totals/pages are estimated due to exclude post-filtering over-fetch results: type: array items: $ref: '`#/components/schemas/MovieResult`' /discover/tv: get: ... responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 + paginationIsEstimate: + type: boolean + example: false + description: True when totals/pages are estimated due to exclude post-filtering over-fetch results: type: array items: $ref: '`#/components/schemas/TvResult`'Also applies to: 5945-5960
🤖 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 `@seerr-api.yml` around lines 5578 - 5594, The discover response schemas are missing the paginationIsEstimate property which should be included in the 200 response objects to reflect that discover responses can now include an estimate flag when post-filter over-fetching is used. Add a paginationIsEstimate property (type boolean) to both discover endpoint response schemas alongside the existing page, totalPages, totalResults, and results properties to maintain consistency with the feature behavior and prevent schema drift for client generation.
🧹 Nitpick comments (1)
server/discover/planBuilder.ts (1)
31-33: ⚡ Quick winStrengthen
discoverOptionstyping to preserve cross-layer contracts.
discoverOptionsasRecord<string, unknown>erases key-level safety and forces unsafe casts in route handlers. Type this as the concrete discover-options shape used byDiscoverPlanso key drift is caught at compile time.💡 Proposed change
- const discoverOptions: Record<string, unknown> = {}; + const discoverOptions: DiscoverPlan['discoverOptions'] = {};🤖 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/discover/planBuilder.ts` around lines 31 - 33, The `discoverOptions` variable on line 31 of planBuilder.ts is typed as `Record<string, unknown>`, which eliminates compile-time type safety and causes unsafe casts in route handlers. Replace this generic Record type with the concrete discover-options shape that `DiscoverPlan` expects and uses, ensuring that the type definition captures all required keys and their specific value types. This change will enable the TypeScript compiler to catch key drift and type mismatches at compile time rather than forcing developers to use unsafe type assertions.
🤖 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 `@docs/using-seerr/settings/general.md`:
- Line 48: The statement on line 48 claims that streaming services support
inclusion only due to TMDB limitations, which contradicts the documentation of
excludeWatchProviders on discover endpoints elsewhere in this PR. Reword the
sentence in that location to clarify whether the exclusion limitation applies
only to the UI or also to the API level. Ensure the revised text explicitly
distinguishes between what the API supports (excludeWatchProviders) and what the
UI supports, so users receive consistent guidance throughout the documentation.
In `@server/discover/fill.ts`:
- Around line 44-71: The pagination logic starts collecting from requestedPage
instead of page 1, which causes duplicate results when post-filtering removes
items on pages beyond the first. Instead of starting tmdbPage at requestedPage,
initialize it to 1 and modify the while loop condition to collect enough items
to satisfy the entire filtered stream up through the requested page (at least
requestedPage * PAGE_SIZE items total). Then adjust the returned results slice
to extract only the appropriate page range using (requestedPage - 1) * PAGE_SIZE
as the start index and requestedPage * PAGE_SIZE as the end index. This ensures
a stable filtered stream where earlier pages' results are never duplicated in
later pages.
In `@server/routes/discover.ts`:
- Around line 151-158: Replace Promise.all with Promise.allSettled in the
keyword details lookup to prevent a single failed keyword lookup from rejecting
the entire discover endpoint response. Update the filter logic to handle the
PromiseSettledResult structure returned by allSettled: iterate through the
results, check for status === 'fulfilled', and keep only the keyword data from
successful results, discarding rejected ones. This same change must be applied
at both locations: the anchor site in server/routes/discover.ts at lines 151-158
where keywordIds are fetched via tmdb.getKeywordDetails, and the sibling site at
lines 460-466 which has the same pattern and should be fixed identically.
- Around line 123-124: The getAllCountryCodes call in the discover route is
executed unconditionally for every request, but country codes are only needed
when building movie-country exclusions. Refactor the code to conditionally call
getAllCountryCodes only when the filter actually requires country code
filtering, rather than fetching it upfront for all requests. This will reduce
unnecessary latency and remote dependencies on the hot paths. Apply this
optimization to all affected locations in the discover routes where this
unconditional call currently occurs.
In `@src/components/Discover/FilterSlideover/index.tsx`:
- Around line 124-143: The exclude/include mode state variables (studioExclude,
genreExclude, statusExclude, keywordExclude, languageExclude, countryExclude)
are initialized from currentFilters only once in useState, but they do not
update when currentFilters changes due to URL navigation or parent updates. Add
a useEffect hook that depends on currentFilters and updates all six exclude
state variables whenever currentFilters changes, ensuring the toggle switches
and query parameter targets remain synchronized with the URL filters.
In `@src/components/Selector/index.tsx`:
- Around line 663-669: The CountrySelector is not properly resetting state when
defaultValue becomes empty, allowing prior selections to remain visible, and it
only handles comma delimiters when splitting values. Fix this by removing the
early return on empty defaultValue and instead clearing the relevant state, then
update the split operation to handle both comma and pipe separators (e.g., using
a regex pattern like /[,|]/) to normalize multi-value query strings that may use
either delimiter. This ensures defaults are properly hydrated and prior
selections are cleared when the input is emptied.
---
Outside diff comments:
In `@seerr-api.yml`:
- Around line 5578-5594: The discover response schemas are missing the
paginationIsEstimate property which should be included in the 200 response
objects to reflect that discover responses can now include an estimate flag when
post-filter over-fetching is used. Add a paginationIsEstimate property (type
boolean) to both discover endpoint response schemas alongside the existing page,
totalPages, totalResults, and results properties to maintain consistency with
the feature behavior and prevent schema drift for client generation.
In `@src/components/Common/SlideCheckbox/index.tsx`:
- Around line 15-18: The onKeyDown event handler in the SlideCheckbox component
has a keyboard detection issue where the spacebar check uses the wrong string
value. The spacebar emits a single space character as e.key value, not the
string 'Space', so the current condition will never match when Space is pressed.
Update the condition to check for e.key === ' ' instead of e.key === 'Space',
and add e.preventDefault() before calling onClick() to prevent the default page
scroll behavior that occurs when Space is pressed on interactive elements.
---
Nitpick comments:
In `@server/discover/planBuilder.ts`:
- Around line 31-33: The `discoverOptions` variable on line 31 of planBuilder.ts
is typed as `Record<string, unknown>`, which eliminates compile-time type safety
and causes unsafe casts in route handlers. Replace this generic Record type with
the concrete discover-options shape that `DiscoverPlan` expects and uses,
ensuring that the type definition captures all required keys and their specific
value types. This change will enable the TypeScript compiler to catch key drift
and type mismatches at compile time rather than forcing developers to use unsafe
type assertions.
🪄 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: 7d7d259a-8e92-48c6-a23a-de1448889009
📒 Files selected for processing (20)
docs/using-seerr/settings/general.mdseerr-api.ymlserver/api/themoviedb/index.tsserver/discover/countryCodes.tsserver/discover/fill.tsserver/discover/planBuilder.tsserver/discover/postFilter.tsserver/discover/schema.tsserver/discover/types.tsserver/routes/discover.tssrc/components/Common/SlideCheckbox/index.tsxsrc/components/Discover/DiscoverMovies/index.tsxsrc/components/Discover/DiscoverTv/index.tsxsrc/components/Discover/FilterSlideover/index.tsxsrc/components/Discover/capabilities.tssrc/components/Discover/constants.tssrc/components/Selector/index.tsxsrc/hooks/useDiscover.tssrc/hooks/useUpdateQueryParams.tssrc/i18n/locale/en.json
- Add concrete TmdbDiscoverMovieParams/TmdbDiscoverTvParams interfaces derived from the wrapper's option shapes, replacing the loose Record<string, unknown> in DiscoverPlan so key drift is caught at compile time. - Use Promise.allSettled for keyword detail lookups so one missing keyword does not fail the entire discover response. - Fetch country codes only when the filter actually involves country inclusion/exclusion, avoiding an unnecessary TMDB call in the common case. - Sync FilterSlideover's local exclude-mode state with currentFilters via useEffect so back/forward navigation keeps the switches correct. - Clear CountrySelector selected state when defaultValue becomes empty. - Fix SlideCheckbox keyboard handling: Space emits e.key === ' ', not 'Space', and preventDefault stops page scroll. - Add paginationIsEstimate to the OpenAPI discover response schemas. - Clarify in docs that streaming services are include-only in the UI (not that TMDB lacks the underlying parameter).
Description
This PR adds exclusionary (negation) filters across all discover filter dimensions where it is technically feasible.
This allows for a greater variety of filters across the discovery UX and surfaces combinations that weren't possible before. For example, you can include a filter for a production country, while excluding specific language, or exclude a specific production country while excluding keywords and including categories etc.
Multiple filters can stack, allowing for much greater granularity and ease of discovery, whilst the UX toggle allows for quick switching back and forth. Default behavior remains inclusive like the current UX enforces, meaning that there are no awkward surprises.
The coverage affects both TV and Movie discovery pages, specifically:
without_*parameters.original_languagefrom the list response;server(user default) is resolved server-side for both include and exclude using the same logic ascreateTmdbWithRegionLanguage.with_origin_country= all codes except the excluded ones); TV is post-filtered onorigin_country. (This is because TMDB surfaces origin_country for TV only)with_status.The UI surfaces a per-section include/exclude toggle using the existing
SlideCheckboxcomponent. Streaming services remain include-only because TMDB'swithout_watch_providersis not surfaced in the discover UI and the user-facing value is low.The implementation avoids extra TMDB calls in the common case. When a post-filter is active and drops results, the route handler over-fetches up to three additional TMDB pages to keep the requested page full. Pagination is marked as an estimate when this happens.
Feature coverage
SlideCheckboxcomponent.All Genres,All Studios,-for Keywords, etc.).resultsFilteredHintmessage when the result count is an estimate due to post-filtering.Backend coverage
without_*parameters.original_languagefrom the list response;server(user default) is resolved server-side for both include and exclude using the same logic ascreateTmdbWithRegionLanguage.with_origin_country= all codes except the excluded ones); TV is post-filtered onorigin_country(TMDB only surfaces this field for TV list responses).with_status.paginationIsEstimateflag on responses.Supporting changes
server/discover/types.ts;DiscoverFiltertype, query schema, and client capability matrix are all derived from it.mergeQueryStringreads fromrouter.asPathso consecutive filter updates don't race and overwrite each other.,and|to match existing Seerr selector conventions.Partially addresses #1766 (discover filters only; override rules not covered).
The PR has been designed to make UX replacement incredibly easy if the provided UX toggles/naming is not desirable.
How Has This Been Tested?
pnpm typecheck— cleanpnpm lint— 0 errors (19 pre-existing warnings)pnpm prettier --check— cleanpnpm build— successfulpnpm i18n:extract— extracted new keys, committed ine4087bf7/discover/moviesand/discover/tvfilter panels render correctly.excludeLanguages=tl|hi+excludeCountries=IN).serverlanguage resolution in both include and exclude modes.all/empty language setting does not hide every result in exclude mode.Screenshots / Logs (if applicable)
Movies discover page with the filter panel open. Spanish is excluded under Original Language and Mexico is included under Production Country; the estimate hint is visible at the top of the results.
i.e: "Show me all non-Spanish language movies with a Production Country in Mexico".
Movies discover page with the filter panel open. Hindi is excluded under Original Language and India is included under Production Country, demonstrating the per-section include/exclude toggles.
i.e: "Show me all non-Hindi movies with a Production Country in India"
Movie detail page for "KD – The Devil" showing the Original Language (Kannada) and Production Country (India) fields that the filters operate on.
This shows the Production Country is India, but language is not Hindi from filter above.
Checklist:
pnpm buildpnpm i18n:extractAI Disclosure: I used GLM 5.2 as an aid while exploring the discover filter architecture, verifying TMDB OpenAPI documentation, and finding the best execution surface. The overall architecture and design decisions were hand crafted. All code was reviewed, tested, and manually verified by me before submission.
Summary by CodeRabbit
Release Notes
New Features
Documentation