Contract: Jellyfin as Seerr's identity source
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
Status: Accepted
How lemonfiber makes Jellyfin the identity source for Seerr during seed, so a household member signs in to Seerr with their Jellyfin account rather than a second one. Jellyfin and Seerr read these calls; lemonfiber writes them.
Satisfies: D1-R7, D1-R16, D1-R4
Why this is written down
Connecting Seerr’s authentication to Jellyfin is the difference between a household member having one account or two, and the spec makes it unconditional. But it is not a single field written to a config file the way a Servarr key is: it is two services’ own setup APIs, driven in order, and — unlike every other service — Jellyfin has no key on disk for lemonfiber to read. So the shape of those calls, and the one credential lemonfiber must mint to make them, are recorded here for the same reason the download-client and Prowlarr-application shapes are: a pinned-version upgrade that moves a field becomes a reviewed change rather than a runtime surprise.
The field sets below are the ones the pinned Jellyfin (jellyfin/jellyfin
10.11.11) and Seerr (ghcr.io/seerr-team/seerr v3.5.0, from seerr-team/seerr)
expose. As with every contract here, the writer and this document move together
when a pin advances.
The one credential lemonfiber mints, not reads
Jellyfin generates no API key to disk and asks its operator to create the first
account through a setup wizard. That is the same shape as qBittorrent’s temporary
password (D1-R16): there is
nothing durable to read, so lemonfiber mints the administrator password,
sets it by driving Jellyfin’s own first-run setup, and records it where a later
run — and the Seerr wiring below — can read it back. The generated password is
recorded under JELLYFIN_ADMIN_PASSWORD, beside the account name admin. As
with qBittorrent, no randomness means no account is created and nothing is
recorded, never a guessable fallback.
If Jellyfin’s setup is already complete when seed runs — the household set it up themselves — lemonfiber holds no credential for it and does not have one to mint. It does not guess or reset one; the Seerr wiring is skipped for that run with a note that Jellyfin was set up outside lemonfiber.
Driving Jellyfin’s first-run setup
Jellyfin’s /Startup/* endpoints answer without a credential only until setup
completes; afterwards they answer an administrator alone. That is what makes them
safe to drive exactly once.
GET /System/Info/Public→{ "StartupWizardCompleted": <bool>, … }. This is the idempotency gate: a completed wizard is left untouched.GET /Startup/User→{ "Name": "<default name>" }. On a server nobody has set up, this read is what creates the first account.POST /Startup/User, JSON{ "Name": "admin", "Password": "<minted>" }→204. It renames the first account toadminand sets its password; it does not create an account, and answers404where none exists yet, which is why step 2 comes first. An emptyPasswordis refused with400.POST /Startup/Complete→204. Setup is finished, and/Startup/*stops answering without an administrator’s credential.
lemonfiber reaches Jellyfin at its published loopback port; the account name and minted password are what it then hands to Seerr.
Configuring Seerr against Jellyfin
Seerr is set up in two steps: sign in through Jellyfin, which on a fresh instance creates the owner and sets the media server, and then a finish step, which is what actually marks Seerr initialised. The sign-in alone does not complete setup, so both are the identity wiring.
-
GET /api/v1/settings/public→{ "initialized": <bool>, … }. It needs no session. The idempotency and consent gate: an already-initialised Seerr is never re-initialised (see below). -
POST /api/v1/auth/jellyfin, JSON:{ "username": "admin", "password": "<the minted Jellyfin password>", "hostname": "jellyfin", "port": 8096, "useSsl": false, "urlBase": "", "email": "[email protected]", "serverType": 2 }Field What Seerr does with it username,passwordForwarded to Jellyfin’s POST /Users/AuthenticateByNameasUsernameandPw. A missingusernameis refused with500.hostnameThe host alone, with no scheme, port or path. Seerr builds the address as {http|https}://{hostname}:{port}{urlBase}, so a whole URL here yields an address nobody wrote. It is the name Seerr reaches Jellyfin by across the stack’s own network — the container name, not the host loopback lemonfiber itself uses.portJellyfin’s port inside the stack. Stored as 8096where absent, but the address of this very call is built from the body as sent, so it is always given.useSslhttpswhere true. Stored asfalsewhere absent.urlBaseA path Jellyfin is served under, empty at the root. Stored as ""where absent.emailOptional. The owner’s address in Seerr; where absent, Seerr files the owner under the Jellyfin account’s name. serverType2selects Jellyfin (1is Plex,3Emby). Read only on the call that sets the media server; any value but2or3there is refused with500(NO_ADMIN_USER).On the first call this signs in to Jellyfin as that administrator and:
- creates the Seerr owner from it, answering
403(NOT_ADMIN) for an account that is not a Jellyfin administrator; - points Seerr’s authentication at Jellyfin;
- mints a Jellyfin API key named
Seerrwith that session (POST /Auth/Keys?App=Seerr, thenGET /Auth/Keys), and writes it with the address fields tosettings.jsonasjellyfin.apiKey,jellyfin.ip,jellyfin.port,jellyfin.useSslandjellyfin.urlBase.
- creates the Seerr owner from it, answering
-
POST /api/v1/settings/initialize— finishes setup, the step that flipsinitializedto true, and answers200with the public settings. It answers only an administrator: the session cookie the sign-in set, carried by the transport onto this call, is what authorises it.
Every JSON body is sent with Content-Type: application/json, because Seerr’s
framework only parses a body it is told is JSON and silently drops one it is not.
Every later sign-in
The owner’s session is what lemonfiber reads and writes Seerr with on later runs,
and a session is opened by the same POST /api/v1/auth/jellyfin with
{ "username": "admin", "password": "<the minted Jellyfin password>" } and
nothing else. Once Seerr holds a Jellyfin address, it refuses
any sign-in that names one with 500 (Jellyfin hostname already configured),
whatever initialized says, and signs in against the address it holds.
Read back, and never override
After writing, GET /api/v1/settings/public is read again and its initialized
must now be true; only then is the connection called wired
(D1-R4). A refusal carries
the service’s own words rather than a paraphrase.
An already-initialised Seerr is left exactly as it is and never re-initialised — whether it was this that initialised it on an earlier run (idempotent, so a second run changes nothing) or the household set it up themselves with local accounts. Switching a running Seerr’s identity source would affect the accounts already on it, so it is treated as the household’s own to change, reported rather than reverted. This is the drift-aware rule the rest of seed follows, applied to the one connection where overriding would cost a household its existing sign-ins.
What ADR-0032 changes
ADR-0032
puts a request gate between Seerr and Jellyfin
(D1-R19,
D1-R20). Where the gate runs,
the address in step 2 is the gate’s Jellyfin route, the gate answers the key mint
itself so Seerr holds no Jellyfin key, and lemonfiber’s later calls carry Seerr’s
own API key in place of the owner’s sign-in. The calls above are the ones the core
on lemonfiber main makes.
Related
- D1 Service auto-wiring — the feature this serves, and the household-identity requirement
- ADR-0032 — the request gate, and what Seerr holds once it runs
- download-client.md · prowlarr-application.md — the other written-down wiring shapes
- versioning.md — why a pinned stack makes a written-down field set safe