Skip to content

feat(discover): add TMDB list custom slider - #3344

Closed
judarange wants to merge 11 commits into
seerr-team:developfrom
judarange:feature/tmdb-list-slider
Closed

judarange wants to merge 11 commits into
seerr-team:developfrom
judarange:feature/tmdb-list-slider

Conversation

@judarange

@judarange judarange commented Aug 3, 2026 •

Copy link
Copy Markdown

Description

Adds a new custom discover slider type, TMDB List: admins can create a Discover row from any public TMDB list id. This implements the minimal proposal from #1837.

  • New DiscoverSliderType.TMDB_LIST (appended at the end of the enum — persisted values unchanged)

  • New endpoint GET /api/v1/discover/list/:listId with strict input validation (numeric id, safe-integer page), page-oriented caching (6h fresh, 48h stale-on-error, single-flight, bounded to 500 entries), and runtime validation of the TMDB payload before anything is cached

  • Works with mixed movie/TV lists, preserves list order, resolves local availability per user after the shared cache

  • Reads lists through the already-shipped TMDB v3 client — GET /3/list/{id} also returns v4-created lists (verified against live ids), so no new API keys, no new admin settings

  • Browsable /discover/list/[listId] page following the network/studio page pattern; unavailable (deleted/private) lists hide the row and show a dedicated state on the list page

  • CreateSlider: help text with an example id, client validation aligned with the server, debounced live preview with an explicit error state

  • MediaSlider rows now lazy-mount near the viewport (generic, default-on; previews opt out) — many custom sliders no longer fire all their fetches on Discover mount

  • a11y: the slider prev/next buttons now have localized aria-labels

  • Fixes Feature Request: Support for Custom Sliders Based on External or Dynamic Sources (e.g. RSS, TMDB Lists, Keywords) #1837

How Has This Been Tested?

  • 28 server tests covering the new route and cache (list order, mixed media, local media info, validation rejections incl. path traversal attempts, pagination, single-flight proven with a deferred-promise barrier, 6h refresh, 48h stale ceiling, stale-on-error, malformed TMDB payloads, 403 unauthenticated) — full suite 177 passed / 0 failed
  • pnpm lint (0 new warnings), pnpm typecheck, pnpm build, pnpm format:check, pnpm i18n:extract all green on Node 22.19 / pnpm 10.24
  • Running in production on my own bare-metal instance (Debian 12, Node 22) with 12 list sliders (~120 titles), exercised daily by real users

Screenshots / Logs (if applicable)

(screenshots in the first comment below)

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy) — AI-assisted development was used for parts of this implementation; all code was human-reviewed, is covered by the tests above, and runs in production on my own instance.
  • I have updated the documentation accordingly (OpenAPI spec seerr-api.yml).
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract
  • Database migration (not required)

Summary by CodeRabbit

  • New Features
    • Browse paginated public TMDB lists containing movies and TV shows.
    • Add TMDB List sliders to Discover with previews and unavailable-list messaging.
  • Improvements
    • Added validation, localized language handling, caching, and resilient loading for lists.
    • Discover sliders now load as they approach the viewport for improved performance.
    • Added accessible, localized labels for slider navigation.
  • Bug Fixes
    • Invalid or unavailable lists now display clear errors or empty states.

Jean-David Rioux and others added 8 commits August 3, 2026 14:34
Adds a TMDB_LIST custom Discover slider so admins can surface any public
TMDB list as a row of posters, with the usual request buttons and a
browsable "show more" page. Closes seerr-team#1837.

v3 vs v4 investigation
----------------------
The lists people want to display are created through the TMDB v4 API.
Verified against the live API that the v3 endpoint `GET /3/list/{id}`
reads those v4 lists just fine, with the same status codes as the v4
endpoint (200 for public, 401 for private, 404 for unknown), the same
per-item `media_type` discriminator, and 20-items-per-page pagination:

  GET /3/list/8542986?api_key=<seerr's shipped v3 key>   -> 200, media_type: tv
  GET /4/list/8542986 (v4 read bearer)                   -> 200, same item

So no v4 read token and no new admin configuration are needed; the
existing TheMovieDb client and its shipped v3 key are used as-is.

Server
------
The internals are built as a small list-provider abstraction
(server/lib/medialists) so other sources such as Trakt or MDBList can be
added later: a provider resolves a list id to an ordered set of
{mediaType, tmdbId} items, hydration fills in any missing TMDB payload
(a no-op for TMDB's own lists, which already carry it), and the service
returns a paginated, MediaSlider-compatible response. Arbitrary-URL
fetching is deliberately not supported; validateListId is the single
choke point where the admin-supplied slider data is checked, and TMDB
list ids must be plain integers. The same check rejects bad data at
slider create/update time.

Caching reuses the existing cacheManager: a new medialist cache holds
the fully hydrated list per provider/list/language, fresh for 6 hours and
resident for 48 so a stale copy can still be served when TMDB is
unreachable. Concurrent requests for the same list are collapsed into a
single upstream fetch. Deleted or private lists are recorded as empty,
logged once, and render an empty slider rather than failing the page.
Local availability is resolved per request since it is user-specific.

Frontend
--------
New slider type in the Discover switch and in the Create Slider form
(with the live preview the other types have), a browsable
/discover/list/[listId] page mirroring the network and studio pages, and
English strings via the repo's message system. Mixed movie/series lists
work and list order is preserved throughout.

The enum member is appended at the end of DiscoverSliderType since the
values are persisted; a comment now records that constraint.
The list provider accumulated up to 25 upstream pages into a single cache
entry, which both truncated long lists (the endpoint claimed to paginate
the whole list but never served past item 500) and turned one cold slider
render into 25 outbound requests.

Providers now resolve exactly one page: `fetchListPage(listId, { page })`
requests only that page from TMDB and reports `totalPages`/`totalResults`
straight from the upstream response, so the pagination contract is honest
for lists of any size and a cold fill costs one request. The cache is
keyed per (provider, listId, language, page) and keeps the same 6h fresh /
48h stale-on-error / single-flight semantics.

Also bounds what can reach the cache:

- the language dimension is no longer taken from the query string; the
  route pins it to the locale resolved server-side and the service
  canonicalises it, so the key can only take a handful of values
- the cache is capped at 500 entries, evicting least-recently-fetched
- `page` is parsed once and must be a safe integer >= 1, else HTTP 400
  (rejects 0, -1, 1.5, Infinity, oversized values, arrays and objects)
- the TMDB payload is validated at the boundary: pagination counters must
  be numbers, `items` an array of objects, ids safe positive integers and
  `media_type` one of movie/tv. A malformed payload is treated as an
  upstream failure, so a stale copy is served instead of being replaced
  with garbage, and nothing malformed is ever cached.

The response now distinguishes a list that could not be read at all
(`list.unavailable: true`) from one that exists and is empty, so the
frontend can react differently to each.

Tests go from 13 to 28: single-flight is proven with a deferred barrier
(all callers pending before the upstream resolves), a rejected in-flight
fetch is shown to be evicted so the next request retries, concurrent stale
callers are shown to share one failed refresh, an entry past its residency
window is shown not to be served, and the page/list-id validation and
malformed-payload cases are enumerated. The discover router is mounted
behind `isAuthenticated()` as it is in production, with an unauthenticated
request asserted to be rejected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Constrains what the endpoint actually accepts and documents what it
actually answers: page is an integer with a minimum of 1, listId carries
the same pattern the server validates against, the language query
parameter is gone (it is resolved server-side), the stable response fields
are marked required, list.unavailable is described, and the 403 and 500
responses are documented alongside the existing 400.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A TMDB list that has been deleted or made private used to render as a
slider heading over an empty strip on Discover, and as the generic
"No results." grid on its own page, neither of which tells anyone what
happened.

The list slider now sets `hideWhenEmpty`, so the row disappears instead of
advertising an empty one, and the list page renders a dedicated, localized
"no longer available" state, keeping the list name as the heading when it
is still known.

Two supporting fixes in the shared slider:

- `hideWhenEmpty` was evaluated before the row had answered, because the
  optional chain collapsed "not fetched yet" and "fetched, empty" into the
  same value; it now waits for data, which also removes the crash on an
  empty `data` array
- `isEmpty` was hardcoded to false, so an empty slider drew nothing at all
  rather than its empty message; it is now derived from the rendered items

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The list id field was a bare text input: nothing said where the number
comes from, a wrong id produced a silent, permanently disabled Add button,
and every keystroke fired a preview request for an id that was still being
typed.

- persistent help text under the field points at the number in the list URL
- client-side validation now mirrors the server pattern, including the
  12 digit cap, instead of accepting any positive integer
- the preview waits 400ms after the last keystroke and only fires for a
  well-formed id
- a preview that cannot be read (unknown id, private list, upstream error)
  renders an explicit message saying so

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every MediaSlider on Discover issued its two initial page requests on
mount, so a customised Discover with a dozen rows fired two dozen requests
up front for posters most of which are never scrolled to. TMDB list rows
make that worse, since each one is a distinct upstream list.

Rows now hold their SWR key back until an IntersectionObserver reports the
row heading within 600px of the viewport, which keeps above-the-fold rows
loading immediately and defers the rest. Environments without
IntersectionObserver, and rows opting out with `lazy={false}` (the slider
previews, whose result count gates the Add button), fetch as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both slider navigation buttons were icon-only with no accessible name, so
screen readers announced two unlabelled buttons on every row of every
discover page. They now carry localized aria-labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The OpenAPI request validator coerces the documented integer page query
parameter to a number before the route handler runs, so rejecting every
non-string value returned 400 for all requests, including the default
page. Accept both representations; the safe-integer check still rejects
malformed input.
@judarange
judarange requested a review from a team as a code owner August 3, 2026 22:26
@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 318f7c95-c27c-42d4-8e1c-0bbce50f0830

📥 Commits

Reviewing files that changed from the base of the PR and between 15beb1a and a75550c.

📒 Files selected for processing (5)
  • server/api/themoviedb/interfaces.ts
  • server/lib/medialists/index.ts
  • server/lib/medialists/providers/tmdb.ts
  • server/routes/discover.test.ts
  • src/components/Discover/CreateSlider/index.tsx

📝 Walkthrough

Walkthrough

Added TMDB public-list support across provider contracts, caching, API routes, slider administration, and frontend rendering. The change supports mixed movie and TV results, pagination, unavailable lists, validation, localized labels, lazy loading, and cache fallback behavior.

Changes

TMDB list discovery

Layer / File(s) Summary
Provider contracts and TMDB mapping
seerr-api.yml, server/api/themoviedb/..., server/lib/medialists/..., server/constants/discover.ts
Added typed list responses, provider contracts, strict payload validation, mixed media mapping, and the TMDB_LIST slider type.
Cached page resolution and hydration
server/lib/cache.ts, server/lib/medialists/index.ts
Added language normalization, page caching, bounded hydration, cache eviction, stale fallback, and concurrent refresh deduplication.
Discover routes and validation
server/routes/discover.ts, server/routes/settings/discover.ts, server/routes/discover.test.ts
Added the paginated list endpoint, slider validation before persistence, API documentation, and tests for authentication, pagination, caching, malformed payloads, unavailable lists, and provider failures.
Slider creation and rendering
src/components/Discover/..., src/pages/discover/list/[listId]/index.tsx, src/components/MediaSlider/index.tsx, src/components/Slider/index.tsx, src/i18n/locale/en.json
Added list selection, debounced previews, list-page rendering, slider links and labels, lazy loading, empty-state handling, and accessible navigation labels.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: 0xsysr3ll, gauthier-th

Poem

A rabbit maps each public list,
With movies and shows in order kissed.
Cached pages hop when needed most,
Lazy sliders wake near the post.
TMDB lists now fill the scene.

🚥 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 describes the primary change: adding a custom TMDB list slider for Discover.
Linked Issues check ✅ Passed The PR fulfills issue #1837 by adding an administrator-configurable TMDB List slider that fetches public list contents through the TMDB API.
Out of Scope Changes check ✅ Passed The changes support the stated TMDB List slider objectives, including caching, browsing, lazy loading, validation, and accessibility.

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.

@judarange

Copy link
Copy Markdown
Author

Screenshots — Discover page with several TMDB list sliders (production instance, French locale):

screenshot-1785795947835-6

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
server/routes/discover.test.ts (1)

363-371: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the cache ceiling and the language normalization.

The suite covers freshness, single-flight, stale fallback, and residency well. Two new behaviors in server/lib/medialists/index.ts remain untested:

  • evictOverflow and MAX_CACHE_ENTRIES: no test proves that a burst of distinct list/page combinations stops growing the cache, or that the oldest entry is dropped first.
  • normalizeMediaListLanguage: no test proves that variant locales such as en, EN, and en-us collapse to one cache key, and that an unrecognized value collapses to the empty string.

Both are the mechanisms that bound request-controlled cache growth, so a regression there is silent.

Do you want me to generate these two tests?

🤖 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.test.ts` around lines 363 - 371, Add tests to the
media list caching suite covering both cache-bounding behaviors in
server/lib/medialists/index.ts: exercise enough distinct list/page combinations
to exceed MAX_CACHE_ENTRIES and assert evictOverflow removes the oldest entry
while keeping the cache capped, then request equivalent en, EN, and en-us
language variants and verify they share one cache key, while an unrecognized
language produces the empty-string normalization key.
🤖 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/api/themoviedb/interfaces.ts`:
- Around line 96-105: Update handling of TmdbListResponse.items so list entries
missing media_type are normalized to media_type "movie" before union validation
or conversion. Preserve existing movie and TV handling when media_type is
present, and ensure classic movie-only lists do not trigger the malformed-list
error path.

In `@src/components/Discover/CreateSlider/index.tsx`:
- Around line 86-103: Update the list-ID change handling around the
debouncedListId flow to call onNewTitles(0) immediately when the raw list ID
changes, before the debounced value is updated. Preserve the existing validation
and request-error reset behavior in the effect, while ensuring a valid
replacement ID cannot retain the previous preview count during the debounce
interval.

In `@src/components/Discover/DiscoverList/index.tsx`:
- Around line 21-35: Guard the useDiscover call in DiscoverListPage/DiscoverList
until router.isReady and router.query.listId is a valid string, preventing
requests to an /undefined endpoint during the initial client render. Pass the
resolved listId only after the dynamic route is ready, and update useDiscover if
necessary to safely skip a null endpoint while preserving normal fetching once
available.

---

Nitpick comments:
In `@server/routes/discover.test.ts`:
- Around line 363-371: Add tests to the media list caching suite covering both
cache-bounding behaviors in server/lib/medialists/index.ts: exercise enough
distinct list/page combinations to exceed MAX_CACHE_ENTRIES and assert
evictOverflow removes the oldest entry while keeping the cache capped, then
request equivalent en, EN, and en-us language variants and verify they share one
cache key, while an unrecognized language produces the empty-string
normalization key.
🪄 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 Plus

Run ID: 6aec66f6-5f5f-4613-8af2-d4eecd7edf26

📥 Commits

Reviewing files that changed from the base of the PR and between 090c659 and f5dfc44.

📒 Files selected for processing (20)
  • seerr-api.yml
  • server/api/themoviedb/index.ts
  • server/api/themoviedb/interfaces.ts
  • server/constants/discover.ts
  • server/lib/cache.ts
  • server/lib/medialists/index.ts
  • server/lib/medialists/providers/tmdb.ts
  • server/lib/medialists/types.ts
  • server/routes/discover.test.ts
  • server/routes/discover.ts
  • server/routes/settings/discover.ts
  • src/components/Discover/CreateSlider/index.tsx
  • src/components/Discover/DiscoverList/index.tsx
  • src/components/Discover/DiscoverSliderEdit/index.tsx
  • src/components/Discover/constants.ts
  • src/components/Discover/index.tsx
  • src/components/MediaSlider/index.tsx
  • src/components/Slider/index.tsx
  • src/i18n/locale/en.json
  • src/pages/discover/list/[listId]/index.tsx

Comment thread server/api/themoviedb/interfaces.ts Resolved
Jean-David Rioux and others added 3 commits August 3, 2026 19:15
Classic v3 lists are movie-only and their items omit the `media_type`
discriminator entirely; only v4 lists, which can mix movies and series,
carry one. The boundary validator rejected the missing field as a
malformed payload, so every classic list answered HTTP 500 instead of
rendering.

`TmdbListItem` now models the field as optional on the movie arm, and the
provider reads an absent discriminator as a movie, normalising it on the
way through. A `media_type` that is present but unsupported is still
treated as malformed.

Reported by CodeRabbit on seerr-team#3344.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The preview debounces for 400ms, during which the row below still shows
the previous list and its reported result count still stands. Replacing a
working id with another well-formed one therefore left the Add button
enabled on the strength of a preview belonging to a different list.

The raw id changing now zeroes the count immediately, so the button can
only re-enable once the new preview has actually returned titles.

Reported by CodeRabbit on seerr-team#3344.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two guards that keep the media list cache from being driven by request
input had no tests. Adds coverage for both, exporting the entry ceiling so
the test cannot drift from the implementation:

- locales differing only in case or padding collapse to one cache key, a
  genuinely different locale gets its own, and anything unusable falls back
  to the single empty-language key
- filling the cache past its ceiling holds the entry count and evicts the
  least recently fetched page

Also covers a classic v3 list whose items carry no media_type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

👋 @judarange, thank you for your contribution!
However, this pull request has been closed because it appears to contain a significant amount of AI-generated code without sufficient human review or supervision.
AI-generated code can often introduce subtle bugs, poor design patterns, or inconsistent styles that make long-term maintenance difficult and reduce overall code quality. For the sake of the project's future stability and readability, we require that all contributions meet our established coding standards and demonstrate clear developer oversight.
This pull request is also too large for effective human review. Please discuss with us on how to break down these changes into smaller, more focused PRs to ensure a thorough and efficient review process. If you'd like to revise and resubmit your changes with careful review and cleanup, we'd be happy to take another look.

@github-actions github-actions Bot closed this Aug 4, 2026
@github-actions github-actions Bot locked as spam and limited conversation to collaborators Aug 4, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Support for Custom Sliders Based on External or Dynamic Sources (e.g. RSS, TMDB Lists, Keywords)

2 participants