Skip to content

Instances & arr proxy

HTTP routes and behavior for instances & arr proxy.

A kids account’s Radarr and Sonarr reads through the proxy are cut to the records the account may see, judged from the record’s own certification and genres (the policy region’s scheme first, then the US scheme; a string neither knows is unrated): library and calendar arrays, wanted, and the records of queue and history by their embedded movie or series: includeMovie=true / includeSeries=true is forced onto those requests so every row carries a parent to judge, and a row without one is dropped. A single blocked movie/{id}, series/{id}, or episode/{id} is 404 {"error":"not found"}; a gated body the proxy cannot judge (not JSON, an unexpected shape) is the same opaque 502 as any unsanitizable response. Chaptarr and Lidarr carry no ratings and are not gated: those modules are granted per user.

GET|POST /api/instances # admin: list/create
GET /api/instances/media-roots # admin: deployment-approved Cantinarr roots for the mapping editor
POST /api/instances/test # admin: dry-run connectivity check, dialed from the server
POST /api/instances/media-server/libraries # admin: libraries a media server reports (same body + credential fallback as /test)
POST /api/instances/plex/link/begin # admin: start a plex.tv PIN link -> { pin_id, code, url } (token held server-side)
POST /api/instances/plex/link/check # admin: poll the PIN { pin_id } -> { linked, account } (the account is recorded as the server's owner on save)
POST /api/instances/plex/servers # admin: the linked account's owned servers ({ plex_link_pin } or { id })
# -> { server_name, version, libraries: [{ id, name, collection_type }] }
PUT|DELETE /api/instances/{instanceID} # admin: update/delete
GET|PUT /api/instances/{instanceID}/users # admin: which users are pinned/assigned here
GET|PUT /api/instances/{instanceID}/grant-users # admin: which users hold an access grant here
# (additive; never moves anyone's default)
POST /api/instances/{instanceID}/webhook # admin: rotate credentials and upsert a managed arr webhook
GET /api/instances/{instanceID}/webhook # admin: live instant-updates state, read from the arr's Connect list
GET /api/instances/{instanceID}/hardcover # admin: {supported, configured, oauth_available, method, reconnect_required, connection_id?, revision}; no tokens
PUT /api/instances/{instanceID}/hardcover # admin: {token}; verify catalog reads (400 invalid/insufficient scope, 502 unreachable), then atomically select API token
DELETE /api/instances/{instanceID}/hardcover # admin: disconnect either method from this instance only
POST /api/instances/{instanceID}/hardcover/device/begin # admin: begin actor/instance-bound OAuth; {flow_id,status,user_code,verification_uri,expires_at,interval}
GET /api/instances/{instanceID}/hardcover/device/{flowID} # initiating admin: poll; pending/connected/expired/denied/cancelled/superseded/failed, safe error and connection_id on success
DELETE /api/instances/{instanceID}/hardcover/device/{flowID} # initiating admin: cancel; reports connected if completion already committed
POST /api/instances/{instanceID}/hardcover/apply # admin: {connection_id,instances:[{instance_id,revision}]}; individual {instance_id,applied,error?} results
ANY /api/instances/{instanceID}/* # proxy to the instance's own API; JSON secrets are redacted,
# and upstream redirects are remapped onto this route (off-origin ones become 502)

Hardcover uses Cantinarr’s registered public client (internal/hardcover/oauth.go) and only read:catalog:data, against https://api.hardcover.app/oauth2/device and /oauth2/token through httpx.External(). No inbound callback or client secret is required. All connection-management responses use Cache-Control: no-store. Device codes stay in memory, bound to the initiating admin and instance, and expire on restart. Provider polling honors intervals, pending/slow-down responses, denial, expiry, cancellation, and transient failures.

Catalog verification precedes an atomic method switch; failed replacements retain the previous connection. Instance revisions protect against concurrent token changes, disconnected/deleted instances, and superseded flows. OAuth apply uses the expected source connection plus explicit target revisions, returning each result independently. Refresh writes use credential revision checks, preserve an omitted refresh token, and retain a newly issued pair in memory if persistence temporarily fails. Confirmed invalid_grant marks the shared connection for reconnection; transient failures preserve credentials. Restarting during an unresolved storage failure can lose a not-yet-persisted rotation, so restore writable storage before restarting. Disconnecting removes only the local instance link and deletes the credential after its last link; account-wide revocation remains in Hardcover’s Authorized Apps page. Chaptarr’s metadata credential is separate.

The proxy allows read-only Radarr/Sonarr browsing (library, queue, history, wanted, calendar) for regular users, the matching Chaptarr reads plus its book/author lookups and MediaCover art, and the matching Lidarr reads plus its album/artist lookups, mediacover art, and the track/track-file reads album downloads need; writes, commands, interactive search, config, and all non-arr services require admin. Requesters are bound to their own effective instance: their pin, or the deterministic global default/fallback: exactly as /api/config reports it; a sibling instance the admin has hidden cannot be reached by guessing its ID, and instance authorization is classified from stored metadata so an undecryptable secret can never widen access. JSON responses are bounded and recursively scrubbed for credential fields and secret-bearing URL query parameters before they reach any client. An encoded, malformed, streaming, or oversized JSON response fails closed rather than bypassing that scrubber.

Radarr, Sonarr, Chaptarr, and Lidarr create/update payloads may include media_path_mappings, an ordered array of { "arr_path": "...", "cantinarr_path": "..." } objects. Each row translates an absolute path prefix reported by that one instance into a server-visible directory beneath CANTINARR_MEDIA_ROOTS; source prefixes may use POSIX, Windows drive, or UNC syntax independently of the server OS. An empty array disables completed-media downloads for that instance. The admin-only media-roots endpoint supplies the approved target roots used by the editor. Mapping paths are admin-only instance configuration and never appear in the requester /api/config payload.

Jellyfin, Emby, Plex, and Audiobookshelf create/update payloads may include media_server_config: { "public_address": "https://jellyfin.example.com", "library_ids": [], "machine_identifier": "...", "auto_approve": false }. public_address is the client-reachable address shown to granted users so they know where to sign in (absolute http/https, no credentials, query, or fragment; empty = unset, except on Plex where it defaults to https://app.plex.tv): the only instance field a requester ever receives, and only because an admin typed it. library_ids are the server’s own library identifiers (Jellyfin’s VirtualFolders.ItemId, Emby’s folder Guid, Plex’s plex.tv-global section ids) the accounts Cantinarr creates may see; an empty list shares every library, including ones added later. machine_identifier (Plex, required) names which of the linked account’s owned servers the instance shares, and auto_approve (Plex) grants the instance to anyone who shares a Plex email and invites them at once. Omitting the field keeps the stored config, the admin list echoes it, and /api/config never carries it. plex_owner_id, plex_owner_username, and plex_owner_email are server-managed like the client identifier: recorded by the PIN link, backfilled from the stored token by the first Plex sign-in when an older instance lacks them, and never served; they are what lets the provider answer for the owner as an administrator account.

A Plex instance has no URL or API key to type. POST /api/instances/plex/link/begin mints a plex.tv PIN ({ pin_id, code, url }), the admin approves it in the browser, and POST /api/instances/plex/link/check { pin_id } answers { linked, account } once they have; the token the approval yields is held server-side for fifteen minutes and never reaches the app. The instance’s create/update/test/libraries bodies then carry plex_link_pin instead of api_key, and POST /api/instances/plex/servers (same candidate body: a pin, or an id whose stored token is used) lists the account’s owned servers for the picker. The stored url is plex.tv itself (a lab may point it at a proxy); the X-Plex-Client-Identifier the token was minted under is kept with the instance.

The proxy is also a transport trust boundary. CONNECT, TRACE, and TRACK are rejected before instance resolution or any upstream contact, and an upstream protocol upgrade (101) is refused, so the HTTP proxy can never become an opaque tunnel or reflect the injected X-Api-Key. Inbound Cantinarr session cookies and every forwarded credential, client-identity assertion (reverse-proxy X-Auth-*/Remote-*/mTLS headers), routing/method-override, and request-trailer header terminate here; only the instance’s own X-Api-Key is added outbound. Upstream responses are marked private and non-cacheable, and nginx/lighttpd internal-redirect controls (X-Accel-*, X-Sendfile, X-Reproxy-URL) plus response trailers are stripped so a fronting web server cannot be steered by an upstream header.

View the maintained source for this page.