Seerr AI is an independent fork of seerr-team/seerr,
branched at 59d5947b and maintained independently. What it adds is
described for people using it in seerr-ai/; this page
is for working on it. Upstream changes are not
merged back in.
upstream is kept as a git remote for reference — to read how something works
there, or to cherry-pick a fix worth having. Nothing is expected to merge
cleanly, and nothing has to.
Upstream develop has been read through b185621e (2 November 2026).
Its two security advisories there are covered: GHSA-xc6j-6p6h-p799 (other
people's details and issues in a title's media info) by mediaForViewer,
done here before upstream's fix; GHSA-8j73-j6rc-q8xh (notification
credentials in every embedded user) by User.toJSON. Start the next read
from that commit.
From c0759ce07 to b185621e: #3579 is taken (abandoned seasons settled
at the end of a Sonarr run). It keeps the fork's own rules: a series Sonarr
has under another TVDB id still counts as there, and one Sonarr lists no
seasons for never has its request declined. Its tests went into
baseScanner.statusReset.test.ts, where upstream's scanner tests live here.
Not taken: a new Indonesian server translation (the fork has no Indonesian).
From 84eae6727 to c0759ce07: dependency updates only. Taken: axios
1.21.0, express-rate-limit 8.7.1, nodemailer 10.2.1 and Cypress 16.2.0
(Next.js was already 16.4.2). Cypress pins a vulnerable systeminformation
and supertest an old qs, so both are overridden past their advisories.
sqlite3 v6 (#3662) is taken: it comes as a ready-built binary for Alpine
on x64 and arm64, and builds with node-gyp 12 where it doesn't, so the old
node-gyp 8 and its @tootallnate/once override are gone. Not taken yet: the
slimmer Docker image (#3615), whose Dockerfile and next.config differ here.
From 62ed636b0 to 84eae6727: #3652 is taken, reworked for several
media servers: the owner signing in with Plex before any media server is
set up makes Plex the primary one (connectAsPrimary) and starts the jobs,
and the owner's email counts for that first Plex sign-in while none is set
up. startJobs replaces the jobs it already scheduled rather than adding
them again, which also fixes Jellyfin or Emby set up over an existing
database scheduling every job twice. #3573 (a series edit or request that
leaves no season to ask for answers 202), #3538 (rules matched against the
server the request goes to) and #3608 (Trending's filters kept in the
address) were already here. Beyond #3538, the request window's rule
preview now matches as the request will be sent: against the default
server for one that isn't among them, and as anime or not as ticked. The
rest was CI, Cypress, docs and devenv.
From 31b100dde to 62ed636b0: #3639 (Node.js 22.23.4) was done here
as 2.0.1, with the same image digest; #3425 is taken (the watchlist POST
body and its 201 answer in the API spec). The rest was CI, the Helm chart
and contributor docs.
From 8f2eceea to 31b100dde, #3630 is taken: when a title arrives,
the requests it completes are saved one at a time, because saved together
their save hooks ran at once on one Postgres transaction and their
savepoints undid each other, failing the whole save. Beyond upstream,
withNestedTransaction makes nested transactions on one connection take
turns by itself, so no other caller can trip over this again. The rest of
that range was already here (#3593, #3533, the fork's Radarr fix covering
all three ways a film is added) or was translations.
Unmerged upstream pull requests taken: #3571 (choosing the networks in the Networks row), reworked: each person chooses their own (kept in their user settings, saved with the rows they hide) where upstream has one list an admin sets; the page carries no validation library; the editor loads only while customizing. #3600 (no blur behind the top bar), reworked: the blur is dropped only where the bar is solid (light mode or solid panels, and the phone's tab bar with solid panels), where it showed nothing but cost scrolling; see-through bars keep it.
git clone <your-remote> seerr
cd seerr
docker build --build-arg COMMIT_TAG="$(git rev-parse --short HEAD)" -t seerr-custom:latest .COMMIT_TAG matters: it is the service worker's script URL, so a stale value
means clients keep an old worker after a deploy. Using the commit hash makes
that automatic.
Then recreate the container. The image is seerr-custom:latest; in Compose,
use pull_policy: never so it isn't looked for in a registry.
build.sh runs ./quality-check.sh first. It compares Seerr AI's own
quality presets (server/lib/quality/presets.ts) with TRaSH Guides as it
is now, against the TRaSH Guides commit they were last reviewed with
(server/lib/quality/trash-reviewed.json), and says:
- Must fix: a TRaSH Guides profile or format Seerr AI relies on is gone
or renamed (
TRASH_PROFILESintrashData.ts,TRASH_EQUIVALENTSinpresets.ts), so TRaSH Guides presets would stop working. - Changed since the last review: new or removed profiles and qualities, formats TRaSH Guides now refuses, formats Seerr AI mirrors that match differently, and size changes.
- What TRaSH Guides never takes that Seerr AI's own presets have nothing like.
It is a report and never stops the build. When it lists something, decide
what Seerr AI's presets should take from it, change them (with tests), and
record the review with QUALITY_CHECK_HERE=1 ./quality-check.sh --accept.
The app itself checks TRaSH Guides once every 24 hours, so servers on
TRaSH Guides presets don't wait for a build.
Patches exported with bin/export-patches.sh BASE FIRST_NUMBER OUT_DIR carry
the tree they were made against and the tree they produce. Apply them with
./apply-patches.sh /path/to/00*.patchIt refuses to start if the working copy is not the tree the first patch was made against, or has uncommitted changes, and undoes everything if any patch fails, so a half-applied series never reaches a build. After the last patch it checks the result is exactly the tree that was exported.
pg_dump -h <db-host> -U <db-user> -d <db-name> > ~/seerr-pre-$(date +%F).sqlCheck for new files under server/migration/ since the last deploy. Every
migration needs both a postgres/ and a sqlite/ variant.
To write one, let TypeORM generate it rather than hand-writing constraint
names: bring a scratch database up to date with pnpm migration:run
(DB_TYPE=postgres for Postgres; NODE_ENV=production with a scratch
CONFIG_DIRECTORY for SQLite, after pnpm build:server), then
pnpm migration:generate. Keep only the statements for your change: the
output also lists older naming differences in the fork's schema, which are
harmless and not worth a migration. Number it after the newest one.
pnpm test # everything
NODE_ENV=test node server/test/index.mts server/routes/list.test.tsTests run on node:test with an in-memory SQLite database. Outbound network
is blocked, so any API client a test touches has to be stubbed. A run with
any failing test exits with 1.
.github/workflows/ci.yml runs on every push and pull request to dev and
main: lint, formatting, types, translations in sync with the code
(node bin/check-i18n.js), a build, and the whole suite on SQLite. On
pushes it also runs the suite on Postgres, one file at a time, which takes
about an hour. A red cross on a commit links to the log of what failed.
real-clients.yml runs alongside it: the download client code against the
real clients, not stand-ins (Deluge, qBittorrent, Transmission, SABnzbd and
NZBGet in their latest linuxserver.io images, rTorrent behind nginx), each
given a torrent or Usenet job to add, stop, finish and delete
(server/api/downloadclients/realClients.test.ts, skipped unless
REAL_CLIENTS is set). To run it by hand against clients of your own, see
the variables at the top of that file.
Production runs Postgres; the default test run is in-memory SQLite. That gap
has let real bugs through — a datetime column Postgres rejects, a watchlist
loop that only reproduced on Postgres, and a helper that inserted a duplicate
row because it wrote through a second connection. Run the suite against
Postgres before deploying anything that touches entities, transactions or
subscribers:
docker run -d --name seerr-test-pg -e POSTGRES_USER=seerr \
-e POSTGRES_PASSWORD=seerr -e POSTGRES_DB=seerr_test -p 5433:5432 postgres:16
NODE_ENV=test DB_TYPE=postgres DB_HOST=127.0.0.1 DB_PORT=5433 \
DB_USER=seerr DB_PASS=seerr DB_NAME=seerr_test pnpm test --concurrency 1--concurrency 1 runs one test file at a time: they all share the one
database, and two at once reset it under each other.
Use a throwaway database. The schema is dropped on every run — never point this at your real database.
Three things the harness does that are easy to trip over:
-
The database resets between every test. Seed in
beforeEach, notbefore. -
API clients assign their methods as instance properties in the constructor, so a prototype stub needs a setter that swallows the write:
Object.defineProperty(TheMovieDb.prototype, 'getMovie', { configurable: true, set() {}, get() { return async () => ({ id: 1, title: 'Test' }); }, });
-
Faked timers and the Postgres pool.
runWithMockTimersreplacessetTimeoutandclearTimeout, which the database pool uses to close idle connections. The Postgres test config turns that reaping off (idleTimeoutMillis: 0); with it on, a connection checked out under a faked clock was closed ten seconds later mid-test, about one run in three.
Each of these came from a bug that reached a running server.
- A new route must be declared in
seerr-api.yml. The OpenAPI validator runs before the router and answers undeclared paths with{"message":"not found"}. This silently disabled two finished features.server/routes/apiSpec.test.tsnow fails for any route the app serves that the spec does not declare. - Never build a Tailwind class name at runtime.
list-cycle-${n}was stripped from the production CSS and every list card rendered blank. Set the value inline, or keep the rule outside@layer. - Check the compiled CSS, not just the component. jsdom renders without the stylesheet, so a purged rule is invisible to component tests.
datetimeis SQLite-only. UseDbAwareColumnfor date columns or Postgres refuses to start — and the SQLite tests will not catch it.- Any floating panel goes through the app's
Modal. A hand-rolled overlay inherits whatever clipping and stacking its parent happens to have. This was got wrong twice. - Anything that waits on the network needs a timeout. Three separate
hangs — the image proxy, the service worker, Tautulli — were all an
unbounded
await. - Do not save an entity loaded with relations just to touch a column.
Cascading relations get re-persisted from stale in-memory arrays; this
made sharing a list fail for its entire life. Use
.update(id, {...}). - Test through the real scaffolding. The OpenAPI validator and a missing
<Transition>parent only showed up when the code ran inside the app. - Keep heavy libraries off the pages everyone opens. Import one
function (
lodash/sortBy,validator/lib/isEmail), never the whole package, and keep server-only code (zod schemas, address parsing) out of files the pages import. Something only used on demand (a dialog, an edit mode) loads withuseLazyModule, notnext/dynamic: a failed download undernext/dynamicstays failed until the page reloads, which an installed app has no button for. A build's per-page weight is in.next/build-manifest.json; check it after adding a dependency. - Decide what each viewer may see, on the server. A title page once
sent every requester's email and Plex id to anyone signed in, and issue
reports to people without issue access. Send other people through
User.filter()(seeserver/lib/mediaForViewer.ts), and check the permission in the route, not only by hiding the button. - Sign-in starts a new session. Use
startSession()fromserver/lib/signIn.ts; settinguserIdon the existing session lets whoever planted that session id ride along. - Every control needs a name. An icon-only button or link is silent to
a screen reader. Give it an
aria-label; one wrapped in aTooltipwith plain text is named from the tooltip automatically. A second link to the same place (a poster beside its title) isaria-hiddenand untabbable. - A feature switch is enforced on the server. Hiding a button is not
turning a feature off: every route a switch covers checks
featureEnabled()too.
CHANGELOG.md is for the people running Seerr AI, not developers. A change they would notice gets one short line under Coming next (added at the top when the first unreleased change lands), in plain words, in New, Changed or Fixed; details go in the guide (docs/seerr-ai), linked once where useful. No code names, file names or jargon. Changes nobody would notice (tests, refactors, the build) are left out.
A release renames Coming next to its version, ## 1.2.3 (date);
release.yml uses that section as the release notes. No empty section is
left behind: the changelog lists only what has been released, and Coming
next comes back with the next change. A pre-release does the same with
its own version, such as ## 2.0.0-beta.2 (date), listing what changed
since the last one; the first starts with one line saying what it
previews. The release after its betas has one section covering
everything since the last release, in place of the betas' sections, as
that is what people updating from it read.
A release that fixes a security problem says so under Security, as upstream does: each fix with its CVE ID (or GHSA ID when it has no CVE), linked to the advisory, and one short line on what it was and whether Seerr AI could be reached through it.
package.json can't hold comments, so each override that holds a package
past an advisory is recorded here. Raise a pin when a newer advisory
covers the version it allows; drop it once nothing in the tree resolves
below it on its own (pnpm why <package>).
| Override | Keeps out | Notes |
|---|---|---|
handlebars >=4.7.10 |
CVE-2026-106446, CVE-2026-106445, CVE-2026-106444, and the 4.7.8 advisories (GHSA-2w6w-674q-4c4q and others) | Only through eslint-plugin-formatjs (ts-jest), a development tool; nothing in the image uses it |
follow-redirects >=1.16.2 |
GHSA-mrwp-f3q8-3g9p, and before it CVE-2026-111756, CVE-2026-111755 | Through axios: credential headers sent on after a cross-origin redirect |
typeorm>glob, cacache>glob >=14.0.2 <15 |
GHSA-3g3m-2fjr-6pw4, and through glob 14 its minimatch 11 past GHSA-g3hv-75wg-m249 | typeorm finds entity and migration files with it (glob.sync, still in 14); cacache is node-gyp's, while sqlite3 is built |
source-map-js >=1.2.2 |
CVE-2026-93749 | Through Next's PostCSS; build only |
tar >=7.5.21 |
GHSA-23hp-3jrh-7fpw, GHSA-r292-9mhp-454m | Through sqlite3's build; denial of service on unpacking |
nodemailer 10.1.4 |
GHSA-8c8m-p3m8-cjg8, GHSA-m943-m63m-w62w, GHSA-85x2-q946-cp5h, and before them GHSA-v53p-9fqp-m79j, GHSA-6vj9-mwq6-2f5v, GHSA-8vvx-rff5-p5rq and the 9.x advisories | Sends every email |
body-parser >=2.4.0 |
GHSA-27r3-jxmh-p828 | Through Express: reads every request body |
multer >=2.5.0 |
GHSA-q7qp-rjg7-q26m | Through express-openapi-validator, for uploads |
fast-uri >=3.1.9 <4 |
GHSA-w74x-v6f9-j3xg, GHSA-wjr9-j795-jxcr, GHSA-xhq7-xmqj-769h, GHSA-333x-w4r6-6crp | Through express-openapi-validator's ajv, which checks addresses in API requests; ajv wants 3.x |
cheerio>undici >=6.29.1 <7 |
GHSA-xjj6-f33q-929c, GHSA-4qv8-2p9j-2685 | Through email-templates' juice, which inlines email styles; Seerr AI's own undici is a direct dependency, at 8.12.3 |
ip-address >=10.7.3 |
The ip-address advisories up to 10.7.0 (subnet checks across address families, leading-zero octets) | Only through node-gyp's proxy support, while sqlite3 is built |
compression is a direct dependency, held at 1.8.2 for
CVE-2026-87776. One advisory is knowingly
left: CVE-2026-93748 (http-cache-semantics, no
fixed version), inside node-gyp, which only runs if sqlite3 has to be
built. And GHSA-g3hv-75wg-m249
(minimatch, fixed only in 11) is left in minimatch 3 under
email-templates' @ladjs/i18n: its multimatch is only called by that
package's Koa redirect middleware, which nothing here uses, and minimatch
11 can't stand in for 3 (no default export).
deepmerge has no fixed release for
CVE-2026-93753 (a __proto__ key in what is
merged sets the result's prototype). Seerr AI patches it instead
(patches/, applied by pnpm on install): 4.3.1, which email templates use
through html-to-text, and 2.2.1, which Formik uses, both skip that key. The
audit can't see a patch, so the advisory is listed in auditConfig as
handled. Drop the patch and that line once deepmerge releases a fix. The Node.js version is pinned by digest in the Dockerfiles;
2.0.1 moved it to 22.23.4 for the October 2026 security release.
| Path | What |
|---|---|
server/routes/list/ |
lists, items, import — split by concern |
server/lib/list*.ts |
access rules, activity notifications, watch sync |
server/lib/notificationReconciler.ts |
dedup and catch-up delivery |
server/lib/requestDispatch.ts |
marking undispatchable requests failed |
server/lib/requestServers.ts |
which Radarr, Sonarr or Lidarr a request goes to, and why it has none (serverIssue on a pending or failed request) |
server/constants/lists.ts, src/constants/lists.ts |
tuning knobs |
src/components/MediaLists/ |
list UI |
src/hooks/useListDetail.ts, useMediaMarkers.ts |
list data and poster badges |
public/sw.js |
service worker |
server/lib/mediaServers.ts |
which media servers are connected, their settings, ids and clients |
src/utils/mediaServerAccounts.ts, src/utils/playLinks.ts |
people's server accounts, and which play link to offer them |
server/lib/oidc.ts, server/routes/oidc.ts |
single sign-on: talking to providers, the correlation cookie, finding or making the account |
server/lib/ai/ |
AI: providers, limit and cache, credit, each feature's prompt |
src/components/Ai/, src/components/AiChat/ |
AI panels and the AI button |
server/api/musicbrainz/, server/api/servarr/lidarr.ts |
MusicBrainz (its one shared queue) and Lidarr |
server/lib/music/, server/lib/scanners/music/ |
music: matching albums, availability, completing requests; the music scans |
src/components/Music/ |
music pages, cards, request cards and the artist's manage panel |
server/lib/quality/, server/routes/settings/quality.ts |
quality profiles: presets, TRaSH Guides, building and applying them, What it does, step-down |
src/components/Settings/SettingsQuality/ |
the Quality settings page, its preview and What it does |
Do not undo these without reading why first — each was tried the other way.
- No app shell caching. Caching HTML caused a reload loop, a stale-build stall and a blank list card. Static assets, images and discover data are still cached.
- No custom SWR cache provider. A bounded LRU provider silently broke
mutatein fifteen components, because the globalmutateaddresses swr's default cache and a provider takes the app out of it. - Roulette and list sections only offer watchable titles. A partially available series counts; something still downloading does not.
- Quality profiles are never pushed on their own. A server is Not
managed until an admin turns it on, and nothing is sent until they press
Apply after a preview; TRaSH Guides updates wait unless Apply updates
automatically is on. Only what Seerr AI created (named
Seerr AI · …) is changed, apart from the default delay profile, which the preview marks as shared. Titles move onto its profiles only when the admin picks it. - Import never adds without review. Automatic title matching gets things wrong, and silently adding the wrong film is worse than no feature.
- Passkeys belong to the Application URL. With it set (Settings → General), passkeys work only at that address and no Host header is trusted; a passkey made there cannot be used from a LAN IP. Changing the URL's host strands every passkey, so people sign in with a media server (or a password) once and add a new one.
- Several media servers, one of each kind. Plex, Jellyfin and Emby can
be connected at once (Settings → Media Servers), at most one of each.
main.mediaServerTypeis kept and means the primary server: the one whose links fillmediaUrl,iOSPlexUrlandtautulliUrlexactly as before, for API readers;playLinkshas every server's. Every other question about servers goes throughserver/lib/mediaServers.ts, notmediaServerType. Emby has its own settings and columns rather than sharing Jellyfin's, so both can be connected. Accounts are matched only by each server's own id, never by email across servers: an email match let a Plex account take over the owner's account on a Jellyfin-first install. A new account whose email or name is taken is refused, not merged. Plex is reached through the owner's Plex token, so the owner links Plex to connect it. Episodes are counted per server, so a season split across two servers shows as partly available; that is a known limitation, told to admins in the guide. - Pages pick a play link for the viewer. A title's page shows Play on
each server; badges, request cards and issues show one, on a server the
viewer has an account on (the primary one first), else the first that
has it (
pickPlayLinkinsrc/utils/playLinks.ts). Notifications link to Seerr's own page, which is the same for everyone. - GitHub Actions: checks and images.
ci.ymlandreal-clients.ymlcheck every push and pull request todevandmain(see Tests).release.ymlbuilds and publishes the image for anai-v*tag,:preview-<name>for apreview-<name>tag, and:developwhen run by hand fromdev(pushes todevpublish nothing; run by hand from any other branch it builds:preview-<branch>), so Seerr AI can be installed like Seerr, on its own. It builds the way upstream does: amd64 and arm64 (the platforms upstream ships) each on their own runner, arm64 on GitHub's arm64 runner rather than under emulation, pushed by digest and joined into one multi-arch image; to GHCR, and to Docker Hub only when theDOCKERHUB_USERNAMEandDOCKERHUB_TOKENsecrets are set (as<username>/seerr-ai, or theDOCKERHUB_IMAGErepository variable); signed with cosign (keyless) with a CycloneDX SBOM attestation from Trivy, both verified before a release is published. Unlike upstream, every image is signed, not only releases, and the version comes from the tag (written intopackage.jsonfor the build), not a release commit. Theai-prefix keeps releases apart from Seerr's ownv1.0.0…v3.xtags, which arrive whenever Seerr is fetched asupstream; nevergit push --tags, which would send those too. It builds only when asked, and doesn't run the tests itself: they areci.yml's, so check a commit's run there (or run them locally, see Tests) before tagging it. Upstream's other workflows (docs, issue triage, Helm, its weekly Trivy scan to code scanning, Discord notices,create-tag) and its Helm chart aren't carried. Anyone can still build their own image withbuild.sh. - Update checks are against this repository.
server/constants/project.tsnames it; Settings → About and the update check read its releases and commits. Change it if the repository is renamed. Both pick the latest release withserver/utils/releases.ts: never a draft, a pre-release only for an install running one, matched to the version by its tag. - Two branches:
devandmain. Work and patches go ontodev, which a self-built server runs.mainonly moves for a release, fast-forwarded todev, then taggedai-v…. A release build checks for newer releases; any other build (versiondevelop-<commit>) checksdev's commits, matching the short hash build.sh uses as well as the full one. - A notice waits its turn. Banners and pop-ups that ask something go
through
NoticeQueue, one at a time, most important first. A new one must report there rather than render on its own, or two will stack again. - The server compresses; the proxy passes it through. Seerr sends
brotli (gzip for older clients) for everything, API JSON included, which
Nginx Proxy Manager never compressed. Hashed assets are immutable for a
year; the service worker and
sw-precache.jsonare never cached. - Per-person data is never shared between sign-ins. The service worker
empties its Discover cache whenever a sign-in or sign-out passes through
it, and whenever a page load (
X-Seerr-User) shows the signed-in person is not the one the rows belong to. Anything a device remembers for a person (a snoozed prompt) is keyed by their id. - Deleting a Plex user keeps them deleted. The account's Plex id is
recorded (
RemovedPlexAccount) so the automatic import does not bring it back. Importing it by hand, or the person signing in with Plex while new Plex sign-ins are on, still does. - AI sends as little as it can. Each feature sends a short instruction, titles as "Name (year)" and list positions rather than records, asks for compact JSON, and caps the answer. Thinking models are asked for their lowest effort, learned per model from their first refusal. Answers are cached, and a repeat costs nothing and does not count.
- The AI key only goes where it was saved for. It is never sent back to a browser, and changing the provider or address needs it typed again, so it cannot be sent to a server it was not meant for.
- An AI answer is paid for before it is asked. The daily limit is taken in one conditional update, so requests made together cannot run past it, and handed back when nothing usable came of it.
- AI credit is worked out, not read. Neither provider lets a program
read an account's balance, so Settings → AI shows the balance an admin
last entered less what has been spent since: from the provider's own
spending report when an admin key is saved (the whole account, counted in
whole UTC days), otherwise estimated from the tokens Seerr's own requests
used (
ai_spend) at prices the admin enters. The admin key is write-only like the API key, and a new provider starts the balance and key afresh. When the provider says the account is out of credit, that is shown there and to the person asking, and clears on the next answer. - Picks only include people who share a watch list with you. What someone watches is otherwise only theirs and admins' to see. Admins can include anyone. People appear to the model as Viewer1, Viewer2.
- AI never acts. Every answer is text or titles found through TMDB. AI Search can set up putting titles on a list or requesting them, but only opens a window the person confirms, with every title shown and each one able to be left out; requests then follow the usual rules, one at a time. A model that gets something wrong can only mislead, never change anything.
- Music is kept by artist, with albums as its parts. An artist is a
Mediarow (mediaTypemusic,tmdbId0, the MusicBrainz id inforeignId, the name inforeignTitle); each album is aMusicAlbum, as a season is aSeason; a request is aMediaRequestholdingAlbumRequests. Lidarr keeps folders, profiles and tags on the artist, so one request for a whole discography is one approval and one set of choices; one row per album (as SeerrNG does) made a discography a pile of approvals fighting over the same artist. Requests, approvals, quotas, watchers and notifications came for free. Albums are completed and announced by the music scans (lib/music/completion), not the save hooks: they change throughupdate(), which the hooks never see, and an artist's status says nothing about which albums one request asked for. For the same reason an artist becoming available never approves a pending music request. Lidarr is only ever told to monitor and search:albumsToMonitorunmonitors everything else, so it is used only for an artist being added, and an artist Lidarr has keeps its own settings. - Film and series code only ever sees films and series. Everything that
goes through titles in bulk (availability sync, Health, catch-up,
watch history, Recently Added, the AI) is scoped with
VIDEO_MEDIA_TYPES, andserver/lib/musicScope.test.tsfails if a music row reaches any of it. Music'stmdbIdof 0 never goes into a table keyed bytmdbId(Blocklist, MediaDeletion, Watchlist, list items): an artist's history is kept byforeignId, and clearing an artist records no deletion. - Permissions are a bigint. Auto-Approve Music is 2^31, past what an
integer column holds and past JavaScript's 32-bit
&.user.permissionsis a bigint read back as a number, andhasPermissionuses arithmetic, not bitwise operators. Bit 0 and 2^29 were the only free ones below. - One MusicBrainz line for the whole server. MusicBrainz allows one request a second from an address, and limits per instance of the client (upstream's way) let several run at once. Every request goes through one queue, people on a page ahead of scans; someone who would wait too long is told it is busy instead. Answers are kept in the database too, so a restart asks nothing again. Health judges MusicBrainz from how that queue has fared rather than spending a request on a check. A mirror (Settings → Music) is not limited.
- Single sign-on finds people by provider account only. A provider
(Settings → Users → Single Sign-On) signs someone in only through a
link of the provider's id and its own id for them (
sub, inoidc_link), made at the issuer the provider still has (asubonly means someone at the issuer that gave it, so changing the issuer URL leaves old links unusable; links from before issuers were recorded take the current one when used, or the old one when it changes), never by email: some providers let anyone put any email on an account, and an email match is how a Plex account could once take over the owner's. A new person is refused, unless the provider lets new people sign up; then they get an account, with the provider's email only when it is verified and nobody here has it, else a stand-in that isn't an email. An email someone here already has is refused with the usual "sign in and link it" message. A provider's id is made from its name once and never reused, even after it is removed, so its old links can never start matching another provider's people. Starting and finishing are pages the browser goes to, not calls: the callback is a link from the provider's site, so the state, PKCE verifier, nonce and return path ride in a signed, httpOnly, SameSite=Lax cookie (Strict would not come back), cleared on use. The callback answers with a page that moves on rather than a redirect, so a Strict session cookie (CSRF protection on) is sent with the page it lands on. Everything is built from the Application URL, never the request's Host. A provider sign-in is proof enough on its own (nomaySignIn), and signing out ends only Seerr's session. A provider's rules for who may sign in (requiredClaims, upstream's idea, kept as{ claim, value? }and read from upstream's space-separated string too) are checked in the callback before anything else happens, so signing in, a new account and linking are all refused alike; they fail closed: a rule that can't be read, or userinfo that can't be, lets nobody in. Failed callbacks count toward the sign-in limit only with a genuine correlation cookie for that provider, so made-up callbacks can't lock out an address. - Enable Proxy Support trusts only known proxies. With it on, Express
takes
req.ipthrough loopback, link-local, private and100.64.0.0/10(Tailscale) hops (LOCAL_PROXIESinserver/utils/trustedProxies.ts), plus Cloudflare's published ranges with Trust Cloudflare and any listed under Also Trust These Proxies; never every hop: with every hop trusted the address is whatever the sender wrote first in X-Forwarded-For, and the sign-in limits count by address. - Personal API keys act as their owner, and only through the header.
Keys (
sai_…,server/lib/personalApiKeys.ts) are stored as hashes, checked against the owner's current permissions on every request (and their right to sign in every few minutes), and never takeX-API-User. A request with anX-Api-Keyheader never uses the session cookie, so a wrong key is a 401 even from a signed-in browser, and CSRF protection skips such requests (server/middleware/csrf.ts): another site can't make a browser send that header. A key can't make keys. - Admins can remove someone's passkeys, not add or rename them. For a lost device. The owner's and other admins' passkeys are the owner's to manage, as with passwords.