# Marquee API v1 — full reference The JSON API native clients (Marquee for Mac) use as their only backend. Every piece of data a website page shows and every action it offers is reachable here, with the same permission rules. Available from server version **0.22.0**. This document extends the fixed core contract (`MarqueeMac/Docs/API_V1_CORE.md`). Everything in the core contract holds; the section below lists the few places where the real server needed something the core contract didn't spell out. --- ## ⚠️ Deviations from / additions to the core contract 1. **Image paths are not always TMDb-relative.** `posterPath`, `backdropPath`, `profilePath`, `logoPath` and `stillPath` are normally TMDb paths (`"/abc.jpg"`), but a poster or backdrop can be a **full `https://` URL** (a TheTVDB artwork fallback the server stores when TMDb has no image). Rule for clients: if the value starts with `http://` or `https://`, use it as-is; otherwise build `https://image.tmdb.org/t/p/`. (Same rule as the website's `tmdbImageUrl`.) 2. **`deviceName` is optional** on login and setup. Missing/blank becomes `"Unnamed device"`; longer than 100 characters is truncated. 3. **Setup can return `429 rate_limited`.** The web `/setup` action allows 5 attempts per hour per client IP (first `X-Forwarded-For` hop, else "unknown"); `POST /auth/setup` shares that bucket. 4. **Token expiry granularity.** `expires_at` slides forward at most once per hour, so a token expires 90 days after its last *recorded* use (up to one hour earlier than 90 days after the literal last request). The `expiresAt` returned at login/setup is 90 days from issue. An expired token is deleted the first time it is presented. 5. **Password changes revoke the caller's own token too.** `PATCH /users/{id}` with a `password` deletes every token of that account — including the calling device's when you change your own password. Sign in again. 6. **Not every list is paginated.** Only the Movies/Series grids use the `{page, totalPages, totalResults, results}` shape. Lists the website shows whole are returned whole as `{"results": [...]}`. For the grids, a "page" is a batch of several TMDb pages (see `GET /movies`), so page sizes vary and `totalResults` is TMDb's count before de-duplication and hide-owned filtering. 7. **TMDb not configured is reported as `502 upstream`.** The website renders TMDb-backed pages as empty shelves when no TMDb credential exists. The API instead answers every TMDb-backed endpoint with `502 upstream` and `"reason": "tmdb_not_configured"` (0.50+; the message names Settings → General). A server older than 0.50 sends no reason, only this body, which clients match word for word: `502 {"code":"upstream","error":"TMDb isn't configured on this server. An admin needs to add a TMDb access token in Settings → Integrations."}`. 8. **`X-Marquee-API: 1` on everything under `/api/v1`**, including responses Next.js generates itself (`405 Method Not Allowed`, automatic `OPTIONS`). Those two have empty bodies rather than the `{error, code}` shape. Unknown `/api/v1/...` paths return a normal `404 not_found` JSON error. 9. **Season requests (additive).** `POST /titles/tv/{id}/request` takes an optional `{"seasons": [1, 2]}`; the request DTOs carry `seasons` and `seasonsLabel`; the title detail's `viewer` has `canRequestSeasons` and `requestedSeasons`, and each `seasons[]` entry has `monitored`, `requested` and `requestable`. A client that sends no `seasons` gets the whole-series request it always did, and a server older than this simply leaves the new fields out — treat missing as null/false and keep the whole-series Request button. 10. **Sign in with Plex / Jellyfin, linked accounts, import (additive).** `GET /server-info` has `signIn`; new public `POST /auth/plex/start`, `POST /auth/plex/poll` and `POST /auth/jellyfin` answer like `POST /auth/login`; `/me` and `HouseholdMember` carry `linked` and `hasPassword`; `/me/links/*`, `/users/import/{provider}` and `/settings/sign-in` are new. A server older than this has no `signIn` in `server-info` — offer password sign-in only. Accounts made through Plex or Jellyfin may have no password (`hasPassword: false`): they set their first one without `currentPassword`. New error code `410 expired` (a Plex poll whose handle is used, unknown or past its 10 minutes). 11. **Any number of Sonarr / Radarr servers (0.43+, additive).** New `/settings/arr-servers` endpoints manage them; `GET /settings/integrations` gains `arrServers`. The fixed `sonarr` / `radarr` / `sonarr4k` / `radarr4k` objects and endpoints keep working and now describe **the default server** of each kind (standard or 4K). `POST /requests/{id}/approve` and `POST /titles/{type}/{tmdbId}/add` take optional overrides (server, quality profile, root folder, tags, series type), and the new `GET /titles/{type}/{tmdbId}/add-options` lists what can be picked. A server older than this answers `404 not_found` on the new endpoints — hide the "Advanced" options and the server list there. 12. **Single sign-on and Jellyfin Quick Connect (0.44+, additive).** `GET /server-info`'s `signIn` gains `sso` (`{ name, signup }` or null) and `quickConnect`; `linked` (on `/me` and `HouseholdMember`) gains `sso`. New public `POST /auth/sso/start` + `POST /auth/sso/poll` and `POST /auth/jellyfin/quick-connect/start` + `…/poll` answer like the Plex PIN flow; new `/me/links/sso/*` and admin `/settings/sso`. A server older than this has neither field — treat missing as null/false and don't offer the buttons. 13. **"Request all N missing" for household members (additive).** The title detail's `franchise` gains `requestAllMissing`, and the new `POST /titles/{type}/{tmdbId}/request-all-missing` requests them all in one go. A server older than this leaves the field out — treat missing as an empty list and don't show the button. 14. **Personal notifications (0.45.0+, additive).** Every account can add its own channels (`/me/notification-channels`) and choose which events reach the bell, device push and each channel (`/me/notification-preferences`); the admin picks what the household channels post (`/settings/notification-events`). `NotificationItem` gains `alert`: false means the account turned device push off for that kind, so an app shows no banner (it's still in the bell). A server older than this answers `404 not_found` on the new endpoints and sends no `alert` — treat missing as true and hide the new screens. 15. **"Can't find" (0.46+, additive).** An approved, released request Sonarr/Radarr still has nothing for (no file, nothing downloading) a while after approval is flagged: `GET /requests/not-found` lists them for reviewers, with `POST /requests/{id}/not-found/search` ("Search again") and `…/not-found/dismiss` ("Mark as found"). `notFoundSince` appears on `/requests/history` rows and the title's `viewer` (reviewers only), `GET /badges` gains `notFoundRequests`, notifications gain the `request_not_found` event type, and the preferences gain the `request_not_found` and `request_still_looking` events. The wait is `GET`/`PUT /settings/not-found`. A server older than this sends none of these fields — treat missing as null/0 — and answers `404` on the new endpoints: hide the section. 16. **Request lifecycle and conversations (0.46+, additive).** A member can change (`PATCH /requests/{id}`) or cancel (`DELETE /requests/{id}`) their own request while it's pending; reviewers can change anyone's before approving (`GET /requests/{id}/edit-options` feeds the season picker). Requests and problem reports have a comment thread (`/requests/{id}/comments`, `/issues/{id}/comments`) between the requester or reporter and the reviewers, with the `request_comment` and `issue_comment` notification types. An approval Sonarr/Radarr couldn't be reached for now stays approved under "Couldn't add" (`addFailed` on `/requests/history`, `failedRequests` on `/badges`) with `POST /requests/{id}/retry`. New fields: `canEdit`, `canCancel`, `editedAt`, `commentCount` on request DTOs, `commentCount` on `Issue`, `requestId`/`issueId` on `NotificationItem`, `viewer.myRequests` on the title. A server older than this leaves them out — treat missing as false/0/null/empty — and answers `404` on the new endpoints: hide Edit, Cancel, Retry and the comments. 17. **API keys, an OpenAPI description and a widget summary (0.47+, additive).** The admin can issue API keys for tools and scripts (`/settings/api-keys`, §16); any `/api/v1` endpoint accepts one as `X-Api-Key: mq_…` or `Authorization: Bearer mq_…`, within its scope. `GET /openapi.json` (public) describes every endpoint, and `GET /stats/summary` gives dashboard widgets their counts. New error answers only ever reach a caller using a key: `403 forbidden` outside the key's scope and `429 rate_limited` after too many wrong keys. A server older than this answers `404` on the new endpoints — hide the API keys card. 18. **Customisable Discover rows and Trakt syncs (0.49+, additive).** The admin arranges Discover for the household (`/settings/discover`, §2): reorders and hides the built-in rows and adds their own (a TMDb keyword, genre, studio, network or list, a public Trakt list, or recently added to the library). `GET /discover` keeps every fixed key — a hidden built-in row is an empty array there, so an older app simply doesn't show it — and gains `shelves`, the rows in the admin's order with an `id`, `kind` and `title` each. A custom row's "See all" is `GET /discover/lists/{its id}`. Members can keep a public Trakt watchlist or list in sync (`/trakt-syncs`, §11): new titles on it are requested as them every few hours (the `trakt-sync` job). A server older than this sends no `shelves` — render the fixed keys as before — and answers `404` on the new endpoints: hide Settings › Discover and the Trakt card. 19. **Languages (0.50+, additive).** `GET /me` gains `language` (the account's chosen language — `en`, `es`, `fr`, `de`, `pt-BR` — or null to follow the device) and `PATCH /me` sets it. Every text the server writes for people (errors, labels, notifications) is in that language, else the request's `Accept-Language`, else English; codes never change. A server older than this sends no `language` — follow the system language — and answers `405` on `PATCH /me`: hide the picker. 20. **Library page (0.51+, additive).** The household library in one place (§17): `GET /library` lists everything Plex, Jellyfin, Sonarr and Radarr have, with filters, sort and paging; `/library/collections-missing`, `/library/duplicates` (admin) and `/library/storage` are its other tabs. A server older than this answers `404` on all four: hide the Library section. 21. **Title page, Discover and profiles, after Seerr (0.53+, additive).** `GET /titles/{type}/{tmdbId}` gains `play` ("Play on Plex / Jellyfin / Emby" links), `facts.ratings` (IMDb / Rotten Tomatoes / Metacritic, with an OMDb key — `PUT /settings/integrations/omdb`), more facts (original title, theatrical and digital release, budget, revenue, studio), the streaming region with TMDb's "where to watch" link, and `viewer.autoApprove`. `GET /discover` gains `watchlist` ("Your Watchlist", from the Plex Watchlist sync; See all is `GET /discover/lists/watchlist`). Request DTOs gain `backdropPath` and who reviewed them (`reviewedBy`). New: `GET /users/{id}/profile` (§11), `GET` / `PUT /settings/discover/locale` (§2). A server older than this omits the new fields — hide what they drive — and answers `404` on the new endpoints. 22. **Search in sections (0.55+, additive).** `GET /search` gains `sections` — movies, series, people, studios & networks, in the order to show them, each ranked with its total — and `theme.placement`; the older `people`/`studios`/`titles` stay. `SearchCompanyCard` adds networks (`kind`), people gain `knownFor`. New: `GET /search/{section}`, a section's See all (§2). `GET /search/suggest` now groups its results (movies, series, people) and, with `?include=company,network`, adds studios and networks. A server older than this omits `sections` — fall back to the old fields — answers `404` on `/search/{section}` (hide See all), and ignores `include`. 23. **Person and studio headers (additive).** `GET /people/{tmdbId}` and `GET /companies/{tmdbId}` gain `knownForTitle` — the title they're best known for, whose backdrop goes behind the header with a "From {name}" link to its page — and `externalLinks` (a person's IMDb, socials and website; a studio's website). A server older than this omits both — keep the plain header and show no links. 24. **Open in Radarr / Sonarr (0.63+, additive).** The title's `viewer` gains `arrLinks` — the title's own page on each Radarr/Sonarr server that has it, standard and 4K — for the admin and accounts with `reviewRequests` (empty for everyone else). Each server (`/settings/arr-servers`) gains an optional `publicUrl` ("Public URL (for links)") the links use instead of `baseUrl`. A server older than this omits both — show no links and no field. 25. **TMDb's words in the reader's language (additive, no new fields).** What comes from TMDb — titles, overviews, taglines, genres, season and episode names, posters and logos with text, trailers, a person's biography and credits, Discover rows, search results — follows the same language as the server's own text (the account's `language`, else `Accept-Language`, else English). Anything TMDb hasn't translated falls back to English field by field; search matches names in either. Ids, dates, `year`, order and `originalTitle` never change with the language, so cache and sort on those — and refetch a title after `PATCH /me` changes the language. Lists read from the server's own records (requests, library, favorites, notifications) keep English names unless a translation has been saved already. A server older than this answers in English whatever the language. 26. **The rest of the collection (0.72+, additive).** New `GET /titles/movie/{tmdbId}/collection-rest` lists the other movies of a movie's TMDb collection the viewer can still add (the admin) or request (a member), and `POST …/collection-rest` adds or requests them all. The apps ask right after a movie's own Add or Request goes through, and offer them in one go ("Part of The Matrix Collection — 3 other movies aren't in your library yet. Add them too?"). A server older than this answers `404` — offer nothing. 27. **Network pages (0.76+, additive).** New `GET /networks/{tmdbId}` answers a `CompanyDetail` for a TV network — its series newest first, its best-known series behind the header, its website; `description` is null and `favorited` always false (no favorite button). Network logos on Discover and network search results open it instead of `/series?network=`. A server older than this answers `404` — open the Series page filtered to the network, as before. --- ## Conventions (recap) - Base: `{server}/api/v1`. JSON bodies, `Content-Type: application/json`, camelCase keys. - Auth: `Authorization: Bearer mqt_<43 base64url chars>` on everything except `server-info`, `openapi.json`, `auth/login`, `auth/setup`, `auth/plex/start`, `auth/plex/poll`, `auth/jellyfin`, `auth/jellyfin/quick-connect/start`, `auth/jellyfin/quick-connect/poll`, `auth/sso/start` and `auth/sso/poll`. Tools use an admin-issued API key instead (0.47+): `X-Api-Key: mq_<43 base64url chars>` or `Authorization: Bearer mq_…` — see §16. - Timestamps: ISO-8601 UTC with milliseconds. Calendar dates: `"YYYY-MM-DD"`. `year` fields are 4-character strings (`"1999"`) or `null`. - Nullable fields are always present. - Every response: `X-Marquee-API: 1`, `Cache-Control: no-store`. - Action endpoints that take no parameters accept an empty body or `{}`. ### Errors Non-2xx responses are `{"error": "", "code": ""}`. | Status | code | When | |---|---|---| | 400 | `invalid` | Bad input — the message is the website's own validation text | | 401 | `unauthorized` | Missing / malformed / unknown / expired / revoked token (or API key) → return to sign-in | | 401 | `invalid_credentials` | Wrong username or password at login | | 403 | `forbidden` | Admin-only endpoint called by a member, an action the caller may not take, or a call outside an API key's scope | | 404 | `not_found` | Unknown id, bad path segment (non-numeric id, unknown media type), unknown endpoint | | 409 | `conflict` | State conflict: already requested, already reviewed, integration not connected / not fully configured, not tracked in Sonarr/Radarr | | 409 | `setup_complete` | `auth/setup` once an account exists | | 410 | `expired` | Plex, SSO or Quick Connect sign-in/link poll with a handle that's used, unknown or older than 10 minutes — start again | | 429 | `rate_limited` | Login, setup, Plex/Jellyfin sign-in rate limit; too many wrong API keys | | 500 | `internal` | Server bug; details are only in the server log | | 502 | `upstream` | A connected service (TMDb, Sonarr, Radarr, Plex, Jellyfin, Trakt…) failed, timed out, or TMDb isn't configured | Business-rule messages are exactly the website's strings, so they match the **Error reference** (`GET /help/errors`). Since 0.50 they're written in the reader's language (see `PATCH /me`), so never match on the text: the two failures a client acts on carry a stable `reason` beside `code` (0.50+; an older server sends none — fall back to comparing the English text), e.g. `{ "error": "No se pudo identificar esta serie para Sonarr.", "code": "conflict", "reason": "sonarr_unresolved" }`: - `"reason": "sonarr_unresolved"` — approving a TV request failed with `409` **`Couldn't resolve this show for Sonarr.`** The website then offers "Manually approve" (`POST /requests/{id}/manual-approve`) and an "Add manually in Sonarr" link built as `{sonarrUrl}/add/new?term={url-encoded title}`. - `"reason": "tmdb_not_configured"` — the `502 upstream` of deviation 7. ### Auth levels - **public** — no token. - **user** — any signed-in account (admin or member). - **admin** — `403 forbidden` for members. An API key (§16) signs in as the admin who made it, or as the member it "acts as", and so gets exactly that account's level — narrowed further by its scope and by the endpoints no key may call. Members see the household library through the admin: every library status is computed against `libraryOwnerId` (from `/me`), exactly like the website. ### Shared shapes #### `TitleCard` (a poster card) ```json { "mediaType": "movie", "tmdbId": 603, "name": "The Matrix", "posterPath": "/aOIuZAjPaRIE6CMzbazvcHuHXDc.jpg", "year": "1999", "subtitle": null, "overview": null, "rating": null, "status": null, "favorited": false, "requested": null, "canQuickAdd": false, "canRequest": false, "episodes": null } ``` | Field | Type | Meaning | |---|---|---| | `mediaType` | `"movie" \| "tv"` | | | `tmdbId` | int | | | `name` | string | | | `posterPath` | string \| null | see deviation 1 | | `year` | string \| null | | | `subtitle` | string \| null | Role/character on a person's credits; genre name on the Movies/Series grid; otherwise null | | `overview`, `rating` | string \| null, number \| null | Movies/Series grid only (`rating` = TMDb vote average 0–10) | | `status` | `LibraryStatus` \| null | null = not in the library (the website shows no badge) | | `favorited` | bool \| null | null where the website shows no favorite star on that list | | `requested` | bool \| null | You already have a pending or approved request; null on lists that offer no quick action (Favorites) | | `canQuickAdd` | bool | Show the "+ Add" quick action (`POST /titles/{type}/{id}/add`): the admin, with Radarr/Sonarr set up for the type, on a title not in the library | | `canRequest` | bool | Show the "Request" quick action: a member with the request permission for the type, on a title not in the library, not blocked, not already requested — when `requested` is true, show "Requested" instead | | `episodes` | `{ "have": int, "total": int }` \| null | Series in the library only (newer servers; older ones leave it out, so decode it as optional): aired episodes with a file on disk (`have`) against episodes aired so far (`total`), specials (season 0) left out — the poster's "96/96" on the right of its title line (`have` never exceeds `total`), muted when they're equal, in the Downloading tone while some are missing. From Sonarr where it tracks the show, else the media server's files against TMDb's aired count. Null for a movie, a show the library doesn't have, or when no source counts it | Every list that returns TitleCards fills these three in the same way (newer servers; older servers left them false/null on Discover's shelves and never sent `canRequest` outside the title page), so a card's button is read from the card alone: `canQuickAdd` → Add, else `requested` → Requested, else `canRequest` → Request, else nothing. `LibraryStatus`: `"owned"` (badge "Owned" / "Already in your library"), `"tracked_downloading"` ("Downloading"), `"ready_to_move"` ("Ready to move" — 0.71+: the download finished but Sonarr/Radarr didn't import it, so it's in the download folder waiting to be moved into the library by hand; older servers send `"tracked_downloading"`), `"tracked_monitored"` ("Missing"), `"tracked_unmonitored"` ("Not monitored" — newer servers only: in Sonarr/Radarr but not monitored, nothing on disk, so it won't download on its own; older servers send `"untracked"` or null for these), `"coming_soon"` ("Coming soon"), `"untracked"` ("Not owned" / "Not in your library"). It's an open set: treat any value you don't know as neutral (no color, no strip). Colors, the same as Radarr's and Sonarr's own legends: `owned` green, `tracked_downloading` purple, `ready_to_move` teal, `tracked_monitored` red, `tracked_unmonitored` orange, `coming_soon` blue, `untracked` none (grey label, no poster strip). `tracked_unmonitored` is still requestable (`canRequest`) — an approved request turns monitoring back on — and admins get "Start monitoring" (`arrTracking`) rather than Add. #### `PersonCard`, `CompanyCard`, `NetworkCard` ```json { "tmdbId": 6384, "name": "Keanu Reeves", "profilePath": "/8RZL.jpg", "knownForDepartment": "Acting", "favorited": false } { "tmdbId": 420, "name": "Marvel Studios", "logoPath": "/hUze.png", "favorited": false } { "tmdbId": 213, "name": "Netflix", "logoPath": "/wwem.png" } ``` `favorited` is null where the website shows no star (Discover's Studios shelf). On the search page (0.55+) a person may also carry `knownFor` — up to three titles, best known first — and a studio or network is a `SearchCompanyCard`: a `CompanyCard` plus `kind`, `"studio"` (opens `GET /companies/{id}`) or `"network"` (opens `GET /networks/{id}`, 0.76+; `favorited` is always null). #### `RequestPerson` ```json { "userId": "83c55a49-…", "displayName": null, "username": "member1", "label": "member1" } ``` `label` is what the website prints (display name, else username). `userId` is null where the underlying query doesn't carry it. --- ## 1. Discovery & Auth ### `GET /server-info` — public Cheap: a few small queries (3 s timeout together), no calls to integrations. ```json { "app": "marquee", "apiVersion": 1, "version": "0.22.0", "setupComplete": true, "status": "ok", "signIn": { "password": true, "plex": true, "jellyfin": true, "jellyfinName": "Jellyfin", "signup": true, "quickConnect": true, "sso": { "name": "Authentik", "signup": false } } } ``` `signIn` says which sign-in buttons to show: `plex` / `jellyfin` are true while the admin has that server connected in Settings → Media servers (Plex also needs its first library sync done). Missing on older servers — show password sign-in only. `jellyfinName` (0.40+; treat missing as "Jellyfin"): what to call the "jellyfin" server. Emby speaks the same API, so the Jellyfin integration works with an Emby server as it is; once the first sync has seen it, this is `"Emby"`. Label everything about that server with it — the sign-in button ("Sign in with Emby"), its username/password fields, Linked accounts, "Import from Emby", member tags. The server's own messages already use it ("Incorrect Emby username or password"). `signup` (0.42.2+; treat missing as false): the admin has "New accounts from Plex/Jellyfin sign-in" on (`GET /settings/sign-in`) and at least one of `plex` / `jellyfin` is true — signing in with it makes a member account for anyone with access to the admin's server. Tell newcomers on the sign-in screen, under the Plex/Jellyfin buttons, naming only the methods offered (e.g. "New here? Use Sign in with Plex — your account is made for you."). False: say nothing; someone without an account gets `403` "There's no Marquee account for this Plex account yet. Ask the admin to add you.". `quickConnect` (0.44+; treat missing as false): `jellyfin` is on and the server is Jellyfin (Emby has no Quick Connect) — offer "Use Quick Connect" on the Jellyfin sign-in (see `POST /auth/jellyfin/quick-connect/start`). Whether Jellyfin has it switched on is only known when it's started (`409`). `sso` (0.44+; treat missing as null): single sign-on is set up — show a "Sign in with {name}" button (see "Sign in with single sign-on" below). `sso.signup`: new accounts from SSO sign-in are on; mention it in the newcomer line like `signup` (e.g. "New here? Use Sign in with Authentik — your account is made for you."). Database unreachable → still `200` with `"setupComplete": null, "status": "degraded"` and `signIn` password-only. Legacy (pre-0.22.0) servers redirect this path to `/login` (HTML) — see core contract. ### `POST /auth/login` — public Body: | Field | Type | | |---|---|---| | `username` | string | required, exact (case-sensitive) | | `password` | string | required | | `deviceName` | string | optional (deviation 2) | ```json { "token": "mqt_qHnY…43 chars…", "expiresAt": "2026-12-16T17:10:57.920Z", "user": { "id": "54caac33-73d6-4864-8e12-1ea6b212d2f1", "username": "timmy", "displayName": "Timmy", "role": "admin", "permissions": { "requestMovies": true, "requestTv": true, "request4kMovies": true, "request4kTv": true, "autoApproveMovies": true, "autoApproveTv": true, "autoApprove4kMovies": true, "autoApprove4kTv": true, "advancedRequests": true, "viewRequests": true, "reviewRequests": true, "manageIssues": true, "reportIssues": true, "manageBlocklist": true, "bypassLimits": true }, "libraryOwnerId": "54caac33-73d6-4864-8e12-1ea6b212d2f1", "avatarUrl": null } } ``` `user.permissions` (0.48+): as on `GET /me`. Errors: `400 invalid` ("Enter your username and password." — missing/blank field), `401 invalid_credentials` ("Incorrect username or password"), `429 rate_limited` ("Too many attempts. Try again in a few minutes."). Rate limits are the web sign-in's own buckets: after 5 failures for a username or 20 for an IP within 15 minutes, both web and API sign-in are refused until the window ends. Only failures count. The IP is the first `X-Forwarded-For` hop, else `X-Real-IP`. ### `POST /auth/setup` — public First-run only: creates the first account as **admin** (same validation as the web setup form) and signs this device in. | Field | Type | Rules | |---|---|---| | `username` | string | 3–32 chars, letters/numbers/`_ . -` | | `password` | string | ≥ 8 chars | | `displayName` | string | optional, 1–80 chars (blank = none) | | `deviceName` | string | optional | Response: same shape as login (`libraryOwnerId` = the new admin's id). Errors: `409 setup_complete` ("Setup has already been completed. Please sign in instead." — checked first), `429 rate_limited` ("Too many attempts. Try again later."), `400 invalid` (first failing rule, e.g. "Username must be at least 3 characters", "Password must be at least 8 characters"). ### `POST /auth/logout` — user Revokes the calling token only (other devices stay signed in). ```json { "ok": true } ``` ### Sign in with Plex / Jellyfin — public Who may sign in: a **Plex** account the admin's Plex server is shared with (a friend, a Plex Home user, or the server's owner), or a **Jellyfin** user of the admin's Jellyfin server. The first sign-in of someone with no linked Marquee account creates a **member** account for them (username from Plex/Jellyfin, made unique with a number; no password) — unless the admin turned "New accounts from Plex/Jellyfin sign-in" off (`GET /settings/sign-in`), then it's `403` "There's no Marquee account for this Plex account yet. Ask the admin to add you." (with "Jellyfin"/"Emby" for those). Accounts are matched only by a link to that Plex/Jellyfin user, never by username or email; the Plex account that owns the server signs in as the admin (and is linked to it) if the admin has no Plex link yet. #### `POST /auth/plex/start` — public No body. Starts a Plex PIN on the server. ```json { "handle": "u8Zq3…43 chars…", "authUrl": "https://app.plex.tv/auth#?clientID=…&code=…&context%5Bdevice%5D%5Bproduct%5D=Marquee", "expiresAt": "2026-09-25T17:40:00.000Z" } ``` Open `authUrl` in the default browser, then poll with `handle` every 2 s until `expiresAt` (10 minutes). The handle is the only way to reach this sign-in — keep it to yourself; the Plex token never leaves the server. Errors: `409 conflict` "Plex sign-in isn't set up on this server.", `502 upstream` "Couldn't start Plex sign-in. Try again.", `429 rate_limited` (30 starts per client address per 10 minutes; 120 shared when the server can't tell addresses apart). #### `POST /auth/plex/poll` — public | Field | Type | | |---|---|---| | `handle` | string | required, from `start` | | `deviceName` | string | optional (deviation 2) | - **`202`** `{ "status": "pending" }` — not approved on plex.tv yet; poll again. - **`200`** — signed in; exactly the body of `POST /auth/login` (`{ token, expiresAt, user }`). The handle is used up. - **`410 expired`** "That Plex sign-in expired. Try again." — handle unknown, already used, or older than 10 minutes. - **`403 forbidden`** "This Plex account doesn't have access to this server." or "There's no Marquee account for this Plex account yet. Ask the admin to add you.". - `502 upstream` "Couldn't reach Plex. Try again." (the handle is used up; start again), `429 rate_limited`. ```bash curl -s -X POST "$SERVER/api/v1/auth/plex/start" # → {"handle":"u8Zq…","authUrl":"https://app.plex.tv/auth#?…","expiresAt":"…"} curl -s -X POST "$SERVER/api/v1/auth/plex/poll" -H 'Content-Type: application/json' \ -d '{"handle":"u8Zq…","deviceName":"Anna’s Mac"}' # → 202 {"status":"pending"} … then 200 {"token":"mqt_…","expiresAt":"…","user":{…}} ``` #### `POST /auth/jellyfin` — public | Field | Type | | |---|---|---| | `username` | string | required — the Jellyfin username | | `password` | string | required — the Jellyfin password | | `deviceName` | string | optional | Checked with the admin's Jellyfin server (`/Users/AuthenticateByName`); the password is sent there and nowhere else, and never stored. Response: same as `POST /auth/login`. Errors: `400 invalid` "Enter your Jellyfin username and password.", `401 invalid_credentials` "Incorrect Jellyfin username or password", `403 forbidden` "There's no Marquee account for this Jellyfin account yet. Ask the admin to add you.", `409 conflict` "Jellyfin sign-in isn't set up on this server.", `502 upstream` "Couldn't reach Jellyfin. Try again.", `429 rate_limited` — the same limits as `POST /auth/login`, in buckets of their own. ```bash curl -s -X POST "$SERVER/api/v1/auth/jellyfin" -H 'Content-Type: application/json' \ -d '{"username":"anna","password":"…","deviceName":"Anna’s PC"}' ``` #### `POST /auth/jellyfin/quick-connect/start` — public (0.44+) Jellyfin's Quick Connect (Jellyfin 10.8+; never Emby): sign in without typing a password, by approving a code in a Jellyfin app you're already signed in to. Offered when `signIn.quickConnect`. No body. ```json { "handle": "Qc7Lm…43 chars…", "code": "482915", "expiresAt": "2026-09-25T17:40:00.000Z" } ``` Show `code` large ("In a Jellyfin app you're signed in to, open your profile → Quick Connect and enter this code"), then poll with `handle` every 2 s until `expiresAt`. The Quick Connect secret stays on the server. Errors: `409 conflict` "Jellyfin sign-in isn't set up on this server." / "Emby doesn't have Quick Connect." / "Quick Connect is turned off on this Jellyfin server. The admin can turn it on in Jellyfin's Dashboard → General.", `502 upstream` "Couldn't reach Jellyfin. Try again.", `429 rate_limited` (same limits as `POST /auth/plex/start`). #### `POST /auth/jellyfin/quick-connect/poll` — public (0.44+) `{ "handle": "…", "deviceName": "…" }` (`deviceName` optional). Exactly like `POST /auth/plex/poll`: `202 { "status": "pending" }` until approved; then `200` with the body of `POST /auth/login`, once; `410 expired` "That Quick Connect code expired. Try again."; `403 forbidden` "There's no Marquee account for this Jellyfin account yet. Ask the admin to add you." (new accounts off) or "Jellyfin didn't accept that Quick Connect code. Try again.". Who it signs in as is decided exactly like `POST /auth/jellyfin` (the linked account, or a new member when allowed). ### Sign in with single sign-on — public (0.44+) The admin's own OpenID Connect identity provider (Authentik, Authelia, Pocket ID, Keycloak, Google…), set up in Settings → Members (`GET /settings/sso`). Offered when `signIn.sso` isn't null; label the button "Sign in with {sso.name}". The app never sees the provider's tokens or the client secret: the browser does the provider's sign-in, the server checks it, and the app polls for a Marquee token — like Plex. Who may sign in, in order: nobody outside the admin's "required group" (when one is set); the account linked to that identity (issuer + subject); with "match by verified email" on, the one non-admin account whose username is the identity's email, only when the provider marks it verified; a new member account when "New accounts from single sign-on" is on; otherwise `403`. Members in the admin's "trusted group" become `trusted` on sign-in; nobody is ever made admin this way. #### `POST /auth/sso/start` — public `{ "deviceName": "…" }` (optional — shown on the page the browser opens). ```json { "handle": "pX2vR…43 chars…", "authUrl": "https://marquee.example.com/login/sso/app?key=Hc9…43 chars…", "expiresAt": "2026-09-25T17:40:00.000Z" } ``` Open `authUrl` in the default browser — only if it's `https`, or on the server's own origin (it's always Marquee's own "Continue with {name}?" page, at the address the admin set as Marquee's; it goes on to the identity provider when the person continues). Then poll with `handle` every 2 s until `expiresAt` (10 minutes); keep it to yourself. Errors: `409 conflict` "Single sign-on isn't set up on this server.", `502 upstream` "Couldn't reach {name}. Try again.", `429 rate_limited` (30 per client address per 10 minutes). #### `POST /auth/sso/poll` — public `{ "handle": "…", "deviceName": "…" }` (`deviceName` optional; defaults to the one given to `start`). - **`202`** `{ "status": "pending" }` — not finished in the browser yet. - **`200`** — signed in; the body of `POST /auth/login`. The handle is used up. - **`410 expired`** "That sign-in expired. Try again." - **`403 forbidden`** "Your {name} account isn't allowed to use Marquee. Ask the admin to add you to the right group." / "There's no Marquee account for this {name} account yet. Ask the admin to add you." / "{name} sign-in was cancelled.". - `502 upstream` "Couldn't finish signing in with {name}. Try again, or ask the admin to check the server log." (the provider refused the code or the ID token didn't check out), `429 rate_limited`. The browser tab ends on a Marquee page saying "You're signed in — go back to the Marquee app" (or why not). ### `GET /me` — user ```json { "id": "54caac33-73d6-4864-8e12-1ea6b212d2f1", "username": "timmy", "displayName": "Timmy", "role": "admin", "permissions": { "requestMovies": true, "requestTv": true, "request4kMovies": true, "request4kTv": true, "autoApproveMovies": true, "autoApproveTv": true, "autoApprove4kMovies": true, "autoApprove4kTv": true, "advancedRequests": true, "viewRequests": true, "reviewRequests": true, "manageIssues": true, "reportIssues": true, "manageBlocklist": true, "bypassLimits": true }, "libraryOwnerId": "54caac33-73d6-4864-8e12-1ea6b212d2f1", "avatarUrl": "/api/v1/users/54caac33-73d6-4864-8e12-1ea6b212d2f1/avatar?v=1790334036549", "autoApproveMovies": true, "autoApproveTv": true, "createdAt": "2026-09-17T17:10:57.821Z", "linked": { "plex": true, "jellyfin": false, "sso": false }, "hasPassword": true, "requestLimits": { "movie": null, "tv": null }, "language": "fr" } ``` `language` (0.50+; an older server omits it — treat that as null): the language this account chose to read Marquee in — `en`, `es`, `fr`, `de` or `pt-BR` — or null to follow the browser or the app's system language. Apps show themselves in it when it's set (and one of theirs), and send it as `Accept-Language`. Set it with `PATCH /me`. `requestLimits` (0.39+; an older server omits it): the account's request limits, each null when that type isn't limited (always for the admin and trusted members). Otherwise `{ "limit": 5, "days": 7, "used": 5, "remaining": 0, "nextSlotAt": "2026-10-03T02:53:36.305Z" }` — `nextSlotAt` (while none is left) is when the oldest counted request ages out. Every request that wasn't declined counts, 4K and Watchlist ones included. The website shows members a line above their requests: "Movies: 3 of 5 requests left (every 7 days)" or "Movies: none left — more in 3 days". `linked` says which media-server accounts (and, 0.44+, single sign-on — treat a missing `sso` as false) this account signs in with (see "Linked accounts" in section 11). `hasPassword` is false for an account made by Plex/Jellyfin sign-in or import that hasn't set a password yet — it can set one with `PATCH /users/{id}` without `currentPassword`. `permissions` (0.48+; an older server omits it) is what this account may do — every switch, `true` or `false`, and always all `true` for the admin. Use it to decide what to show; the server enforces each one (`403 forbidden` without it), re-reading them on every request, so a change the admin makes applies straight away: | Permission | Lets them | Enforced by | |---|---|---| | `requestMovies`, `requestTv` | ask for movies / TV (and a show's seasons) | `POST /titles/{type}/{id}/request`, `request-all-missing`, Plex Watchlist sync, `PATCH /requests/{id}` | | `request4kMovies`, `request4kTv` | ask for the 4K copy (once there's a 4K Radarr/Sonarr) | the same, with `is4k` | | `autoApproveMovies`, `autoApproveTv`, `autoApprove4kMovies`, `autoApprove4kTv` | have that kind of request approved straight away | — | | `advancedRequests` | pick the server, quality profile, folder and tags — when asking for a title, and when approving or retrying one | `GET /titles/{type}/{id}/add-options`; the override fields of request / approve / retry | | `viewRequests` | see everyone's requests | `GET /requests/pending`, `/requests/history` | | `reviewRequests` | approve, decline, change and approve-all others' requests; Can't find and Couldn't add. Brings `viewRequests` with it | approve / reject / approve-all / retry, `/requests/not-found…`, `PATCH /requests/{id}` on someone else's, `pendingRequests` etc. on `/badges` | | `manageIssues` | see every problem report, mark them fixed, search again, remove any | `GET /issues` (all), `/issues/{id}/resolve`, `/search`, `DELETE /issues/{id}` | | `reportIssues` | report a problem | `POST /titles/{type}/{id}/issues` | | `manageBlocklist` | block titles and keywords | `/settings/blocklist…`, `/titles/{type}/{id}/block` | | `bypassLimits` | ignore request limits (`requestLimits` then null) | — | What's the admin's alone — settings, integrations, household accounts, API keys, single sign-on, sign-in, jobs, activity, Add buttons, relink, monitoring, manual approval — isn't a permission, and can't be granted. Treat an unknown permission as off, and a missing one as off too. Use `role` only for the admin's own UI (General/Services/Activity/Jobs settings tabs, Add buttons, household accounts). The role is re-read on every request, so a demotion takes effect immediately (`403`s). `role` is `admin`, `member`, or (0.39+) `trusted`. Since 0.48 a non-admin role is the preset nearest their `permissions`: `trusted` exactly when they're the Trusted preset (everything but the blocklist: the review queue and problem reports, requests approved straight away, Advanced options, no limits), `member` otherwise — including a custom mix. So an app that still goes by the role never shows more than the account may use. Approving adds the title with the admin's Sonarr/Radarr. Treat an unknown role like `member`. `autoApproveMovies` / `autoApproveTv` are the same as `permissions.autoApproveMovies` / `.autoApproveTv` (kept for older apps). ### `PATCH /me` — user (0.50+) Changes the account's own preferences. Only what's sent changes; the answer is the whole of `GET /me`, already in the new language. Body: `{ "language": "de" }` — one of `en`, `es`, `fr`, `de`, `pt-BR` (any case), or `null` to follow the browser / system language again. Anything else is `400 invalid` "language must be null or one of en, es, fr, de, pt-BR." A full-access API key may change it for the account it acts as; a read-only key can't. The website's picker is Settings › Account › Appearance › Language. **Languages everywhere else.** Since 0.50 every message the API writes for people — `error` texts, labels, notification texts — is in the account's chosen language, else the request's `Accept-Language` (for public endpoints like sign-in, and accounts that follow their device), else English. Codes (`code`, event and status names) never change with the language, so match on those, never on text. Notifications are written when they're created, in the recipient's language at that moment (an account that chose none reads the household's: the admin's language, else English); the household channels (Discord, ntfy, the webhook…) post in the household's language. ### `GET /badges` — user The header counters in one call, suitable for polling (website: bell every 30 s, requests badge every 20 s). ```json { "unreadNotifications": 2, "pendingRequests": 1, "openIssues": 1, "notFoundRequests": 1, "failedRequests": 0 } ``` Each count is `0` unless the account may act on it (as on the website): `pendingRequests`, `notFoundRequests` and `failedRequests` need `reviewRequests`; `openIssues` needs `manageIssues`. `openIssues` (0.38+; an older server omits it): open problem reports, also `0` for members. `notFoundRequests` (0.46+; an older server omits it): requests listed under "Can't find", `0` for members. `failedRequests` (0.46+; an older server omits it): approved requests listed under "Couldn't add", `0` for members. The website's Requests badge shows `pendingRequests + openIssues + notFoundRequests + failedRequests`, since all of them wait on that page. --- ## 2. Discover / Browse / Search All endpoints in this section need TMDb (`502 upstream` otherwise — deviation 7). ### `GET /discover` — user The Discover landing page, shelves in page order. Cards carry `status` and the viewer's quick action (`canQuickAdd` / `canRequest` / `requested`; newer servers) but no `favorited` (the website shows no favorite star on these shelves). Empty shelves are empty arrays — the website hides them. Genre tiles link to `/movies?genre=` / `/series?genre=`; studio logos to `/companies/{id}`; network logos to `/networks/{id}` (0.76+; `/series?network=` before). ```json { "recentlyAdded": [ /* TitleCard, from Plex/Jellyfin, newest first, max 20 */ ], "watchlist": [ /* TitleCard, your Plex Watchlist, newest first, max 20 */ ], "trending": [ { "mediaType": "tv", "tmdbId": 299939, "name": "Monster: The Lizzie Borden Story", "posterPath": "/57XS.jpg", "year": "2026", "subtitle": null, "overview": null, "rating": null, "status": null, "favorited": null, "requested": false, "canQuickAdd": true, "canRequest": false } ], "popularMovies": [ /* TitleCard ×20 */ ], "movieGenres": [ { "id": 28, "name": "Action", "backdropPath": "/qeQJ.jpg" } ], "upcomingMovies": [ /* TitleCard, release date today or later */ ], "studios": [ { "tmdbId": 2, "name": "Walt Disney Pictures", "logoPath": "/wdrC.png", "favorited": null } ], "popularSeries": [ /* TitleCard ×20 */ ], "seriesGenres": [ { "id": 10759, "name": "Action & Adventure", "backdropPath": "/…jpg" } ], "upcomingSeries": [ /* TitleCard */ ], "networks": [ { "tmdbId": 213, "name": "Netflix", "logoPath": "/wwem.png" } ], "seeAll": { "recentlyAdded": { "type": "list", "list": "recently-added", "mediaType": null }, "watchlist": { "type": "list", "list": "watchlist", "mediaType": null }, "trending": { "type": "list", "list": "trending", "mediaType": null }, "popularMovies": { "type": "browse", "list": null, "mediaType": "movie" }, "movieGenres": { "type": "browse", "list": null, "mediaType": "movie" }, "upcomingMovies": { "type": "list", "list": "upcoming-movies", "mediaType": null }, "studios": { "type": "browse", "list": null, "mediaType": "movie" }, "popularSeries": { "type": "browse", "list": null, "mediaType": "tv" }, "seriesGenres": { "type": "browse", "list": null, "mediaType": "tv" }, "upcomingSeries": { "type": "list", "list": "upcoming-series", "mediaType": null }, "networks": { "type": "browse", "list": null, "mediaType": "tv" } }, "shelves": [ { "id": "trending", "kind": "trending", "title": "Trending", "custom": false, "results": [ /* TitleCard */ ], "genres": null, "logos": null, "seeAll": { "type": "list", "list": "trending", "mediaType": null } }, { "id": "5b0f3c2e-8f7a-4d0e-9b1c-2a6d7e8f9a01", "kind": "keyword", "title": "Anime", "custom": true, "results": [ /* TitleCard */ ], "genres": null, "logos": null, "seeAll": { "type": "list", "list": "5b0f3c2e-8f7a-4d0e-9b1c-2a6d7e8f9a01", "mediaType": null } }, { "id": "movieGenres", "kind": "movieGenres", "title": "Movie Genres", "custom": false, "results": null, "genres": [ { "id": 28, "name": "Action", "backdropPath": "/qeQJ.jpg" } ], "logos": null, "seeAll": { "type": "browse", "list": null, "mediaType": "movie" } }, { "id": "studios", "kind": "studios", "title": "Studios", "custom": false, "results": null, "genres": null, "logos": [ { "tmdbId": 2, "name": "Walt Disney Pictures", "logoPath": "/wdrC.png" } ], "seeAll": { "type": "browse", "list": null, "mediaType": "movie" } } ] } ``` `shelves` (0.49+; an older server omits it — then show the fixed keys in the order above): the rows to show, **in the admin's order** (Settings › Discover), hidden ones left out, including the admin's own rows. Show them in this order, skipping empty ones as the website does. `id` is the built-in row's key or a custom row's uuid; `custom` says which. Exactly one of `results` (a poster row — every custom row and the built-in poster rows), `genres` (`movieGenres` → `/movies?genre=`, `seriesGenres` → `/series?genre=`) and `logos` (`studios` → `/companies/{id}`, `networks` → `/series?network=`) is non-null. Custom `kind`s: `keyword`, `genre`, `company`, `network`, `tmdbList`, `traktList`, `library` ("recently added"). **An app that doesn't know a `kind` shows `results` as a poster row, and skips the row when `results` is null.** Custom rows' cards carry `status` like the others; their `seeAll` is always `{ "type": "list", "list": "" }` → `GET /discover/lists/{id}`. The fixed keys stay for older apps: a hidden built-in row is an empty array there, and custom rows only appear in `shelves`. `watchlist` (0.53+; an older server omits it): "Your Watchlist" — the viewer's own Plex Watchlist as the sync has seen it (Settings › Account › Request from my Plex Watchlist), newest first. Empty unless the viewer has that sync on; it's a built-in row like the others (`kind` `watchlist` in `shelves`, which the admin can hide or move). `seeAll` (0.42.4+; an older server omits it, and then only Popular Movies/Series and the genre shelves have a "See all", to the Movies/Series grid): where each shelf's "See all" chevron goes, keyed like the shelves. Every shelf has one. `type: "list"` → `GET /discover/lists/{list}` (the website's `/discover/{list}`); `type: "browse"` → the unfiltered Movies (`mediaType: "movie"`) or Series (`"tv"`) grid, `GET /movies` / `GET /series`. Treat an unknown `type` or `list` as no "See all". ### `GET /discover/lists/{list}` — user 0.42.4+. A Discover shelf's full list, paged (the website's `/discover/{list}`, infinite scroll). `list` is one of `recently-added`, `watchlist` (0.53+, your own Plex Watchlist, 40 a page like `recently-added`), `trending`, `upcoming-movies`, `upcoming-series`, or (0.49+) a custom row's `id` from `GET /discover`'s `shelves` (anything else: `404 not_found`). For a custom row, `list` in the answer is its id and `title` its name. | Query | Type | Default | | |---|---|---|---| | `page` | int ≥ 1 | 1 | at most 250 (25 for `recently-added` and custom `library` rows, 500 for `tmdbList`, 100 for `traktList`) | Custom rows page like the built-in lists: keyword, genre, studio and network rows are 2 TMDb pages per page (movies and series mixed by popularity when the row has both); a TMDb list is 20 titles a page; a Trakt list 40; a `library` row 40, like `recently-added`. Trakt and TMDb answers are cached for an hour. `trending` and the two `upcoming-*` lists need TMDb (`502 upstream` otherwise) and are 2 TMDb pages per page, de-duplicated; `upcoming-movies` drops re-releases whose release date has passed, so its pages can run short. `recently-added` is the library's newest Plex/Jellyfin titles, 40 per page; its `totalPages` / `totalResults` only look one page ahead (it is empty without a connected media server). Continue while `page < totalPages`, skip titles already shown, as on the Movies grid. Cards carry `status`, `favorited` and the quick action (`canQuickAdd` / `canRequest` / `requested`); `trending` and `recently-added` mix movies and series. ```json { "list": "trending", "title": "Trending", "page": 1, "totalPages": 250, "totalResults": 1000, "results": [ { "mediaType": "tv", "tmdbId": 299939, "name": "Monster: The Lizzie Borden Story", "posterPath": "/57XS.jpg", "year": "2026", "subtitle": null, "overview": null, "rating": null, "status": null, "favorited": false, "requested": false, "canQuickAdd": true, "canRequest": false } ] } ``` Errors: `400 invalid` (bad `page`), `404 not_found` (unknown list). ### `GET /settings/discover` — admin (0.49+) Settings › Discover (website, Mac, Windows; the admin only): every Discover row in order, hidden ones included. With nothing changed, it's the built-in rows in the order of `GET /discover`'s fixed keys, all shown. ```json { "shelves": [ { "id": "recentlyAdded", "kind": "recentlyAdded", "title": "Recently Added", "custom": false, "hidden": false, "source": null }, { "id": "trending", "kind": "trending", "title": "Trending", "custom": false, "hidden": true, "source": null }, { "id": "5b0f3c2e-8f7a-4d0e-9b1c-2a6d7e8f9a01", "kind": "keyword", "title": "Anime", "custom": true, "hidden": false, "source": { "mediaType": "all", "tmdbId": 210024, "name": "anime", "url": null } }, { "id": "8d1e2f3a-4b5c-4d6e-8f70-1a2b3c4d5e6f", "kind": "company", "title": "A24", "custom": true, "hidden": false, "source": { "mediaType": "movie", "tmdbId": 41077, "name": "A24", "url": null } }, { "id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f", "kind": "traktList", "title": "Best of 2024", "custom": true, "hidden": false, "source": { "mediaType": "all", "tmdbId": null, "name": null, "url": "https://trakt.tv/users/someone/lists/best-of-2024" } } ], "traktConfigured": true, "maxCustomShelves": 30 } ``` `id`: a built-in row's key (`recentlyAdded`, `trending`, `popularMovies`, `movieGenres`, `upcomingMovies`, `studios`, `popularSeries`, `seriesGenres`, `upcomingSeries`, `networks`) or a custom row's uuid. `source` (custom rows only) is one flat shape for every `kind`: | `kind` | Website label | `source` | |---|---|---| | `keyword` | TMDb keyword | `tmdbId` (keyword), `name`, `mediaType` `movie`/`tv`/`all` | | `genre` | Genre | `tmdbId` (genre), `name`, `mediaType` `movie`/`tv` | | `company` | Studio | `tmdbId` (company), `name`, `mediaType` `movie`/`tv`/`all` | | `network` | Network | `tmdbId` (network), `name`; `mediaType` always `tv` | | `tmdbList` | TMDb list | `tmdbId` (list), `name` | | `traktList` | Trakt list | `url` (a public list or watchlist on trakt.tv) | | `library` | Recently added to Plex/Jellyfin | `mediaType` `movie`/`tv`/`all` | `traktConfigured`: Trakt rows need Trakt connected (Settings › General) — without it they're empty. `403` "Only the admin can arrange Discover." for anyone else, on every `/settings/discover` endpoint. API keys can't reach them. - **`PUT /settings/discover`** — `{ "shelves": [{ "id": "trending", "hidden": false }, …] }`: the rows in their new order, each `hidden` or not (left out: unchanged). Every `id` must exist, once; rows left out keep their visibility and go after the ones sent, in their current order. Answers as `GET`. `400` "There's no Discover row "…". Reload and try again." / "The row "…" is in the list twice.". - **`POST /settings/discover/shelves`** — adds a row at the end, shown: `{ "kind": "keyword", "tmdbId": 210024, "mediaType": "all", "title"?: "Anime" }` (`name` optional — looked up from TMDb when left out); a TMDb list takes `tmdbId` or `url` (its themoviedb.org/list link); a Trakt row `url`; `library` just `mediaType`. `title` is optional (up to 60 characters; default from what it shows, e.g. "Anime", "A24", "Action Movies", "Recently Added Movies"). `201` with the row (the shape of one `shelves` entry). `400` for a bad field ("Pick a keyword.", "Paste a public Trakt list or watchlist link, like https://trakt.tv/users/someone/lists/favourites.", …); `409` "Discover can have up to 30 rows of your own. Remove one first."; `502 upstream` without TMDb (except `library`). Only trakt.tv links are accepted, and the server only ever reads Trakt's own API with the username and list from it. - **`PATCH /settings/discover/shelves/{id}`** — `{ "hidden"?: bool, "title"?: "…", "mediaType"?, "tmdbId"?, "name"?, "url"? }`: any row can be shown or hidden; a custom row can also be renamed or pointed at another keyword, list, etc. of its kind. Answers the row. `400` "A built-in row can only be shown or hidden."; `404` for an unknown id. - **`DELETE /settings/discover/shelves/{id}`** — removes a custom row. `{ "ok": true }`. `400` "A built-in row can't be removed. Hide it instead."; `404`. - **`POST /settings/discover/reset`** — the built-in rows back in their usual order, all shown; custom rows stay, after them. Answers as `GET`. - **`GET /settings/discover/lookup?type=keyword&q=anime`** — what a row can be built from: `type` `keyword` or `company` (TMDb search; `q` required, empty `q` gives no results), `network` (the networks Discover knows, filtered by `q`, or a network's TMDb number as `q`), `genre` (with `mediaType=movie|tv`; `q` filters). `502 upstream` without TMDb. ```json { "results": [ { "tmdbId": 41077, "name": "A24", "logoPath": "/1ZXsGaFPgrgS6ZZGS37AqD5uU12.png", "detail": "US" }, { "tmdbId": 210024, "name": "anime", "logoPath": null, "detail": null } ] } ``` `detail` tells same-named ones apart (a company's country); null otherwise. #### `GET /settings/discover/locale` · `PUT` — admin (0.53+) Settings › Discover › Region & language: the country "Currently streaming on" and a movie's release dates are for (`streamingRegion`), and the region and original language TMDb's Popular and Upcoming rows are limited to (`discoverRegion`, `discoverLanguage`). Null is the default: the server's own country (else US) for `streamingRegion`, worldwide for `discoverRegion`, English for `discoverLanguage` (`"any"`: no limit). `effective` is what that comes to; `regions` and `languages` are the codes that may be chosen (ISO 3166-1 and ISO 639-1). `PUT` body: any of `{ "streamingRegion": "GB", "discoverRegion": null, "discoverLanguage": "any" }` — only what's sent changes, null or `""` puts it back to the default. Answers as `GET`. `400 invalid` for a code that isn't in the lists, or a body with none of the three. ```json { "streamingRegion": "GB", "discoverRegion": null, "discoverLanguage": null, "effective": { "streamingRegion": "GB", "discoverRegion": null, "discoverLanguage": "en" }, "regions": ["AR", "AT", "AU", "GB", "US"], "languages": ["any", "en", "es", "fr", "de"] } ``` ### `GET /movies` and `GET /series` — user The Movies / Series results grid (infinite scroll on the website). | Query | Type | Default | | |---|---|---|---| | `sort` | `popularity` \| `top_rated` \| `newest` | `popularity` | | | `genre` | int | — | TMDb genre id (from `/extras`) | | `year` | int 1800–3000 | — | release / first-air year | | `network` | int | — | **series only** (ignored for movies) | | `hideOwned` | `true`/`false`/`1`/`0` | `true` | Hide anything already in the library (the website's default when signed in) | | `page` | int 1–500 | 1 | | Each page is a batch of 4 TMDb pages (10 with `hideOwned`), de-duplicated. Continue while `page < totalPages`. Titles can reappear across pages because TMDb's ranking shifts; skip ones you already show (as the website does). ```json { "page": 2, "totalPages": 23, "totalResults": 4584, "results": [ { "mediaType": "movie", "tmdbId": 502, "name": "Fail Safe", "posterPath": "/qrsj.jpg", "year": "1964", "subtitle": "Thriller", "overview": "Because of a technical defect an American bomber team mistakenly orders the destruction of Moscow…", "rating": 7.832, "status": null, "favorited": false, "requested": false, "canQuickAdd": false, "canRequest": true } ] } ``` Errors: `400 invalid` (bad `sort`, non-integer or out-of-range numbers). Empty `results` on page 1: the website says "Nothing left here — try a different genre or year, or turn off "Hide titles you already track"." ### `GET /movies/extras` and `GET /series/extras` — user Everything on those pages besides the grid: the genre filter options, the active network chip, and the "Because you watched …" row. | Query | Type | | |---|---|---| | `genre` | int | "Because you watched" only appears with no genre… | | `year` | int | …and no year filter | | `network` | int | series only — resolves the network chip | ```json { "genres": [ { "id": 10759, "name": "Action & Adventure" } ], "network": { "tmdbId": 213, "name": "Netflix", "logoPath": "/wwem.png" }, "becauseYouWatched": { "title": "Severance", "items": [ /* TitleCard ×≤12 with status, favorited, requested, canQuickAdd, canRequest */ ] } } ``` `network` is null without `?network=`; `becauseYouWatched` is null when there's no Plex watch history of that media type (it rotates daily through the last 10 watched titles). ### `POST /surprise` — user "🎲 Surprise me": one random title from the full TMDb result set for the filters. | Body field | Type | Default | |---|---|---| | `type` | `"movie"` \| `"tv"` \| `"all"` | `"all"` (random per call) | | `genreId` | int | — | | `year` | int | — | | `hideOwned` | bool | `true` (re-rolls up to 5 times to skip library titles) | ```json { "mediaType": "movie", "tmdbId": 424694 } ``` Errors: `404 not_found` — "Nothing matches those filters — try loosening them." or "Couldn't find something new — try different filters."; `400 invalid` (bad `type`/numbers). ### `GET /search?q=` — user The search results page. `q` is required (`400` if blank). ```json { "query": "keanu", "people": [ { "tmdbId": 6384, "name": "Keanu Reeves", "profilePath": "/8RZL.jpg", "knownForDepartment": "Acting", "favorited": false, "knownFor": ["The Matrix", "John Wick", "Speed"] } ], "studios": [ { "tmdbId": 420, "name": "Marvel Studios", "logoPath": "/hUze.png", "favorited": false } ], "titles": [ /* TitleCard with status, favorited, requested, canQuickAdd, canRequest */ ], "theme": { "label": "Science Fiction", "items": [ /* TitleCard — section heading on the website: "