Skip to content

Media server accounts

Implementation contracts and behavior for media server accounts.

A Jellyfin, Emby, Plex, or Audiobookshelf instance is an ordinary encrypted service_instances row plus media_server_config. The mediaaccess service builds a mediaserver.Provider per call from the stored URL and key (the jellyfin, emby, plex, or audiobookshelf client, chosen by service type in one factory). Providers come in two kinds. Jellyfin, Emby, and Audiobookshelf are account servers: Cantinarr creates a local account. Plex is an invite server: it holds no accounts of Cantinarr’s, and access is a share the linked plex.tv account extends to the email the user supplies, accepted on Plex’s side. The share is the account: the identity is the canonical (lower-cased) email, GET of a share that is gone is absence (never a disabled account), SetDisabled(true) removes the share or cancels the pending invite, and re-granting shares again: plex.tv hands an account still connected to the owner the share back accepted at once, and sends anyone else a new invite to accept (the “check your email” push goes out only in the second case). plex.tv lists accepted shares under the server’s shared_servers and invites nobody has accepted under the account’s sent-invite list, where an invite to an address with no Plex account yet is keyed by that email rather than a user id; the provider reads both and cancels through whichever holds the invite. Asking for the invite records the email on the user, refuses an address another user’s row already holds, adopts a share someone made by hand instead of inviting again, and moves a share Cantinarr sent to a new address when the user changes it (an admin-linked share is the admin’s to unlink first). A grant that arrives after the email was shared sends the invite off the request, and the drift sweep also sends the invites a grant still owes, so an invite that failed while plex.tv was down lands later; the reconcile branch re-invites only when Cantinarr itself removed the share (the row is stamped), so an unrelated grant write never emails anyone whose share vanished on its own. Sharing an email while holding no Plex grant tells the admins (plex_access_request, with the outcome) unless an instance has auto-approve on, in which case the user is granted and invited at once. users.plex_invited_at is no longer written; the API derives it from the user’s live Plex account row for apps that still read it.

Linking an account that already exists is the other way in. An admin picks one from the server’s list (administrators included, marked as such), or imports the server’s accounts wholesale: each picked account becomes a Cantinarr user of exactly that name (Jellyfin/Emby/Audiobookshelf may reuse an existing user of the name; Plex requires an explicit mapping through Link account when that name is taken), granted and linked through the same path the picker uses (linked only by default, preserving disabled state and library permissions; explicit management applies the current grant), so the admin’s pick is the mapping and nothing is ever merged by name on Cantinarr’s own initiative; a granted user proves one is theirs from the guide. On Jellyfin and Emby the proof is the account’s own username and password: the client checks them with POST /Users/AuthenticateByName under a device identity of its own and with no API key on the wire, logs the session it opened out again, and never lets the password into an error; the row check runs before the credentials are sent, so a request that will be refused moves no lockout counter on the server; the account is recorded as linked (never created, never changed); a refused password is a 400 with a code, never a 401. On Plex the proof is a plex.tv sign-in with the person’s own account: Cantinarr reads that account’s email through the token the approval yields, removes the device plex.tv registered for the sign-in (which drops the “Cantinarr” entry from the account’s Authorized Devices and invalidates the token; the plain sign-out endpoint leaves the entry listed), remembers the email on the user, and runs the same share pass a shared email does, inside a short budget with the answer cached on the pin so the app’s polling always lands on the same result (the drift sweep sends whatever the budget cut off). plex.tv never lists the owner among a server’s shares, so the PIN link records the owner on the instance (the first sign-in backfills it for instances linked earlier) and the provider answers for that identity with an administrator account that is never invited, re-scoped, or removed: the owner signing in is recognised rather than invited to their own server. Linked administrator accounts, the Plex owner among them, are left alone by every path that changes accounts; reconciliation clears management and pending work for protected accounts.

Where a title can be watched is asked of the media server, never inferred from the arrs: GET /api/media-servers/watch looks the title up on every Plex, Jellyfin, or Emby server the caller holds a live linked account on (and that has a sign-in address, since nothing else is client-reachable), concurrently under a short budget, as that account, so the server applies its own library access. The match is by provider id, never by name alone. Historical disabled_at records reconcile managed writes; they never block a live lookup after access is restored outside Cantinarr. Jellyfin and Emby explicitly verify the linked account is enabled before searching and re-read its identity and decoded policy afterward, because an administrator API key can read disabled users’ items. Disabled, missing, or changed accounts return unverified without an exact URL. Grants, account mappings, and target configuration are checked again after the remote calls; changed targets are omitted. Jellyfin and Emby use TMDB for a movie and TVDB, else TMDB, for a series: Emby filters by provider id itself (AnyProviderIdEquals, confirmed against the item’s ids before it is trusted), while Jellyfin has no such filter (10.11 silently ignores it), so the client narrows by production year (a three-year window around the title’s year) or, without a year, by the title, and matches the ids in what comes back. Each server answers with a state. On Jellyfin and Emby, a title Radarr has that Jellyfin has not scanned yet reads as missing and a server that could not answer reads as unreachable, never as absence. For Jellyfin and Emby, the URL is the admin-typed public_address plus the web client’s item page (/web/#/details?id=…&serverId=… on Jellyfin, /web/index.html#!/item?id=…&serverId=… on Emby); the service joins the two, so the client package never learns the public address and the service never learns a URL scheme.

Plex verifies the current accepted share at plex.tv and reads its server-scoped access token for this lookup only; the owner uses the stored owner token after a fresh identity check. It discovers direct connections for the configured owned machine through plex.tv, prefers HTTPS, verifies /identity before sending a credential, and queries only libraries both selected in the current accepted share and visible with that account’s token (the verified owner uses its own visible libraries). This intersection remains bounded while Plex applies a share update or a LAN authentication exception broadens an obsolete token’s view. Before returning an exact link, it rechecks the accepted share, token, and selected libraries; a changed share is unverified. The paginated movie/show search narrows by year or title and requires a unique GUID match (TMDB, or TVDB for a show without a TMDB GUID) within the five-second budget and 1,000-candidate limit. An incomplete, ambiguous, or unmatched search is unverified, never a claim that Plex lacks the title. A verified URL opens https://app.plex.tv/desktop/#!/server/{machine}/details?key=…; tokens and private connection addresses never reach the app. Every granted Plex server with a sign-in address also returns fallback_url when no exact link is verified, including unlinked accounts and pending shares; the app labels this generic shortcut Open Plex. The existing Plex sign-in token is still revoked immediately after proving the account.

The watch endpoint enforces kids-account limits before contacting any media server, using authoritative TMDB details and ratings. It replaces client-supplied title, year, and TVDB ID with the approved title’s metadata, so a permitted TMDB ID cannot be combined with another show’s ID. A blocked title returns 404; unavailable policy data returns 503. Responses are never cached.

Audiobook listening is separate from movie/TV watching: GET /api/media-servers/listen requires an explicit authorized Chaptarr instance and its native foreign_book_id. It reads current audiobook records and requires audio files before deriving ASINs and validated ISBNs from audio editions. Every granted Audiobookshelf server with a configured public address is queried under a five-second budget. The linked user’s active state, libraries, tag rules, and explicit-content permission filter the administrator-key search; candidate items must have playable audio and exact identifiers, and their metadata and user policy are re-read before returning. Search limits or missing metadata yield unverified, not absence. Separate item IDs remain separate. Confirmed URLs use only the configured public address plus /item/{id}; generic fallbacks contain no title assertion. The service rechecks source configuration, grants, account links, and target configuration before responding, and caches no listening result.

Plex, Jellyfin, and Emby instances store an optional iPhone/iPad default in media_server_config.video_apps.ios: service (the official app, also the default when absent), infuse, or browser. Personal overrides use authenticated self-only GET/PUT /api/me/video-apps, returning an object keyed by plex, jellyfin, and emby, each containing { "ios": "" } when inheriting. PUT replaces all three choices atomically; omitted service entries reset to inheritance. The video_app_preferences table keys choices by user and service type and cascades on user deletion. Unknown services, apps, platforms, and launch URLs are rejected. Guide and Watch-link responses include resolved video_apps, read from storage for each request. Preferences never grant media access or alter library assignments. The app applies these choices only on iPhone/iPad: Infuse gets a title’s existing TMDB identity only after a verified match; generic shortcuts carry no title. Existing public URLs remain unchanged for browser fallback. Infuse’s own connections determine the available server/copy. See the Infuse API.

Audiobookshelf instances store administrator app defaults in media_server_config.listening_apps (ios and android, each defaulting to browser). listening_app_preferences stores personal overrides by Cantinarr user; empty values inherit each instance’s corresponding default. Both the guide and listen responses include the resolved listening_apps, read from current storage on each request. Only supported platform/app identifiers are accepted, never arbitrary launch URLs. The authenticated self-only GET/PUT route applies to admins and requesters alike; deleting a user cascades to their preferences. The app uses these choices for native handoff and retains the original verified or generic public URL for browser fallback. No media-server credentials or app-local connection identifiers are exposed.

The pre-instance Plex integration (a linked account in the settings table) migrates on first boot, in one marker-guarded transaction: the linked account and selected server become a Plex instance (auto-approve carried over from the old auto-invite switch), everyone Cantinarr had invited is granted it with an account row dated by their invite, and the old settings keys go; a second boot changes nothing, a token that does not decrypt leaves everything in place and logs, and a link that never chose a server is dropped with a log line telling the admin to link again.

Creating a Jellyfin or Emby account is what follows. Creating an account is create, then restrict to the shared libraries as a non-administrator: on Emby with a password step in between, because its create route takes none and a password-less Emby account signs in with an empty one; the half-made account is deleted if any later step fails: and also when the create call’s own answer is lost or unreadable, in which case the client looks the name up and deletes what it finds, since the pre-check proved the name was free a moment earlier: and the password is used once and never stored. Every policy change fetches the live policy and posts it back in full, because both servers replace the whole object. Library ids are whatever the server’s policy matches: Jellyfin’s VirtualFolders.ItemId, Emby’s folder Guid. Grants are the eligibility: the instance handler fires a grant observer after media-server grant writes, and the service reconciles only the affected users’ managed user_media_server_accounts rows against the live account: disabling accounts whose grant went away, re-enabling ones that came back, never touching administrators. A switch-off that could not reach the server is owed, not lost: a five-minute maintenance sweep (and one pass at startup) re-derives the intent from the grants: managed rows with persisted access_sync_pending or whose disabled_at stamp disagrees with the grant: and retries only those, so a grant revoked while the server was down still lands once it is back. The sweep reads only Cantinarr’s own tables when nothing is owed, and it never compares against the live server, so an account an admin disabled on the media server is left alone instead of fought over on a timer. Changing an instance’s shared-library selection fires a second observer that re-applies the new set to the accounts Cantinarr created there and still manages, so unticking a library takes it away from the people who already have accounts and not only from future ones; linked accounts keep the policy their admin gave them. That pass runs inside the admin’s save and is capped at 30 seconds, so a server that answers policy writes slowly leaves the rest for the next save rather than holding the request open. Deleting a Cantinarr user prepares switch-off for managed accounts before the delete and commits it only after the delete succeeded. A per-user lock protects that snapshot until commit or abort; account actions also serialize per user/instance and re-read management authority before remote writes. Rows record what Cantinarr did; the media server is the live truth, which is why GET /api/media-servers re-reads each account and reports verified.

Audiobookshelf is verified against 2.36.0 with an active administrator API key. New accounts are created with a password and restricted library policy in one request, as active ordinary users with downloads enabled and update/delete/upload/e-reader creation and explicit content disabled. The client reads the account back to confirm the policy and deletes the new account if confirmation fails. If the create response is lost, cleanup requires a newly observed ordinary account that accepts the submitted password; an unconfirmed or concurrent account is left for the administrator to check. Shared-library updates patch only the library fields, preserving other remote permissions; linked accounts retain their existing library policy. Managed grant reconciliation disables/restores isActive without deleting history and protects root/admin accounts. A user links an existing local account by signing in once via /login; the temporary refresh session is immediately closed via /logout. Passwords, temporary tokens, and legacy user tokens are never persisted. The API key stays encrypted with the instance, and all Audiobookshelf traffic uses the internal transport with redirects refused.

Admin media-account responses include pending alongside verified; pending is live invitation state and is meaningful only when verified is true. A successful directory read supplies the current remote username without rewriting the stored link. Pending invitations are not active library access. created_by_cantinarr remains provenance, not invitation status. Older responses without pending cannot establish Plex acceptance.

View the maintained source for this page.