Skip to content

API reference

Compiled from the routes.ts and index.ts files in apps/api/src/modules/* on 2026-10-26. Request and response schemas live in packages/shared/src/*.ts. All paths are prefixed with /api/v1. Every route requires authentication unless listed as public in Authentication.

Some modules (releases, imports, notifications) are not fully listed here yet; history, blocklist and calendar are.

Module registration order (apps/api/src/app.ts): installMetrics on the root instance first (its hooks see every route; GET /metrics at the root), then under /api/v1 the auth hook, then the audit hook (installAudit), then the modules: system, logs, health, jobs, events, settings, config (applies ARCLIGHT_CONFIG_FILE / ARCLIGHT_SETTING__* while registering, before any onReady), backup, metadata, library, profiles, guides, indexers, solver, download-clients, history, blocklist, releases, import-lists, imports, scene-mapping, notifications, media-servers (moves a legacy mediaServer value into the list once), retention, media-analysis, transcode, library-hygiene, health-scan, stats, updates, push (bus listener on), mobile, subtitles, tag-rules, requests, discord-bot, calendar, compat, migrate, vpn, torrent, tracker-stats, cross-seed, engine-agent, auth, audit, metrics. The Sonarr/Radarr-shaped compat API mounts at the root afterwards. Cross-module hooks resolve their target lazily, so the order only matters for readability: the releases engine asks tracker-stats for its freeleech-only filter through ReleasesDeps.releaseFilter (rejection notFreeleech, wired in wiring.ts), and health gets whisper from the subtitles module for the WhisperUnreachable check.

Errors: modules map typed service errors to JSON { "error": "..." } bodies (400, 404, 409, 5xx). Deletes answer 204 with a null body unless noted.

The list endpoints behind the UI’s search boxes take an optional q query parameter (ListSearchParam in packages/shared/src/list-search.ts; SQL side apps/api/src/db/list-search.ts). One rule everywhere: the search is case-insensitive; dots, dashes and underscores count as spaces (the.office = the office, web-dl = web dl); the text is split into words and every word must appear in at least one of the endpoint’s searched fields, in any order; %, _ and \ are matched literally (LIKE wildcards are escaped). A blank q (or one of separators only) means no search; more than 200 characters is 400. The search narrows the other filters, never the caller’s permission scope, and paging and totalRecords count the matching rows only. Each endpoint lists its searched fields: /history, /blocklist, /wanted/* (episodes, movies, albums, books), /subtitle/wanted, /profilemigration/mismatch, /request, /audiorequest, /issue, /audit (and /audit/export), /log (search, alias q), and POST /command/bulksearch (q with allMovies / allEpisodes).

Implemented in apps/api/src/modules/auth (contract: packages/shared/src/auth.ts). An onRequest hook installed by app.ts on the /api/v1 scope resolves who each request is, in this order:

  1. Session cookie al_session (from POST /auth/login or POST /setup/admin): httpOnly, SameSite=Lax, Secure when the request arrived over https (directly or via X-Forwarded-Proto). Only a sha256 of the token is stored. Sessions last sessionDays (default 30) and slide forward once past half their lifetime; rememberMe: false gives a browser-session cookie and a one-day server lifetime.
  2. HTTP Basic credentials, only when the auth method is basic.
  3. API key in the X-Api-Key header or ?apikey= query. One key per install, generated on first boot and stored in the security settings section. It grants admin access.
  4. Local bypass: once setup is complete and an admin exists, requests where the socket peer and every X-Forwarded-For hop are loopback, RFC 1918, IPv4 link-local, IPv6 ULA or link-local skip sign-in when the method is none or requireForLocal is false. Public addresses always need credentials, so none never opens the api to the internet.

Anonymous requests to protected routes get 401 {"error","code":"UNAUTHORIZED"} (plus WWW-Authenticate: Basic when the method is basic). Unknown routes answer 404.

First launch. Until an admin account exists, every route answers 403 {"code":"SETUP_REQUIRED"}, whatever the credentials (the API key included), except GET /auth/status, GET /setup/status, POST /setup/admin, POST /setup/complete and GET /system/ping (and /ping on the compat surface). /api/v3, /metrics and the event socket are closed too. GET /auth/status reports adminExists; needsSetup stays true while no admin exists. POST /setup/admin and the headless POST /setup/complete create the admin only while none exists (409 afterwards); POST /setup/rootfolder needs the signed-in admin.

Public routes (no credentials): POST /auth/login, POST /auth/logout, GET /auth/status, POST /auth/plex/pin, POST /auth/plex/poll, GET /system/ping, GET /calendar/feed.ics (checks its own token), and /setup/* (which guard themselves: writes need the admin once one exists, and answer 409 SETUP_COMPLETE after setup). The SPA’s static files are outside /api/v1. A GET route may also be public conditionally: config: { publicWhen: (req) => boolean } is evaluated per request (the public request portal uses it for GET /public/portal, GET /discover/* and GET /discoverslider); the route still sees req.auth when credentials were sent. GET /auth/status carries mediaServerLogin: {kind, name?} | null, the first enabled media server with loginEnabled (the login page shows the Jellyfin/Emby selector or the Plex button from it). PUT /user/me ignores the manager-owned settings keys ADMIN_MANAGED_USER_SETTINGS (quota, contentRestrictions, approvalPolicy, requestTypes, discordUserId).

Roles and access (deny by default). admin (and API-key / bypass callers) can do everything. Anyone else may call a route only when it opts in with config.access, for every method, GET, HEAD and the WebSocket upgrade included; without it the hook answers 403 FORBIDDEN. Three kinds of opt-in:

import { ADVANCED_REQUESTERS, requireAdmin, requireSelfOrAdmin } from '../auth/index.js';
app.post('/request', { config: { access: { portal: true } } }, h); // any signed-in user, any method
app.get('/series', { config: { access: { areas: ['library'] } } }, h); // read-only, users granted the Library area
app.get('/rootfolder', { config: { access: { areas: ['library'], permissions: ADVANCED_REQUESTERS } } }, h);
app.get('/user/:id/quota', { config: { access: { portal: true } }, preHandler: requireSelfOrAdmin }, h); // own account only
app.get('/thing', { preHandler: requireAdmin }, handler); // belt and braces for admin-only

portal routes must scope the work to the caller (own requests, issues, account) or check the permission bitfield in the service. areas grants are per user (users.app_access, set by an admin: appAccess on POST/PUT /user; library, calendar, activity, wanted), and only ever admit GET/HEAD. The full list of routes open to non-admins is the allow-list in apps/api/src/access.test.ts; that test enumerates every registered route and fails when a route opens without being listed. The event socket (/events) is open to signed-in users but filtered: non-admins get their own request and issue events (everyone’s with View requests / View issues), library events with the Library, Calendar or Wanted area and queue, download, history, release and import events with the Activity area; nothing else.

Media-server sign-in (POST /auth/login with a media-server method, POST /auth/plex/poll) never signs in an admin (403), and only ever creates role: 'user' accounts with no app access and no local password (localPassword: false). PUT /user/:id refuses to promote such an account (400 PASSWORD_REQUIRED) unless the same request sets password; any role change or password reset ends the user’s sessions. GET/PUT /settings/signin (admin) hold SignInSettings: local, jellyfin, emby, plex (booleans) and newUsers (automatic | invite). Admins always sign in locally.

req.auth ({ user, via, isAdmin, appAccess, sessionId?, expiresAt? }) is available in every handler; user is null for API-key and bypass callers. authServiceFor(ctx) returns the shared AuthService. Users carry a permissions bitfield (Permission in shared; ADMIN = 2 implies all) and a free-form settings record, ready for the requests phase. Failed logins are limited to 10 per address per 15 minutes (429 RATE_LIMITED). Passwords are hashed with scrypt (N=16384, r=8, p=1, 16-byte salt) from node:crypto.

Method Path Access Purpose
POST /auth/login public {method?, username, password, rememberMe?} → Session; sets the cookie. method (LoginMethod): local (default) checks Arclight’s password; jellyfin/emby/mediaServer sign in against Settings > Media server (AuthenticateByName), creating a local user with the default permissions of Settings > Requests on first sign-in (createUsersOnLogin). 401 INVALID_CREDENTIALS, 400 (not configured / wrong kind), 403 (login disabled, account disabled), 429
POST /auth/plex/pin public Plex sign-in step 1 → PlexPin {id, code, authUrl}; open authUrl in a popup (requests module)
POST /auth/plex/poll public PlexPollRequest {id, rememberMe?} → 202 {pending: true} until approved, then Session + cookie. Allowed for accounts already linked, or (Plex server configured, createUsersOnLogin) accounts that can reach that server; 403 otherwise
POST /auth/logout public Ends the session and clears the cookie (204)
GET /auth/status public {needsSetup, authMethod, authenticated, session}
GET /auth/me any Current Session (user, via, isAdmin, expiresAt)
GET /auth/sessions any The caller’s active sessions (current marks this one)
DELETE /auth/sessions/:id any Revoke one of the caller’s sessions
GET /user/me any The signed-in user
PUT /user/me any Update own display name, email, avatar, settings
PUT /user/me/password any {currentPassword, newPassword}; ends the caller’s other sessions (204)
GET /user admin List users
GET /user/:id admin One user
POST /user admin Create (201); 409 USER_EXISTS (usernames are case-insensitive)
PUT /user/:id admin Update; password resets it and ends the user’s sessions; 409 LAST_ADMIN on demoting the only admin
DELETE /user/:id admin Delete (204); not yourself, not the only admin
GET /settings/security admin {method, requireForLocal, sessionDays, apiKey}
PUT /settings/security admin Update {method, requireForLocal, sessionDays}
POST /settings/security/apikey admin Regenerate the API key → {apiKey}
GET /setup/status public {needsSetup, steps[{id, complete, optional}]} for admin, tmdb, rootFolders, downloadClient, indexer
POST /setup/admin public Create the first admin and sign it in (201); 409 once a user exists
POST /setup/rootfolder setup admin {path, mediaType, createIfMissing}: create the directory if asked, add the root folder (existing path returns it)
POST /setup/complete setup admin {tmdbApiKey?, rootFolders?, authMethod} (+ username/password/email when no user exists: headless setup); marks setup done; 409 on a second call
Method Path Purpose
GET /system/status App name, version, start time, uptime, runtime and config paths
GET /system/ping Public liveness probe: {"status":"ok"}
GET /system/debug/snapshot Admin. DebugSnapshot: version, uptime, process.memoryUsage(), container memory from cgroup v2/v1 (nulls when not readable), event-loop lag p99, active handles by type with queued bytes, torrent engine and VPN summaries
POST /system/debug/run Admin. {input}: one built-in, read-only diagnostic command per line (help, report, memory, buffers, gc, handles, engine, vpn, logs [n] [filter], ping <url>, dns <host>, version); # lines ignored; at most 20 commands and 1 MB of output. Returns {results: [{command, ok, output, durationMs}], truncated}, output redacted. Never runs a shell or evaluates code
GET /system/feeds {feeds: FeedSummary[]}: self-updating data feeds (guides, definitions, placeholder updates, plus anything registered through feedsRegistry(ctx) in apps/api/src/feeds.ts) with state ok/stale/error/never/disabled/unregistered, interval, last sync/success/error, pending count

Feeds are stale after 3 intervals without a success (a feed that never succeeded counts from when it was registered); the FeedStale health check reports stale feeds as warnings.

Method Path Purpose
GET /log LogQuery (page, pageSize, level minimum, search, q) → LogPage from the in-memory ring buffer, newest first. search and its alias q follow the list text search rule over message, component, level and the context fields (max 200 characters each); given both, the words of both must match
DELETE /log Clear the ring buffer
GET /log/file The active log file and its rotations (LogFile[])
GET /log/file/:name Download one of them
GET /log/stream WebSocket: one LogRecord JSON per message
Method Path Purpose
GET /health Evaluate all health checks; overall ok/warning/error plus findings
Method Path Purpose
GET /jobs Recent job runs
GET /jobs/scheduled Registered scheduled jobs with interval, last and next run
POST /jobs/run Run a registered job by name now (202; 404 for unknown name)
Method Path Purpose
GET (WebSocket) /events Live feed: hello on connect, then every event bus message
Method Path Purpose
GET /settings/metadata TMDB settings; API key masked
PUT /settings/metadata Update TMDB settings; a masked key keeps the stored value
GET /settings/releases Decision-engine knobs (max size, minimum age, retention, season packs, availability delay, unaired, decisionLogDays (default 14) for the release decision log; searchUnreleased (default false: only interactive search looks for items not released yet) and releaseGraceHours (default 0: hours after the release moment before automatic searches count an item as released; see Wanted → Release filter); music: allowDiscographyPacks (default false: discography / multi-album packs are only grabbed interactively) and minPackWanted (default 2: a pack must fill or upgrade at least this many monitored albums to be grabbed automatically))
PUT /settings/releases Replace the releases settings section

Packs that re-skin the web app; see Themes for the format. Settings section themes. The catalogue and files are public so the sign-in page and request portal can apply a theme; everything else is admin only.

Method Path Purpose
GET /theme Public. ThemeCatalog: themes[] (id, name, version, description, author, colorScheme dark/light, backdrop tron/shapes/none, swatches[], source builtin/bundled/synced, css path or null), defaultTheme, allowUserChoice. Tron is always first with css: null. cache-control: no-cache
GET /theme/:id/theme.css Public. The pack’s stylesheet (text/css); 404 for an unknown pack. ?v=<version> equal to the current version is cached for a year (immutable), anything else is no-cache
GET /theme/:id/fonts/:file Public. A .woff2 the pack ships (font/woff2, cached a day); 404 otherwise
GET /theme/status Admin. ThemeStatus: settings, sync (lastSyncAt, lastError, lastChanged[]) and themes[]
POST /theme/sync Admin. Sync from the source now (one at a time). ThemeSyncResult: ok, changed[] (pack ids installed or updated), skipped[{id, reason}], error
GET /settings/themes Admin. ThemeSettings: defaultTheme (tron), allowUserChoice (true), syncEnabled (true), syncIntervalHours (1 to 168, default 24), sourceUrl (raw-content base serving index.json and <id>/<file>)
PUT /settings/themes Admin. Replace the section; reschedules ThemesSync

A user’s own pick is the theme field of their UI preferences (default follows defaultTheme; ignored for non-admins while allowUserChoice is off). The ThemesSync job is in the scheduler table; a sync also runs about 90 seconds after start when the last one is older than the interval.

Method Path Purpose
GET /metadata/search Search TMDB for series or movies (query, type)
GET /metadata/series/:tmdbId Full series metadata from TMDB
GET /metadata/movie/:tmdbId Full movie metadata from TMDB
POST /command/refresh Refresh metadata on demand. Body RefreshCommand: exactly one of {seriesId}, {movieId} or {all: true}, plus optional rescan: true (re-read the folder from disk afterwards through the imports module). Answers 202 with the started Job (status running); the outcome arrives as a job WebSocket event and in GET /jobs. 400 for a bad body, 404 for an unknown id (no job is started)

On-demand jobs. Per-item refreshes, redownloads and library searches run as ad-hoc jobs (Scheduler.runTask): each gets a jobs row and job events like a scheduled job, but it is not registered, so it is not in GET /jobs/scheduled and cannot be started with POST /jobs/run. Names: RefreshSeriesItem, RefreshMovieItem (one at a time per item or id set; a single-item refresh that fails is a failed job carrying the reason), RefreshLibrary ({all: true}: all series, then all movies, then an optional full rescan), HistoryRedownload, SeriesSearch, MovieSearch.

Method Path Purpose
GET /rootfolder List root folders with accessibility and free space
GET /rootfolder/:id One root folder
POST /rootfolder Add a root folder
DELETE /rootfolder/:id Remove a root folder
GET /tag List tags
GET /tag/:id One tag
POST /tag Create a tag
PUT /tag/:id Rename a tag
DELETE /tag/:id Delete a tag and remove its id from everything that stores tag ids (see tag-rules)
GET /series List series (filterable)
GET /series/:id One series with seasons
POST /series Add a series (looks up by tmdbId through metadata); searchOnAdd: true queues a SeriesSearch job for it
PUT /series/bulk Bulk edit series: ids plus any of monitored (+ monitorOption), monitorNewItems, qualityProfileId, seriesType, seasonFolder, rootFolderPath, tags: {add?, remove?}; refresh: true queues a RefreshSeriesItem job for the ids and search: true a SeriesSearch job (user-invoked search per series), both after the update commits. Answers the updated series. 400 when refresh/search are asked for but the commands are not wired (nothing is changed); unknown ids 404, unknown tags or root folder 400
PUT /series/:id Update a series
PUT /series/:id/seasons/:seasonNumber/monitor Set a season’s monitored flag
DELETE /series/:id Delete a series
GET /episode List episodes (filterable)
GET /episode/:id One episode
PUT /episode/monitor Bulk set episode monitored flag
PUT /episode/:id Update an episode
GET /movie List movies (filterable)
GET /movie/:id One movie
POST /movie Add a movie (looks up by tmdbId through metadata); searchOnAdd: true queues a MovieSearch job for it
PUT /movie/bulk Bulk edit movies: ids plus any of monitored, qualityProfileId, minimumAvailability, rootFolderPath, tags: {add?, remove?}, refresh: true (RefreshMovieItem job), search: true (MovieSearch job); same rules as /series/bulk
PUT /movie/:id Update a movie
DELETE /movie/:id Delete a movie
GET /mediafile List media files
GET /mediafile/:id One media file
DELETE /mediafile/:id Delete a media file record
DELETE /mediafile/:id/grab-override “Allow upgrades again”: clear the file’s grabOverride (set by a “Grab anyway” grab, pauses automatic upgrades) → the MediaFile
GET /wanted/missing/episodes Paged (WantedEpisodesQuery: page, pageSize, includeSpecials, q) monitored episodes without a file. q matches series title, episode title and the s01e02 code
GET /wanted/missing/movies Paged (WantedMoviesQuery: page, pageSize, q) monitored movies without a file. q matches title, original title and year
GET /wanted/cutoff/episodes Paged (page, pageSize, includeSpecials, q as for missing) monitored episodes of monitored series whose file has not met cutoff, as WantedEpisodesPage
GET /wanted/cutoff/movies Paged (page, pageSize, q as for missing) monitored movies whose file has not met cutoff, as WantedMoviesPage

Release filter (every Wanted list: missing and cutoff unmet, episodes, movies, albums, books; WantedReleaseFilter in packages/shared/src/release-dates.ts). released: released (default: out now), unreleased (release moment in the future or unknown, “upcoming”) or all. releasedAfter / releasedBefore: ISO dates or timestamps; a date-only value is a whole UTC day (after = from 00:00, before = through 23:59:59.999), both inclusive, and rows without a release moment are left out while a bound is set. Rows are filtered before paging, so totalRecords follows the filter. Invalid values answer 400. The release moment per type: episode = air time (airDateUtc, else 00:00 UTC of airDate); movie = the date that satisfies its minimumAvailability (announced: earliest of in cinemas / digital / physical, always out; inCinemas: in cinemas; released: earlier of digital / physical, else in cinemas + 90 days; with no dates the coarse status decides); album / book = releaseDate (YYYY / YYYY-MM count from the start of the period).

Unreleased items are never searched automatically (missing / cutoff-unmet jobs, re-search after a failed download, search on add) nor by user-started searches (POST /command/search, /command/bulksearch, series / season / artist / author searches). Those skip them and report it: SearchCommandResponse.skippedUnreleased, BulkCommandResponse.unreleased (counted in skipped), and “, N not released yet (skipped)” in job messages. A series or season search leaves out its unaired episodes (a season counts as whole when every aired episode is asked for). Interactive search (GET /release) still searches them. /settings/releases: searchUnreleased (default false) lets user-started searches through; releaseGraceHours (0-168, default 0) makes automatic searches wait that long after the release moment.

Cutoff. A file meets cutoff when, under its series’ or movie’s current quality profile, its quality reaches the profile cutoff and its custom-format score reaches the format cutoff (cutoffFormatScore, or minFormatScore when upgrades are off). The score comes from matching the profile’s custom formats against the file (scene name or relative path, quality, languages, size, release group, edition). Files whose parent or profile is gone count as met. The same checker (library/cutoff.ts, createCutoffChecker) drives the CutoffUnmetSearch job, so the job searches exactly what these routes list.

Multi-version library. A series or movie may carry versions: [{ tag, qualityProfileId, enabled }] (MediaVersions: at most 8, unique tags, no path characters), set on POST/PUT /series and /movie; null or [] means unversioned (one file per episode/movie, the classic behaviour). Each enabled slot is monitored, searched and upgraded on its own against its own profile; a disabled slot keeps its file but is never wanted.

  • media_files.version_tag records the slot a file fills; one file per (movie, tag) and per (episode, tag) (series links live in episode_version_files). An untagged file on a versioned item belongs to the first configured slot.
  • Episode/Movie expose files[] ({id, versionTag, quality, sizeBytes, relativePath}, primary first); hasFile is true when any version exists; episodeFileId/movieFileId point at the primary file (untagged, else the first configured slot). MediaFile.versionTag.
  • /wanted/missing/* and /wanted/cutoff/* work per slot: a versioned item is listed while any enabled slot is empty / below its own profile’s cutoff, with wantedVersions: string[].
  • Releases: a release is evaluated against the enabled slot whose profile fits it best (allowed, then nearest the cutoff, then format score, then config order) and only competes with that slot’s file, queue and history; ReleaseDecision.versionTag says which. One grab per slot.
  • Imports: a file goes into the best-fitting slot and replaces only that slot’s file (ManualImportRequestItem.versionTag pins it). Naming: {Version} token; setting naming.versionLayout: sideBySide (default, appends - <tag> when the format has no {Version}) or subfolder (<folder>/<tag>/<file>).
  • Requests: the 4K track maps to slot 4K (an item already in the library keeps its copy as slot Standard); a file in slot 4K makes the 4K track available. | GET | /library/exclusions | List import exclusions | | DELETE | /library/exclusions/:id | Remove an import exclusion | | GET | /library/statistics | Library counts and sizes |

Contract: packages/shared/src/music.ts. Artists from MusicBrainz, their albums (release groups), editions (releases), the selected edition’s tracks and the audio files on disk. Every route answers 404 {code: "MUSIC_DISABLED"} while features.music is off (PUT /settings/features), and the music jobs do nothing. Reads open to app-area users like the series/movie reads (library: artist, album, audio file; wanted: the wanted lists); everything else is admin-only.

Method Path Purpose
GET /artist ArtistListQuery → Artist[] with statistics (ArtistStatistics). Filters monitored, status, qualityProfileId, albumTypeProfileId, rootFolderPath, tag, mbArtistId, search (name and aliases); sortKey (sortName default, added, status, qualityProfileId, path, sizeOnDisk), sortOrder; includeStatistics=false skips the counts
GET /artist/lookup ?term= → ArtistSearchResult[]: MusicBrainz artist search (interactive priority), artistId set for artists already in the library. mbid:<uuid> (or a bare MBID) looks one artist up by id
GET /artist/:id ArtistDetails: the artist with statistics and its albums (each with AlbumStatistics)
POST /artist AddArtistRequest → 201 Artist. Checks the root folder (type music), the quality profile (domain music), the album-type profile (default when omitted), tags and the exclusion list, fetches the artist record, inserts it at <root>/<Artist Name> and starts the ArtistAdd job; albums and tracks arrive while the job runs. searchOnAdd: true starts an ArtistSearch job once the albums are loaded (and the folder rescanned). 409 when the artist is already added or excluded; 502/503 when MusicBrainz fails
PUT /artist/editor BulkArtistUpdateRequest → Artist[]: ids plus any of monitored, monitorOption (re-applied to every album), monitorNewAlbums, qualityProfileId, albumTypeProfileId, rootFolderPath, tags: {add?, remove?}; refresh: true starts RefreshArtist for the ids after the update
PUT /artist/:id UpdateArtistRequest → Artist: monitored, monitorNewAlbums, monitorOption, qualityProfileId, albumTypeProfileId (applies from the next refresh), rootFolderPath (path becomes <root>/<Artist Name> unless path is given), path, tags. Files are not moved
DELETE /artist/:id DeleteArtistQuery: deleteFiles (removes the artist folder, only inside its root folder), addExclusion (never re-add this MBID). Cascades to albums, editions, tracks and audio_files rows. 204
GET /album AlbumListQuery (artistId, monitored, mbReleaseGroupId, includeStatistics) → Album[]
GET /album/:id AlbumDetails: the album with artist (ArtistSummary), every edition in releases, the selected edition’s tracks and the album’s files (AudioFile)
PUT /album/monitor BulkAlbumMonitorRequest (albumIds, monitored) → Album[]
PUT /album/:id UpdateAlbumRequest → AlbumDetails: monitored, anyReleaseOk, selectedReleaseId (an album_releases.id of this album; anything else is 400). Switching the edition fetches its tracks from MusicBrainz, replaces the stored tracks and re-links the album’s files by MusicBrainz recording id, then by disc/track position
GET /audiofile artistId and/or albumId → the music AudioFile[]. Shared with audiobooks (authorId / bookId, see “audiobooks”); one kind per request, gated by that kind’s flag
DELETE /audiofile/:id/grab-override “Allow upgrades again” for a music or audiobook file: clears grabOverride on every file of its album / book → the AudioFile[] that changed
GET /albumtypeprofile AlbumTypeProfile[] (“Studio albums”, the default, and “Everything” on a fresh install)
GET /albumtypeprofile/:id One profile
POST /albumtypeprofile AlbumTypeProfileInput → 201. isDefault: true makes it the default (the previous one is unset). 409 on a duplicate name (case-insensitive)
PUT /albumtypeprofile/:id AlbumTypeProfileInput → AlbumTypeProfile. The default cannot be un-defaulted directly; mark another one instead
DELETE /albumtypeprofile/:id 204; 400 for the default profile, 409 while an artist uses it
POST /command MusicCommand → 202 Job: {name: "RefreshArtist", artistIds}, {name: "RescanArtist", artistIds}, {name: "ArtistSearch", artistIds}, {name: "AlbumSearch", albumIds}. The search commands search through the release pipeline (see “releases: music”): ArtistSearch looks for each artist’s wanted albums, AlbumSearch for each album; the job message is searched N artists: R releases, G grabbed. Unknown ids 404 (no job starts), unknown names 400. The route is shared with the audiobook commands (see “audiobooks”); a music command answers 404 MUSIC_DISABLED while music is off
GET /wanted/missing/albums WantedAlbumsQuery (page, pageSize, q: album title, disambiguation, artist name) → WantedAlbumsPage: monitored albums of monitored artists, released (a partial date counts from the start of its year or month), with fewer track files than tracks (or no tracks loaded and nothing on disk). Newest first. Read-only in Wave 1
GET /wanted/cutoff/albums WantedAlbumsQuery (same q) → WantedAlbumsPage: monitored albums with files where any track file’s quality is below the artist’s profile cutoff (custom-format scores for audio arrive in Wave 2)

Album-type profiles. A release group becomes an album row when its primary type is listed, every one of its secondary types is listed, and (once its editions are known) at least one edition has a listed status. Albums that stop passing (or vanish from MusicBrainz) are removed on the next refresh unless they have files.

Monitoring. monitorOption (MusicMonitorOption): all, none (also unmonitors the artist on add), future (not yet released, undated included), missing / existing (by track files), latest / first (by release date among the released albums). On refresh, new release groups follow monitorNewAlbums: all, none, or future (only unreleased ones).

Editions. The default edition is picked by chooseRelease with the musicMetadata preferences: official first, then preferredCountries, preferredFormats (by the least preferred medium), the most common track count, the earliest date. Only the selected edition’s tracks are stored.

Statistics. AlbumStatistics: trackCount (stored tracks of the selected edition), trackFileCount, totalTrackCount (MusicBrainz’s count for the edition), sizeOnDisk (the album’s audio files), percentOfTracks. ArtistStatistics adds albumCount, monitoredAlbumCount, completeAlbumCount; its trackCount counts tracks of monitored albums plus any track with a file. GET /library/statistics gains artistCount, monitoredArtistCount, albumCount, monitoredAlbumCount, trackCount, trackFileCount and audioSizeOnDisk while music is on (sizeOnDisk stays video-only).

Rescans. RescanArtist (also run at the end of ArtistAdd when the artist folder exists) scans the folder with the tag reader (music-metadata, ffprobe as fallback), groups the files into albums and matches each group to the album’s selected edition (audio.trackMatchThreshold). Every matched file gets one audio_files row (quality from the measured codec, bitrate and bit depth); tracks.has_file / audio_file_id follow the matches, and rows of files that are gone or now match another track are removed. Files outside the artist folder (linked in place by a library import) and files the scan found but could not match keep their row and track. Unmatched folders are reported in the job message.

MusicBrainz. Base URL and rate from musicMetadata (the public host is locked at 1 request per second), User-Agent Arclight/<version> ( https://github.com/ZacABrewer/arclight [contact email] ). Lookups and edition switches are interactive and go before the jobs, which run at background priority. Artist, release-group, edition and release answers are cached in metadata_cache (7 days, 1 day, 1 day, 30 days); manual refreshes skip the cached artist and release-group answers. Album covers are Cover Art Archive URLs (https://coverartarchive.org/release-group/<mbid>/front-500, 404 when there is none); artist images come from Fanart.tv when musicMetadata.fanartApiKey is set, else from the artist’s Wikidata image.

Jobs. Ad hoc: ArtistAdd (one per artist), RefreshArtist, RescanArtist, ArtistSearch, AlbumSearch. Scheduled: RefreshArtists, MetadataCacheCleanup (see Scheduler jobs). No library WebSocket events are published for music yet; the job events of these jobs say when to refetch.

Contract: packages/shared/src/audiobooks.ts. Authors, their books (one audiobook edition each, keyed by ASIN when known), series and series entries, co-author links (book_authors) and the audio files on disk (audio_files, mediaType: "audiobook"). Metadata comes from the Audible catalogue (search, bibliographies), Audnexus (canonical author, book and chapter records by ASIN) and OpenLibrary (fallback, ISBNs), configured in the audiobookMetadata settings section. Every route answers 404 {code: "AUDIOBOOKS_DISABLED"} while features.audiobooks is off, and the audiobook jobs do nothing. Reads open to app-area users like the music reads (library: author, book, series, audio file; wanted: the wanted lists); everything else is admin-only.

Method Path Purpose
GET /author AuthorListQuery → Author[] with statistics (AuthorStatistics). Filters monitored, qualityProfileId, rootFolderPath, tag, asin, olAuthorId, search (name and aliases); sortKey (sortName default, added, qualityProfileId, path, sizeOnDisk, bookCount), sortOrder; includeStatistics=false skips the counts
GET /author/lookup ?term=&region= → AuthorSearchResult[]: Audnexus author search (OpenLibrary when it finds nothing), interactive priority; authorId set for authors already in the library. An author ASIN (asin: prefix optional) or an OpenLibrary id (OL…A) looks one author up
GET /author/:id AuthorDetails: the author with statistics, books (owned and co-authored, each with its series links: name, position, positionSort, isPrimary) and series (BookSeriesSummary of every series those books are in). The UI groups the books by series (ordered by positionSort) and lists the rest as standalone
POST /author AddAuthorRequest → 201 Author. Checks the root folder (type audiobook), the quality profile (domain audiobook), tags and the exclusion list, reads the author record (Audnexus by asin, else OpenLibrary by olAuthorId; one interactive call), inserts it at <root>/<Author Name> and starts the AuthorAdd job; books arrive while the job runs. searchOnAdd: true starts an AuthorSearch job once the books are loaded (and the folder rescanned). 409 when the author is already added or excluded; 404/502/503 when the catalogue fails
PUT /author/editor BulkAuthorUpdateRequest → Author[]: ids plus any of monitored, monitorOption (re-applied to every book), monitorNewBooks, qualityProfileId, rootFolderPath, tags: {add?, remove?}; refresh: true starts RefreshAuthor for the ids
PUT /author/:id UpdateAuthorRequest → Author: monitored, monitorNewBooks, monitorOption, qualityProfileId, rootFolderPath (path becomes <root>/<Author Name> unless path is given), path, languages (lower-cased; applies from the next refresh), tags. Files are not moved
DELETE /author/:id DeleteAuthorQuery: deleteFiles (removes the author folder, only inside its root folder), addExclusion (never re-add this ASIN, or OpenLibrary id). Deletes the author’s books, series entries, co-author links and audio_files rows in one transaction and prunes series no book is in any more. 204
GET /book BookListQuery (authorId: owned or linked through book_authors; seriesId, monitored, hasFile, asin, isbn, includeStatistics) → Book[] with series and BookStatistics (fileCount, sizeOnDisk, durationMs)
GET /book/lookup BookLookupQuery → BookSearchResult[]: asin / isbn look one book up (Audnexus, OpenLibrary by ISBN; [] when no source knows it); term, or title + author, search the Audible catalogue (OpenLibrary when it finds nothing). bookId / authorId are set for books and authors already in the library. 400 without any of term, title, asin, isbn
GET /book/:id BookDetails: the book with author (AuthorSummary), contributors (library authors linked through book_authors, with role), narrators, chapters and files (AudioFile, part order)
POST /book AddBookRequest → 201 Book: one book by asin or isbn (region defaults to audiobookMetadata.audibleRegion). The book’s primary author is found in the library by ASIN, OpenLibrary id or name; when they are not there, author (AddBookAuthorOptions: rootFolderPath, qualityProfileId, monitored, monitorNewBooks default none, tags) creates them and an AuthorAdd job loads the rest of their bibliography unmonitored (the requested book keeps monitored). 400 when the author is unknown and no author block is sent; 409 when the book is already in the library or excluded
PUT /book/monitor BulkBookMonitorRequest (bookIds, monitored) → Book[]; 404 for unknown ids
PUT /book/:id UpdateBookRequest (monitored) → Book
DELETE /book/:id DeleteBookQuery: deleteFiles (the book folder when it is a folder of its own inside the author folder, else each file), addExclusion (the ASIN, else the ISBN: refreshes skip the book and POST /book refuses it). Deletes the audio_files rows in the same transaction and prunes orphaned series. 204
GET /bookseries BookSeriesListQuery (authorId, search) → BookSeriesSummary[] (bookCount, availableBookCount)
GET /bookseries/:id BookSeriesDetails: the series with its books in position order (positionSort, unnumbered last, then release date) and the authors owning them
GET /audiofile authorId and/or bookId → the audiobook AudioFile[] (shared route with music; mixing music and audiobook ids is 400)
POST /command AudiobookCommand → 202 Job: {name: "RefreshAuthor", authorIds}, {name: "RescanAuthor", authorIds}, {name: "AuthorSearch", authorIds}, {name: "BookSearch", bookIds}. Same route as the music commands (the name picks the module; each kind is gated by its own flag). The search commands go through the release pipeline’s audiobook search port; until it is wired the job message is not available: the release pipeline is not running. Unknown ids 404 (no job starts), unknown names 400
GET /wanted/missing/books WantedBooksQuery (page, pageSize, q: title, subtitle, author and credited authors’ names, book series names) → WantedBooksPage (Book + authorName, qualityProfileId): monitored books of monitored authors, released (a year-only date counts from the start of the year), without a file. Newest first
GET /wanted/cutoff/books WantedBooksQuery (same q) → WantedBooksPage: monitored books with files where any file’s quality is below the author’s profile cutoff (the lowest file wins)

Bibliographies and languages. AuthorAdd / RefreshAuthor read the author’s Audible bibliography (OpenLibrary works when Audible has none). Audible lists every language an author is published in, so books are kept to the author’s languages, else the audiobookMetadata.languages setting (empty keeps every language; names as the catalogue writes them, e.g. english). The same title in two languages has two ASINs and stays two books. Each book is upserted by ASIN (else OpenLibrary work, ISBN, or the author’s title); a book another library author already owns gets a book_authors link instead of a copy, and every credited library author but the owner is linked (role from the credit: author, translator, editor, foreword, contributor). Series are matched by ASIN, else by name; position is kept as written (3, 2.5, 1-6) with a numeric positionSort. Books gone from the bibliography without a file are removed when unmonitored or in a language the author no longer keeps; excluded ASINs/ISBNs are skipped. Chapters of up to 10 monitored books per refresh are read from Audnexus.

Monitoring. monitorOption (AudiobookMonitorOption): all, none (also unmonitors the author on add), future, missing / existing (by files), latest / first (by release date among the released books), inSeries (books in any series). On refresh, new books follow monitorNewBooks: all, none, or inSeries.

Statistics. AuthorStatistics over the books the author owns: bookCount, monitoredBookCount, availableBookCount (with a file), missingBookCount (monitored, released, no file), fileCount, sizeOnDisk, seriesCount, percentOfBooks (available monitored books / monitored books). GET /library/statistics gains authorCount, monitoredAuthorCount, bookCount, monitoredBookCount and bookFileCount while audiobooks are on, and audioSizeOnDisk (music and audiobook files) while either audio type is on.

Rescans. RescanAuthor (also run at the end of AuthorAdd when the author folder exists) scans the author folder with the tag reader. One book is one folder (CD 1, Disc 02 (Bonus), Part 2, Pt.3 subfolders fold into it) or a single file directly in the author folder (titled by its file name). Each group is matched to one of the author’s books by the ASIN tag (or [ASIN …] in the path), then series + position (Author/Series/Book 3 - Title), then title (with or without the subtitle; among same-titled books the kept languages win). Every file of a matched book gets an audio_files row (partNumber for multi-file books, quality AB-* from codec and bitrate, chapters from the file); books.has_file and path follow, a chaptered file fills chapters when the catalogue had none, and rows of files that are gone are removed. Files outside the author folder (linked in place by a library import) and files the scan found but could not match keep their link. Unmatched folders are counted in the job message.

Caching. The catalogue clients keep their in-memory caches; the calls the jobs repeat are also stored in metadata_cache (provider audiobook-catalog): author records 7 days, bibliographies 1 day (keyed by region and language filter), books 7 days, chapters 30 days. Manual refreshes skip the stored bibliography. Searches are not stored. MetadataCacheCleanup prunes these rows with the music ones.

Jobs. Ad hoc: AuthorAdd (one per author), RefreshAuthor, RescanAuthor, AuthorSearch, BookSearch. Scheduled: RefreshAuthors (see Scheduler jobs). GET /calendar lists book release dates as book items (mediaType=audiobook; books dated only to the year are left out) and the iCal feed carries them.

imports: music import and manual import (audio mode)

Section titled “imports: music import and manual import (audio mode)”

Contract: packages/shared/src/imports.ts (AudioManualImport*). Code: apps/api/src/modules/imports/audio/music-import.ts, dispatched from imports/service.ts when a tracked download’s mediaType is music.

Completed downloads. The download folder is scanned with the tag reader and grouped into albums (MusicBrainz release-group id, then album and album-artist tags, then the folder; CD1 style disc folders fold into their album). The target is the queue item’s artistId / albumIds (else the grab snapshot, else the release title’s artist). Each group is matched to the target albums’ selected edition (audio.trackMatchThreshold, default 0.8). With anyReleaseOk and no files on the album yet, an edition that fits badly (below the threshold, files left over or tracks missing) is matched again against the album’s other editions; a better fit becomes the selected edition (tracks reloaded from MusicBrainz, like PUT /album/:id). A download without albumIds (or a discography pack) imports every album of the artist it matches, and only those.

Specs per file: absolutePath, freeSpace (the album’s total against the artist’s root folder), notUnpacking (temporary), sameFile, alreadyImported, upgrade (a track’s file is replaced only by a strictly better quality under the artist’s profile), trackMapping (one file per track; files that fit no track are rejected), mixedReleases (files of two editions of one album), cueImage (one file plus a cue sheet: the download needs manual import). An incomplete album is accepted with a warning and stays wanted.

Files go to <artist path>/<album folder>/<track file> with the media-management transfer mode (hard link, else copy, or move when the client allows), replaced files to the recycle bin. The track name comes from the naming config when renameTracks is on (multi-disc: the prefix or subfolder style); otherwise the original name (and any disc sub-folder) is kept below the built album folder. Extras of the album folder follow: .cue, .log, cover images, and .lrc (renamed with its track). Files are never retagged. Each file gets an audio_files row (quality from the measured codec/bitrate/bit depth, else the file and folder names, else the release title; codec, bitrate, sample rate, bit depth, duration, tags) linked from tracks.audio_file_id, so album and artist statistics change at once. The download is imported once every target album received files; otherwise it stays importPending with the reasons as status messages (and an import manualRequired event).

Per imported album: one import imported WebSocket event (mediaType: "music", mediaId the artist id, albumIds), one history record (downloadFolderImported, target {mediaType: "music", artistId, albumIds}), one import (or upgrade) notification (“Artist – Album [FLAC]”, with the track list), and a media-server refresh of the album folder (servers flagged for refresh whose music library contains it).

Method Path Purpose
GET /manualimport/audio AudioManualImportQuery → AudioManualImportItem[] (one per audio file); mediaType=audiobook → AudiobookManualImportItem[] (see below)
POST /manualimport/audio AudioManualImportRequest → AudioManualImportResult

GET query (one of folder, downloadId, artistId is required):

Field Meaning
folder Folder (or file) to scan
downloadId A tracked download: its output folder and its artist
artistId Pin the artist (else the download’s, else the tags’ album artist / MusicBrainz artist id, else the folder name); alone it scans the artist folder
albumId Match every group against this album only (the user picked it)
filterExistingFiles Default true: skip files already in audio_files

AudioManualImportItem:

Field Meaning
path, relativePath, name, sizeBytes The file
quality QualityModel the import would record
mediaType Always music
groupKey, groupFolder Files of one album folder share the key; groupFolder folds disc folders
artistId, albumId, albumReleaseId The proposal (absent when nothing fits); albumReleaseId is the album’s selected edition
trackIds [trackId] of the proposed track, with mediumNumber, trackNumber, trackTitle
confidence 0-1, the group’s edition match
trackScore 0-1, this file’s fit to its proposed track
tags What the file says: title, artist, albumArtist, album, trackNo, discNo, durationMs, codec, bitrateKbps, sampleRateHz, bitDepth
rejections Why automatic import would refuse it (ImportRejection[]: unknownArtist, unknownAlbum, trackMapping, cueImage, mixedReleases, notQualityUpgrade, sameFile …). Files below the threshold still get the best album and track proposed
warnings Accepted with a note, e.g. the album is incomplete
downloadId When listed for a tracked download

POST body: { items: AudioManualImportRequestItem[], importMode: "auto" | "move" | "copy" } (auto: move unless the tracked download may not be moved). Each item: path, artistId, albumId, trackIds (exactly one track of the album’s selected edition), optional albumReleaseId (must equal the selected edition: to use another edition, switch it with PUT /album/:id {selectedReleaseId} and list again), quality (override), downloadId. Specs are bypassed (like video manual import); a track mapped twice, a track of another album or edition, or a missing file fails that item only. A file already inside the artist folder is registered in place. Result: { imported: [{path, audioFileId, trackId, destination}], failed: [{path, error}] }. Each album imported gets the same events, history, notification and refresh as an automatic import (manual: true), and a tracked download becomes imported once all its albums have files from it.

Completed downloads with mediaType: "audiobook" go to the audiobook importer (imports/audio/audiobook-import.ts). The target is the download’s authorId / bookIds (from the grab); without an author the release title’s author is looked up. The download is split into books: one book is one folder (CD2, Disc 1, Part 3 sub-folders fold into it) or, inside one folder, one group of files sharing an ASIN or album tag (a pack of single-file books). Each book matches a target book (all the author’s books when the grab named none) by ASIN/ISBN tag, title (tags, file and folder names) and series position. Parts are ordered by disc tag, then track tag, then natural file-name sort; a chaptered m4b is one part.

Specs per book (a book imports all its parts or none): absolutePath, minimumFreeSpace, unpacking, sameFile, alreadyImported, notQualityUpgrade (best new part against the best existing file) and durationMismatch (the parts’ total must be within audio.audiobookDurationTolerance percent, default 15, of the book’s runtime; books without a runtime pass). A tagged narrator that is none of the book’s narrators is a warning.

Files go to <author folder>/<book folder>/ (bookFolderFormat, or seriesBookFolderFormat when the book is in a series) with transferFile (hard link, copy or move; replaced files go to the recycle bin). A single file keeps its name unless renameAudiobooks; multi-part books are renamed with bookPartFormat ({Part:00} - {Book Title}) when audio.renameAudiobookParts (default on) or renameAudiobooks is on. Files are never retagged. With audio.writeAbsSidecars (default off) the book folder also gets desc.txt (overview), reader.txt (narrators) and cover.jpg (fetched from the book’s catalogue cover URL, only then). Every file gets an audio_files row (mediaType: "audiobook", authorId, bookId, partNumber, chapterCount) and the book gets hasFile: true and path.

Per imported book: one import imported event (mediaType: "audiobook", mediaId the author id), one history record (target {mediaType: "audiobook", authorId, bookIds}), one import (or upgrade) notification (“Author – Title (narrated by X)”), and a media-server refresh of the book folder (servers flagged for refresh with an audiobook library containing it: Audiobookshelf book libraries, Jellyfin/Emby books, Plex artist sections by path).

audio settings (GET/PUT /settings/audio, AudioImportSettings):

Field Default Meaning
trackMatchThreshold 0.8 Music: minimum release match
audiobookDurationTolerance 15 Percent the parts’ total may differ from the book’s runtime
renameAudiobookParts true Rename multi-part books with bookPartFormat even with renameAudiobooks off
writeAbsSidecars false Write desc.txt, reader.txt, cover.jpg into the book folder

PUT replaces the section: send every field.

The same routes with mediaType: "audiobook":

GET /manualimport/audio?mediaType=audiobook (one of folder, downloadId, authorId is required) returns AudiobookManualImportItem[], one per audio file:

Query field Meaning
folder Folder (or file) to scan
downloadId A tracked download: its output folder, author and books
authorId Pin the author (else the download’s, else the tags’ artist, the path, the release title); alone it scans the author folder
bookId Match every group against this book only
filterExistingFiles Default true
Item field Meaning
path, relativePath, name, sizeBytes, quality The file and the quality the import would record (AB-*)
mediaType Always audiobook
groupKey, groupFolder Files of one book share the key
authorId, bookId, bookTitle The proposal (book ids absent when nothing fits)
partNumber, partCount Proposed part order within the group (1-based)
confidence 0-1, the group’s book match
groupDurationMs Sum of the group’s durations (null when one is unknown)
runtimeMs The proposed book’s catalogue runtime (null when unknown)
tags title, author, album, narrator, series, seriesPart, asin, isbn, trackNo, discNo, durationMs, chapterCount, codec, bitrateKbps
rejections unknownAuthor, unknownBook, durationMismatch, notQualityUpgrade, sameFile …
warnings E.g. a narrator mismatch
downloadId When listed for a tracked download

POST /manualimport/audio items with mediaType: "audiobook": {path, mediaType, authorId, bookId, partNumber?, quality?, downloadId?} (music and audiobook items may be mixed in one request). Items with the same book and download import together as that book, ordered by partNumber (the proposed order when omitted; duplicate numbers fail the book). Specs are bypassed. Result items carry bookId and partNumber instead of trackId: { imported: [{path, audioFileId, bookId, partNumber, destination}], failed: [{path, error}] }. A tracked download becomes imported once all its books have files from it.

Library import (existing music and audiobook folders)

Section titled “Library import (existing music and audiobook folders)”

Contract: packages/shared/src/library-import.ts (LibraryImport*). Code: apps/api/src/modules/imports/audio/library-import.ts (routes in library-import-routes.ts). Brings a collection that is already on disk into the library: folders whose files are not in audio_files yet become candidates, each with a proposed catalogue book or MusicBrainz release group. Every route is gated by the flag of the media type it names (404 MUSIC_DISABLED / AUDIOBOOKS_DISABLED). Scans are kept in memory (the last six); a restart forgets them.

Method Path Purpose
POST /libraryimport/scan LibraryImportScanRequest → 202 LibraryImportScan (starts the LibraryImportScan job)
GET /libraryimport/scan LibraryImportScanSummary[], newest first (no candidates)
GET /libraryimport/scan/:id LibraryImportScan: the summary plus candidates (poll while status is scanning, matching or importing)
DELETE /libraryimport/scan/:id 204; 409 while its job runs
GET /libraryimport/lookup ?mediaType=&term= → LibraryImportMatch[]: free text, an ASIN, or a release-group / release MBID (to change a match)
POST /libraryimport/import LibraryImportRequest → 202 LibraryImportScan (starts the LibraryImport job)

POST /libraryimport/scan body: { mediaType: "music" | "audiobook", path, rootFolderPath? }. path is an absolute folder (404 when it cannot be read). rootFolderPath is the registered root folder of that media type new artists / authors go under; it defaults to the root folder holding path (400 when path is outside every root and none is given). Scanning the same path while a scan of it runs answers the running scan.

Scan summary: id, mediaType, path, rootFolderPath, status (scanning → matching → ready; importing while an import runs; failed with error), progress {done, total} (files read while scanning, folders while matching, items while importing), jobId, startedAt, finishedAt, stats {files, alreadyInLibrary, unreadable, candidates, imported, failed}.

Grouping. Audiobooks: one book is one folder; CD 1, Disc 02 (Bonus), Part 3, Pt.4 sub-folders fold into it. In a folder that also holds other book folders (an author folder) or the scanned folder itself, every loose file is a book of its own unless its ASIN / album tag or its name ties it to others (“Title.m4b” and “Title - Part 2.m4b”; “Title - 01.mp3”, “Title - 02.mp3”). A plain book folder splits only when its files carry different ASIN or album tags, or when it holds several distinct single-file books (.m4b / .m4a). Parts are ordered by disc tag (else the disc or part folder’s number), then track tag, then natural file-name order. Music: the album grouping of the importer (release-group id, release id, album + album-artist tags, then the folder; disc folders fold in). Extensions are matched case-insensitively; .wma counts as audio.

What the files say (detected): tags first (album, album artist / artist, narrator or a composer that is not the author, series and series part, date, ASIN including Audible’s CDEK atom, ISBN, MusicBrainz ids), then the path relative to the root folder: Author/Book, Author/Series/01 - Book, Author - Title (Year), Title (Year), {Narrator}, [ASIN], a loose Author/Title.m4b; music Artist/Album (Year) and Artist - Album [Year]. Titles are searched without “(Unabridged)”, bracketed parts and “Book 3 -” prefixes.

Matching. Audiobooks: an ASIN (tag or path) is looked up directly (matchedBy: "asin", confidence 1), then an ISBN (isbn); otherwise the catalogue is searched by title and author (then the other title spelling, then the title alone) and each result scored: title 65 %, author 25 %, series position, narrator and runtime 10 % (matchedBy: "search"). Music: a release-group id tag, else the release id’s group (mbid, confidence 1), else a MusicBrainz release-group search by album artist and album (then free text), scored title 60 %, artist 30 %, year 10 %. The best result scoring at least 0.35 is the proposal (match, confidence); up to five others are alternatives. A failed lookup leaves the candidate without a match and adds a warning.

Candidate: id, mediaType, folder, relativeFolder, files[] (path, relativePath, sizeBytes, durationMs, readable), sizeBytes, durationMs, detected (author, title, series, seriesPosition, narrator, year, asin, isbn, mbReleaseGroupId, mbReleaseId, mbArtistId), match / alternatives (LibraryImportMatch: foreignId = book ASIN (or isbn:<isbn>) or release-group MBID, title, subtitle, creditName, creditForeignId (author ASIN / artist MBID), year, coverUrl, narrators, runtimeMinutes, series, seriesPosition, albumType, inLibrary, score), confidence, matchedBy, warnings, status (pending, importing, imported, failed) and message (the import’s result).

Warnings, not blockers. The automatic import’s durationMismatch check (the parts’ total against the book’s runtime) is only a warning here, as are unreadable files (imported by folder and name, quality from the extension), mixed MusicBrainz release ids and a match that is already in the library (the files are added to it).

POST /libraryimport/import body: { scanId, items: [{candidateId, foreignId?}], mode?, qualityProfileId, albumTypeProfileId?, tags? }. foreignId overrides the proposal (400 when a candidate has neither). The scan must be ready (409 otherwise). Per candidate, in order:

  • Audiobooks: a book with that ASIN in the library is used; otherwise it is added like POST /book with an author block (monitorNewBooks: none, no search): a new author’s other books arrive unmonitored through AuthorAdd, and the author’s folder becomes the item’s nearest ancestor named like the author (a flat Author - Title layout keeps <root>/<Author>).
  • Music: an album with that release group is used; otherwise the credited artist is added like POST /artist with monitorOption: none, monitorNewAlbums: none and no search (then marked monitored), the artist folder taken over the same way; a release group the album-type profile leaves out is added on its own. The edition is the one the files are tagged with, else the selected one when its track count equals the file count, else the preferred edition with that many tracks; its tracks are loaded and the files matched to them (threshold 0.3: the user picked the album).
  • The imported book / album is monitored, nothing else is. mode: "inPlace" (default) links the files where they are (no move, no rename); mode: "move" hands them to the manual-import mover (POST /manualimport/audio with importMode: move: naming settings, author / artist folder).

The candidate ends imported with a message (“Linked 12 files to OK Computer (1 file fits no track)”) or failed with the reason. Author and artist rescans keep files they cannot speak for: files outside the author / artist folder and files the rescan found but could not match keep their link (files that are gone are still removed).

Method Path Purpose
GET /qualityprofile List quality profiles
GET /qualityprofile/schema Blank profile template for the UI
GET /qualityprofile/:id One quality profile
POST /qualityprofile Create
PUT /qualityprofile/:id Update
DELETE /qualityprofile/:id Delete
GET /customformat List custom formats
GET /customformat/schema Available spec implementations
POST /customformat/test Test a custom format against a release title
GET /customformat/:id One custom format
POST /customformat Create
PUT /customformat/:id Update
DELETE /customformat/:id Delete
GET /delayprofile List delay profiles
PUT /delayprofile/reorder Reorder delay profiles
GET /delayprofile/:id One delay profile
POST /delayprofile Create
PUT /delayprofile/:id Update
DELETE /delayprofile/:id Delete
GET /releaseprofile List release profiles
GET /releaseprofile/:id One release profile
POST /releaseprofile Create
PUT /releaseprofile/:id Update
DELETE /releaseprofile/:id Delete
GET /qualitydefinition List quality definitions (size limits)
PUT /qualitydefinition/:qualityId Update one definition

CustomFormat responses carry guideId (read-only): the TRaSH trash_id the format is synced from by the guides module, null for user-created formats.

Moving items between quality profiles, file fit against the governing profile, and the replacement work list. Contract: packages/shared/src/profile-migration.ts.

Method Path Purpose
GET /profilemigration/settings Automatic-mode settings (enabled, intervalMinutes, searchesPerRun, maxSearchesPerDay, maxAttempts, retryAfterHours)
PUT /profilemigration/settings Update them (partial); reschedules the ProfileMigration job
GET /profilemigration/profiles ProfileMigrationProgress per quality profile: rule, switches, file fit counts, mismatched items, queue counts, pendingSearches
GET /profilemigration/profiles/:id One profile’s progress
PUT /profilemigration/profiles/:id { mismatchAction?, autoEnabled?, paused? }; rebuilds the work list
POST /profilemigration/profiles/:id/search “Search all now”: 202 { searches, job } (400 without a rule)
POST /profilemigration/profiles/:id/retry Failed items back to queued
POST /profilemigration/preview { selection, toProfileId } -> ProfileMigrationPreview (no changes)
POST /profilemigration/apply Same body; changes the profiles, records profileChanged history, returns the preview and the changed ids
GET /profilemigration/mismatch Paged files that do not meet their profile; status (notAllowed by default, upgradeAllowed; comma list), mediaType, qualityProfileId, q (text search over title, year, episode code, profile name, quality and version slot; see List text search), page, pageSize. Each record carries its work-list row (migration: state, attempts, lastSearchedAt, nextSearchAt retry time, message) and replacement {state, nextSearchAt, maxAttempts}: whether the file will be replaced (scheduled, downloading, failed, manualOnly, paused, globalOff, noRule, cutoffSearch, unmonitored) and an estimate of the ProfileMigration run that searches it (queue order, searchesPerRun, the rolling maxSearchesPerDay, retryAfterHours)
GET /profilemigration/items Items with at least one file that does not meet its profile (library badges)
POST /profilemigration/reevaluate Full re-evaluation as a ProfileFitRefresh job (202)

selection combines (AND) mediaType, seriesIds, movieIds, fromProfileId, rootFolderPath, tagIds (any of them) and versionTag; at least one of ids, profile, root folder or tag is required. mismatchAction is none (default), replace or replaceIfBetter; the decision engine and the import upgrade check read it per profile (ReleasesDeps.profileMigration, ImportsDeps.profileMigration). History gains the event types profileChanged and profileReplaced.

TRaSH-style sync of custom formats and quality-profile scores from a guide repository (GuidesSettings, settings section guides). The guide’s metadata.json names the custom-format and quality-profile folders per app; GitHub-hosted repos are listed through the contents API (api.github.com/repos/<owner>/<repo>/contents/<dir>?ref=<branch>), files are read from raw.githubusercontent.com; other hosts must serve <dir>/index.json (a JSON array of file names). Responses are cached in guides_cache (ETag revalidation; files whose git blob sha is unchanged are not downloaded). Synced formats = formats the mapped guide profiles list plus includeFormats globs, minus excludeFormats (names or trash ids, */?). A sync never deletes a format and never changes a user-created one unless its adoptFormat item is applied by id. Changes are announced as an applicationUpdate notification (previousVersion/newVersion = guides <revision>).

Method Path Purpose
GET /guides/status GuidesStatus: settings summary, last sync/success/error/apply, pending count, last fetch stats, synced format count
GET /guides/diff The pending GuidesDiff: items (createFormat, updateFormat, adoptFormat, setScore; stable ids format:<trashId> / score:<profileId>:<trashId>), staleFormats (synced formats the guide dropped; never deleted), warnings
POST /guides/sync Fetch + diff now; mode notify stores the diff, mode apply applies it (adoptFormat stays pending). GuidesSyncResult; 502 UPSTREAM when the guide is unreachable
POST /guides/apply {ids?}: apply the pending diff or a subset; without ids everything except adoptFormat. Idempotent. GuidesApplyResult (applied, skipped[{id, reason}], counts, pendingCount)
GET /guides/profiles GuideProfileInfo[]: quality profiles the guide offers (score set, formats, mappedTo); ?refresh=true refetches the profile files
GET /settings/guides GuidesSettings: enabled (false), source trash/custom, repoUrl, branch (master), apps (["radarr"]), syncIntervalHours (24), mode notify/apply, profiles[{guideProfileName, localQualityProfileId, app?}], includeFormats, excludeFormats, scoreAllSyncedFormats (true)
PUT /settings/guides Replace the section; reschedules GuidesSync
Method Path Purpose
GET /indexer List indexers
GET /indexer/schema Indexer templates (Torznab, Newznab)
GET /indexer/categories Standard category list
GET /indexer/status Failure/backoff status per indexer
GET /indexer/stats ?days=1..90 (default 30): IndexerStats, queries / grabs / failures / mean response ms per indexer and per UTC day, plus totals
GET /indexer/health ?days=1|7|30 (default 7): IndexerHealth, per indexer queries, failure rate, mean / p95 response ms, mean results, zero-result share, grabs, last success / failure (with message), backoff state and a verdict (healthy, slow, failing, unused, noResults); the thresholds are in the response
POST /indexer/test Test unsaved indexer settings (any implementation)
POST /indexer/testall Test every saved indexer
GET /indexer/:id One indexer
GET /indexer/:id/caps Capabilities (?refresh=true refetches)
GET /indexer/:id/download ?url= a result link of that indexer: streams the .torrent / .nzb as an attachment, or 302 to a magnet. Goes through the indexer (definition login cookies, proxies). Links must be a recent search result or on one of the indexer’s hosts (400 otherwise); 404 UNAVAILABLE when the indexer no longer has it
POST /indexer Create
PUT /indexer/:id Update
DELETE /indexer/:id Delete
GET /search Manual search across enabled indexers; returns a release list (results stay grabbable for 30 min)
POST /search/grab SearchGrabRequest {guid, indexerId, force?, downloadClientId?}: re-runs the decision engine on the cached search result and grabs it. No library match → 409 NO_MATCH unless force, which sends it untracked to the default client of its protocol (unknown in the queue). 404 when the result is no longer cached
GET /settings/indexers IndexerGlobalSettings: minimum age, retention, maximum size (shared with /settings/releases), RSS interval minutes (0 = off, else 10-1440), hardcoded subs, whitelisted subtitle tags, prefer indexer flags
PUT /settings/indexers Replace them; reschedules RssSync immediately

Indexer implementations are torznab, newznab and definition. A definition indexer’s settings carry definitionId and fields (values for the definition’s settings list: checkboxes boolean, selects an option key, the rest strings; sitelink picks a mirror); its baseUrl is the site link in use. Indexers take an optional proxyId; without it every proxy sharing one of the indexer’s tags applies. Responses add definitionName, privacy and proxyNames.

Four layers, later ones overriding earlier ones by id: the community catalogue in <config>/definitions/community/ (only while enabled), bundled definitions, the definition feed’s downloads in <config>/definitions/feed/, user YAML files in <config>/definitions/*.yml (watched). source in summaries is community, builtin, feed or custom. Community files that do not load are not in this list (they are catalogue entries with support: unsupported).

Method Path Purpose
GET /indexer/definition IndexerDefinitionSummary[], filterable by privacy, protocol, language (prefix) and query
GET /indexer/definition/:id IndexerDefinitionDetail: summary + search modes, requiresLogin, load warnings
POST /indexer/definition {yaml}: validate and save a custom definition (201; 400 with the parse error)
DELETE /indexer/definition/:id Delete a custom definition file (404 for bundled ones)
POST /indexer/definition/reload Re-read the folders: {total, builtin, feed, custom, community, errors[{file, code, message}]} (errors excludes community files)

Everything the Add indexer list shows, in one shape (packages/shared/src/indexer-catalogue.ts): every definition above, community files that failed to load, the Newznab/Torznab presets (packages/indexers/src/presets.ts) and the two generic endpoints.

Method Path Purpose
GET /indexer/catalogue IndexerCatalogue {etag, counts{total, supported, limited, unsupported, bySource}, community{enabled, lastSuccessAt, lastError}, entries[]}. Sent with a weak ETag and cache-control: no-cache; If-None-Match with the current tag answers 304 with no body
GET /indexer/catalogue/entry/:key IndexerCatalogueEntryDetail: the entry plus searchModes, requiresLogin, warnings, and file for unsupported ones; 404 unknown key

An IndexerCatalogueEntry has key (definition:<id>, preset:<id>, generic:torznab/generic:newznab), kind, id, name, description, language, protocol, privacy, links, categoryIds, categoryGroups (movies, tv, anime, music, books, xxx, other; a parent category listed only because of a mapped subcategory does not count), tested, source (builtin/feed/custom/community/preset/generic), support (supported; limited = loads with a warning about an unsupported feature such as a login captcha; unsupported = does not load) with supportReason, requiresFlareSolverr, overrides (sources with the same id this entry hides) and, for presets and generic endpoints, preset {implementation, baseUrl, apiPath, categories, animeCategories, apiKeyHint?}. Whether the user already has an indexer for an entry is decided by the web (definition id, or preset base-URL host).

Settings indexers.communityCatalogue (CommunityCatalogueSettings): enabled (true), url (default https://api.github.com/repos/Prowlarr/Indexers/contents/definitions/v11?ref=master, DEFAULT_COMMUNITY_CATALOGUE_URL; schema COMMUNITY_CATALOGUE_SCHEMA = 11), intervalHours (24). The listing is either a GitHub contents array (name, type, sha = git blob SHA-1, size, download_url; non-YAML files and folders are skipped) or an Arclight feed index.json (sha256 over LF text). A sync sends If-None-Match with the last ETag (304 = nothing to do), copies files whose hash is unchanged, downloads the rest (8 at a time, max 2 MiB each, at most 5000 files), verifies the hash and swaps <config>/definitions/.community-staging into <config>/definitions/community with the old folder kept as .community-previous. A file that fails keeps its previous copy and clears the ETag so it is retried; if every download fails, or the listing is unreadable, the sync fails and the live folder is untouched. State lives in <config>/definitions/.community-state.json. Every file is loaded by the definition store (parse results are cached by size and mtime). A GitHub rate limit (403/429 with x-ratelimit-remaining: 0) is reported with its reset time. The catalogue registers as feed communityDefinitions (FeedStale health check); IndexerCatalogueEmpty warns when an enabled, synced catalogue loads nothing.

Method Path Purpose
GET /indexer/catalogue/community/status CommunityCatalogueStatus: settings, last sync/success/error, sourceUrl, files, supported, limited, unsupported, overridden, topReasons[{reason, count}], previousAvailable, previousSyncedAt, stale
POST /indexer/catalogue/community/sync Sync now: {notModified, added, updated, removed, unchanged, failed[{file, reason}], total}; 400 VALIDATION when turned off, 502 UPSTREAM on a failed listing/sync
POST /indexer/catalogue/community/rollback Swap back the folder the last sync replaced (kept until the listing changes); 404 when there is none
GET /indexer/catalogue/community/settings CommunityCatalogueSettings
PUT /indexer/catalogue/community/settings Replace them; reschedules IndexerCommunityCatalogueSync and reloads the definitions when enabled changes

Settings indexers.definitionFeed (DefinitionFeedSettings): enabled (true), url (default https://raw.githubusercontent.com/ZacABrewer/arclight/main/packages/indexers/src/definitions/builtin/, this repository’s builtin folder; owner/repo are ARCLIGHT_GITHUB_* in packages/shared/src/project.ts), intervalHours (12), mode auto/manual. While the default feed’s index.json answers 404 (repository not published yet) a sync succeeds with no changes and the feed reports state: never with detail “feed not published yet” instead of an error or a stale-feed warning; any other URL’s 404 is an error as before. The feed serves index.json = {schemaVersion: 1, definitions: [{id, version, file, sha256}]} (sha256 over the UTF-8 text with CRLF normalised to LF) and the files it names, relative to url. Changed entries are downloaded, verified (sha256, package parser, id) and installed into <config>/definitions/feed/; the replaced version is kept in feed/.previous/; entries no longer listed are removed; the catalogue is reloaded and an applicationUpdate notification sent. Rejected entries keep the installed version. The bundled set is published in the same format as packages/indexers/src/definitions/builtin/index.json (node scripts/build-definition-index.mjs, --check to verify), so the repository can serve as its own feed.

Method Path Purpose
GET /indexer/definition/feed/status DefinitionFeedStatus: settings, last sync/success/error, index schemaVersion, installed (version, sha256, previousVersion, active = not overridden by a user file), rejected[{id, version, reason}]
POST /indexer/definition/feed/sync Sync now: {notModified, added, updated, removed, unchanged, rejected}; 502 UPSTREAM when the index cannot be read or is invalid
POST /indexer/definition/feed/rollback/:id Restore the kept previous version (the rolled-back version is skipped until the feed publishes a different one); 404 not a feed definition, 400 no previous version
GET /indexer/definition/feed/settings DefinitionFeedSettings
PUT /indexer/definition/feed/settings Replace them; reschedules IndexerDefinitionFeedSync
Method Path Purpose
GET /indexerproxy List (password masked as ********)
GET /indexerproxy/:id One proxy
POST /indexerproxy Create {name, type: flaresolverr|http|socks5, host, port, username?, password?, requestTimeout? (s), tags}
PUT /indexerproxy/:id Partial update; the mask as password keeps the stored one
DELETE /indexerproxy/:id Delete (indexers pointing at it fall back to tag matching)
POST /indexerproxy/test Test an unsaved proxy: FlareSolverr sessions.list ping, or an HTTP CONNECT / SOCKS5 tunnel to url (default https://www.google.com/)
POST /indexerproxy/:id/test Test a saved proxy ({url?})
Method Path Purpose
GET /downloadclient List clients
GET /downloadclient/schema Client templates (qBittorrent)
GET /downloadclient/:id One client (password never returned)
POST /downloadclient Create
PUT /downloadclient/:id Update
DELETE /downloadclient/:id Delete
POST /downloadclient/test Test unsaved client settings
POST /downloadclient/testall Test every saved client
GET /remotepathmapping List remote path mappings
GET /remotepathmapping/:id One mapping
POST /remotepathmapping Create
PUT /remotepathmapping/:id Update
DELETE /remotepathmapping/:id Delete
GET /queue Paged queue of tracked downloads
GET /queue/status Queue totals; diskGuard {paused, held, volumes, minimumFreeBytes}: downloads the disk-space guard paused and grabs it holds
GET /queue/summary QueueSummaryItem[]: one compact row per queue item linked to a series or movie (id, mediaType, seriesId, episodeIds, movieId, trackedState, trackedStatus, status, progress, eta, stateCode, stateSeverity; no titles or paths), for the library’s Queued / Downloading / Importing / Failed chips. Read-only for the Library and Activity app areas
GET /queue/:id One queue item (with stateCode / stateReason / stateSeverity, stalledSince, live rates and peers)
GET /queue/:id/timeline What happened to the download (grabbed, paused by the kill switch, stalled, imported, …), oldest first; kept 30 days, also after it left the queue
DELETE /queue/bulk Remove several items (body has ids and options)
DELETE /queue/:id Remove one item (query options)
POST /queue/grab/:id Retry a pending item (“Send now”; also overrides a disk-space hold)
POST /queue/:id/replace “Try another release”: QueueReplaceRequest {blocklist = true, removeData = true}. Removes the download from its client, fails it through history (blocklist with the reason) and starts a ReplacementSearch job for its episodes / movie. Answers QueueReplaceResult {id, blocklisted, searchStarted, message}
POST /queue/:id/reannounce Ask the client for fresh peers (built-in engine: trackers + DHT; qBittorrent: reannounce). 400 when the client cannot, or the download is not active. Answers {id, message}

Self-healing (Settings > Media Management): a stalled download is re-announced at once (stallRecovery) and, still stalled stallReplaceMinutes (default 30, 0 = never) after it was flagged, replaced as above with the reason “stalled: no data for N min”, unless it is past stallReplaceMaxProgress (default 90%), paused, the engine is locked down or grabs are blocked by the VPN guard. The disk-space guard (diskSpaceGuard, uses minimumFreeSpaceMb) holds grabs that would leave the client’s download folder below the minimum as pending items (state code diskHeld, retried by the RetryHeldGrabs job every 2 minutes) and pauses downloads on a volume below the minimum (diskPaused), resuming them at the minimum + 1 GiB. Timeline event types reannounced, replaced and held; paused/resumed carry data.reason = 'diskSpace'.

| POST | /queue/:id/limits | { downloadLimit?, uploadLimit? } in bytes/s (0 = unlimited) for one download; built-in engine (kept in its resume data) and qBittorrent. Returns the queue item; 409 when the client cannot | | POST | /queue/:id/move | { move: top / up / down / bottom }: download queue order (built-in engine; qBittorrent with torrent queueing on) | | POST | /queue/:id/forcestart | { enabled }: run past the active-download limit without taking a slot |

Queue items’ live block carries downloadLimit, uploadLimit, queuePosition (1-based among unfinished downloads) and forceStart when the client reports them.

Implemented in apps/api/src/modules/requests (contract: packages/shared/src/requests.ts; behaviour: docs/spec/requests.md). The request, discover, issue, watchlist and own-account routes below are open to role user (config.access.portal); slider, override-rule and settings writes, user permission/quota edits, media-server settings and imports, the watchlist sync and the digest are admin-only. /user/:id/... routes open to users act on the caller’s own account only. The service enforces the permission bitfield (Permission in the contract). API-key and local-bypass callers act as an admin; when they create a request without userId it is owned by the oldest admin account. Errors are { error, code } with 403 FORBIDDEN (permission or quota), 404 NOT_FOUND, 409 CONFLICT (duplicate or wrong state), 400 VALIDATION, 503 NOT_CONFIGURED (no TMDB key).

Method Path Purpose
GET /request RequestListQuery (take, skip, filter, mediaType, requestedBy, sort, sortDirection, q) → RequestPage. q matches title, year, media type, status, decline/failure reason and the requester’s username and display name (never emails). Without MANAGE_REQUESTS/REQUEST_VIEW only the caller’s own; asking for another user’s is 403
GET /request/count RequestCount over the requests the caller may see
GET /request/:id One MediaRequest (owner, MANAGE_REQUESTS or REQUEST_VIEW)
POST /request CreateRequest → 201 MediaRequest, or 202 { message } when every season is already requested/available. Checks permission (REQUEST/REQUEST_MOVIE/REQUEST_TV, 4K variants), quota, duplicates, import exclusions; applies override rules; auto-approves when permitted, globally enabled or a matching rule says so
PUT /request/:id UpdateRequest on a pending request (owner or MANAGE_REQUESTS; overrides need REQUEST_ADVANCED)
DELETE /request/:id MANAGE_REQUESTS, or the owner while pending
POST /request/:id/approve MANAGE_REQUESTS (admins included). Optional ApproveRequest overrides: rootFolderPath, qualityProfileId, tags (stored on the request), monitorOption (series: applied when the series is added, or re-applied to an existing one; the requested seasons are always monitored too), minimumAvailability (movies), seasons: number[] (series: replaces the requested seasons, checked like PUT /request/:id). Series-only fields on a movie request (and minimumAvailability on a series) answer 400 and leave the request pending. Adds the title to the library and searches when the settings say so
POST /request/:id/decline MANAGE_REQUESTS; optional { reason }
POST /request/:id/retry MANAGE_REQUESTS; re-runs a failed approval
GET /request/:id/policy Owner, MANAGE_REQUESTS or REQUEST_VIEW → RequestPolicyExplanation {decision: approve/hold/pending, held, estimatedSizeBytes, nextWindowAt, steps[{rule, outcome, message}]}: the auto-approval chain (permission, settings, override rule, user override, size, budgets, windows) evaluated now with the stored size estimate
POST /request/bulk/approve BulkRequestAction → BulkRequestResult
POST /request/bulk/decline BulkRequestAction → BulkRequestResult

Evaluated in POST /request after the permission, global autoApprove and override-rule checks, only when none of them approved (RequestsSettings):

  • autoApproveIfSizeBelowGb (GB = GiB; null off): approve when the best approved release is smaller. The size comes from a quick interactive search (releases.interactiveSearch, 20 s timeout; series: the sum over the requested seasons) and only for titles already in the library; otherwise the size is unknown and the rule is skipped. It is stored on the request (media_requests.estimated_size_bytes) and only searched for when a policy needs it.
  • approvalBudgets[] {scope: user|role|all, role?, userIds?, movieGb?, tvSeasons?, periodDays, autoApproveUnder: {sizeGb?}}: approve while the scope’s approved/completed usage of the last periodDays plus this request fits (movieGb: estimated sizes of movie requests, unknown sizes count 0, the request itself must have a size; tvSeasons: seasons). user: per user (optionally only userIds); role: per role (optionally only role); all: shared. Any applicable budget with room approves.
  • approvalWindows[] {days 0-6, startHour, endHour (exclusive; <= start wraps midnight), action} in server local time. Inside an autoApprove window every request is approved; with such windows configured, requests outside them are held (pending, heldByPolicy: true, media_requests.policy_hold = 'window') until one opens. Inside a hold window nothing is auto-approved; requests that would have been are held until it closes.
  • Per-user override (users.settings.approvalPolicy, shown on the user resource, set through PUT /user/:id/permissions): autoApprove inherit/always/never, autoApproveIfSizeBelowGb (replaces the global one), budgetExempt (neither limited by nor counted in budgets).

Policy approvals have no human approver (modifiedBy: null). Job RequestsPolicySweep (hourly) re-evaluates held requests: approves (and sends to the library) those the policies now approve, and releases those no window holds any more to plain pending. Manual approve/decline clears the hold.

users.settings.contentRestrictions (ContentRestrictions, set by MANAGE_USERS through PUT /user/:id/permissions): maxCertification.movie G/PG/PG-13/R/NC-17/NR and maxCertification.tv TV-Y/TV-Y7/TV-G/TV-PG/TV-14/TV-MA (null = no cap), hideAdultContent (hides NC-17 movies and TV-MA series). Certifications are TMDB’s (metadata region, else US), cached per title for a day (failed lookups for a minute). A title without a known US rating counts as unrated: only a movie cap of NR admits it, a TV cap never does. Capped users see discover lists, sliders, similar/recommended titles and person credits filtered; title pages above the cap answer 403; POST /request (also on their behalf) answers 403 with the reason. Public-portal visitors get hideAdultContent.

users.settings.requestTypes (RequestTypes, set by MANAGE_USERS through PUT /user/:id/permissions): booleans movie, series, music, audiobook, subtitles (language requests of kind subtitle), audioLanguages (kind audio), each defaulting to true. false refuses that type even when the permission bits allow it (REQUEST grants every type); true leaves it to the bits and never grants anything. ADMIN is not filtered; absent or malformed means every type on. Refusals are 403 FORBIDDEN with "<type> requests are turned off for this user" (movie, series, music, audiobook, subtitle, audio language), on POST /request (also collection requests), POST /audiorequest and POST /request/language. Requests made on behalf of a user (userId) apply that user’s filter. Watchlist sync skips types that are off. TitleDetail.canRequest / canRequest4k and CollectionDetail.canRequest / canRequest4k are false for a type that is off; LanguageRequestOptions carries canRequestSubtitles and canRequestAudio (and canRequest is false when both are off). The web reads the filter for music and audiobook buttons from the session user’s settings. New accounts (POST /user for role user, media-server sign-in and import, Seerr import) get RequestsSettings.defaultRequestTypes, stored only when it turns a type off; explicit settings.requestTypes on POST /user wins.

RequestsSettings.publicPortal {enabled, allowAnonymousDiscover, message, paths, hosts}. paths are extra portal paths (one segment each, normalised to /slug; segments the app or the server use, such as /api, /settings or /assets, are refused: RESERVED_PORTAL_SEGMENTS). hosts are PortalHost {hostname, portalOnly} entries. Page loads (the SPA fallback, not /api) on a portal path or a portal hostname get <meta name="arclight-portal"> with a PortalDocument ({mode: "path", base} or {mode: "host", portalOnly}) in index.html; a portal-only hostname answers 302 / for any page that is not a portal page, /login or /setup. The hostname comes from Host or X-Forwarded-Host (forwarded headers are always trusted). This hides the admin UI; it is not access control (the api checks every call). While enabled, GET /public/portal → PublicPortal {enabled, title, applicationUrl, message, allowAnonymousDiscover, loginMethods {local, mediaServer {kind, name?} | null, plex}} is public (signed-in callers may always read it). With allowAnonymousDiscover too, every GET /discover/* route and GET /discoverslider answer anonymous visitors (as a user without permissions: canRequest: false, no requests or watchlist). Anonymous calls are rate-limited per client IP by a token bucket (burst 120, 2 tokens / s) answering 429 {code: "RATE_LIMITED"} with Retry-After; signed-in callers are not limited. Requesting always needs sign-in.

Discover (TMDB proxy with Arclight status)

Section titled “Discover (TMDB proxy with Arclight status)”

Each result is a DiscoverItem carrying mediaStatus/mediaStatus4k, the newest active request (or requestStatus: "declined" when the newest request was declined) and the library id, looked up with one batched query per media type per page. hideAvailable/hideRequested settings filter list pages, and so do the same query flags per call; content caps filter them per user (see above). Paged routes answer page, totalPages (at most 500, TMDB’s limit) and totalResults.

TMDB answers are cached in the api (DiscoverCache, apps/api/src/modules/requests/discover-cache.ts): list and search pages 5 minutes, title/person/collection details 6 hours, vocabularies (genres, genre tiles, studio/network logos, watch providers, regions, languages, certifications) 24 hours; identical lookups in flight share one TMDB call, and saving the metadata settings clears the cache. Status is overlaid fresh on every answer. The TMDB client also shares in-flight requests and caps its own 10-minute response cache at 2000 URLs. Region-dependent answers (watch providers, certifications, release dates) use the instance region from Settings > Metadata unless the call names one.

DiscoverQuery (movies/tv) takes page, sortBy, genre and keywords (ids; , = all, | = any), studio, network, language, releaseDateGte/Lte, voteAverageGte/Lte, voteCountGte, runtimeGte/Lte, watchProviders + watchRegion, certification, certificationLte + certificationCountry, hideAvailable, hideRequested.

Method Path Purpose
GET /discover/movies DiscoverQuery → DiscoverPage (TMDB discover/movie)
GET /discover/tv DiscoverQuery → DiscoverPage (TMDB discover/tv)
GET /discover/trending TrendingQuery (mediaType all/movie/tv, timeWindow day/week, page)
GET /discover/upcoming DiscoverKindQuery (mediaType movie/tv): release/first-air date from today
GET /discover/search DiscoverSearchQuery (query, page, mediaType all/movie/tv): movies and series
GET /discover/genres ?mediaType=movie or tv → DiscoverGenre[]
GET /discover/genre/:id DiscoverKindQuery: titles in a genre
GET /discover/network/:id Series on a network (title = network name)
GET /discover/studio/:id Movies by a studio (title = studio name)
GET /discover/keyword/:id DiscoverKindQuery: titles with a keyword
GET /discover/person/:id PersonDetail with annotated cast/crew credits
GET /discover/movie/:tmdbId TitleDetail: details, cast, crew, videos, similar, recommendations, requests, flags
GET /discover/tv/:tmdbId TitleDetail incl. per-season status. Both title routes also carry certification, movie releaseDates {inCinemas, digital, physical} and collection, series firstAirDate/lastAirDate
GET /discover/slider/:id ?page&timeWindow=day|week (trending): the items of one slider (recentlyAdded needs RECENT_VIEW); tile sliders (genres, studios, networks, providers) answer 400 (the web renders those from the tile routes below)
GET /discover/list/:list DiscoverListName (popularMovies, popularTv, upcomingMovies, upcomingTv, nowPlaying, topRatedMovies, topRatedTv, airingToday, onTheAir) + DiscoverListQuery (page, hides) → DiscoverPage
GET /discover/search/people DiscoverTextQuery (query, page) → PersonPage (PersonSummary rows with known-for titles)
GET /discover/keywords DiscoverTextQuery → KeywordPage (keyword ids for the filter bar)
GET /discover/genres/tiles DiscoverTileQuery (mediaType) → DiscoverTile[]: genres with the backdrop of their most popular title
GET /discover/companies DiscoverTileQuery → DiscoverTile[]: the well-known studios (movie) or networks (tv) with logos
GET /discover/providers DiscoverTileQuery (mediaType, region) → DiscoverTile[]: streaming services in the region, most prominent first
GET /discover/info/:kind/:id kind studio/network/keyword → DiscoverEntity {id, name, imageUrl} (page headers)
GET /discover/options DiscoverFilterOptions: instance region and language, regions, languages, certifications per media type
GET /discover/collection/:id CollectionDetail: parts (annotated, capped), missing, canRequest, canRequest4k
POST /discover/collection/:id/request Signed in. CollectionRequestInput {is4k} → CollectionRequestResult {requested, skipped, failed}: one request per part the library lacks and nobody requested (stops at the first quota refusal)
Method Path Purpose
GET /discoverslider All sliders in order (MANAGE_SETTINGS gets all; others the enabled ones they can use: anonymous portal visitors get no recentRequests/watchlist, recentlyAdded needs RECENT_VIEW)
POST /discoverslider MANAGE_SETTINGS: create a custom slider (DiscoverSliderInput)
PUT /discoverslider/order MANAGE_SETTINGS: DiscoverSliderOrder rewrites order and enabled flags
POST /discoverslider/reset MANAGE_SETTINGS: delete custom sliders, restore built-in defaults
PUT /discoverslider/:id MANAGE_SETTINGS: DiscoverSliderUpdate (built-ins: enabled only)
DELETE /discoverslider/:id MANAGE_SETTINGS: custom sliders only
Method Path Purpose
GET /issue IssueListQuery → IssuePage (own only without MANAGE_ISSUES/VIEW_ISSUES). q matches title, year, media type, issue type, status, the reporter’s username and display name, and the comment text (the first comment is the report’s message)
GET /issue/count IssueCount
GET /issue/:id One Issue with comments
POST /issue CreateIssue (MANAGE_ISSUES or CREATE_ISSUES); the message becomes the first comment; admins notified
POST /issue/:id/comment IssueCommentInput: MANAGE_ISSUES or the creator → updated Issue
POST /issue/:id/status IssueStatusUpdate (open/resolved): creator or MANAGE_ISSUES
DELETE /issue/:id MANAGE_ISSUES, or the creator while it has one comment
PUT /issuecomment/:id Edit own comment → updated Issue
DELETE /issuecomment/:id Author or MANAGE_ISSUES
Method Path Purpose
GET /overriderule All rules
POST /overriderule OverrideRuleInput → OverrideRule
PUT /overriderule/:id Replace a rule
DELETE /overriderule/:id Delete
Method Path Purpose
GET /watchlist ?userId (others need WATCHLIST_VIEW) → WatchlistItem[]
POST /watchlist WatchlistCreate: add to the caller’s list (409 duplicate) → 201 WatchlistItem
DELETE /watchlist/:id Remove one of the caller’s items (MANAGE_USERS may remove anyone’s)
POST /watchlist/sync Run WatchlistSync now (MANAGE_USERS) → WatchlistSyncResult

Sync sources: the plex.tv watchlist (user’s plexToken) and, for users linked to the Jellyfin/Emby server of Settings > Media server, that account’s favourite movies and series (/Users/{id}/Items?Filters=IsFavorite, read with the link’s token or the server API key). Items mirror into the list with source plex/jellyfin/emby; items no longer listed leave it. Auto-requests need autoRequestFromWatchlist (default on), AUTO_REQUEST* and the per-type watchlistSyncMovies/watchlistSyncSeries flag.

Media server (compatibility view over mediaServers)

Section titled “Media server (compatibility view over mediaServers)”

One Jellyfin, Emby or Plex server used for sign-in, user import, links and favourites. Since the consolidation this is a view over the media-servers list: the first enabled server flagged loginEnabled, else the first enabled server. PUT /settings/mediaserver writes into the list (updates that server in place keeping its purpose flags, or appends one with usedForAvailability; kind: null turns login off on it, or removes it when it is not used for retention or refresh). Availability uses every listed server flagged usedForAvailability (falling back to the Jellyfin/Emby notification connections when the list has none). Contract: MediaServerSettings {kind: 'jellyfin'|'emby'|'plex'|null, url, apiKey, name, loginEnabled, createUsersOnLogin}; apiKey is an API key (Plex: owner X-Plex-Token) and reads back as ******** (send it back to keep it). Links are stored in the user’s settings JSON as mediaServerLink {kind, externalId, username, accessToken (masked on read), deviceId, linkedAt}.

Method Path Purpose
GET /settings/mediaserver MANAGE_SETTINGS → MediaServerSettings (masked)
PUT /settings/mediaserver MANAGE_SETTINGS: replace the section (URL must be http(s))
POST /settings/mediaserver/test MANAGE_SETTINGS; optional body = unsaved settings → MediaServerTestResult (System/Info / Plex identity)
POST /user/:id/mediaserver/link Self or MANAGE_USERS. {username, password} (Jellyfin/Emby AuthenticateByName) or {pinId} (a completed Plex PIN; also sets plexToken) → UserRequestSettings. 403 bad credentials, 409 account linked to another user
DELETE /user/:id/mediaserver/link Self or MANAGE_USERS: remove the link → UserRequestSettings
POST /user/me/mediaserver/plex/pin Signed-in user: start a Plex PIN for linking (no sign-in) → PlexPin
POST /user/me/mediaserver/plex/poll Signed-in user, {id}: 202 {pending: true} until approved, then links the Plex account to the caller (stores the token and plexToken; no new session) → UserRequestSettings; 409 when linked to another user
GET /user/import/mediaserver MANAGE_USERS → MediaServerUser[] (Jellyfin/Emby users, or Plex friends) with linkedUserId
POST /user/import/mediaserver MANAGE_USERS: MediaServerImportRequest {externalIds?} (absent = all) → MediaServerImportResult; creates linked users with default permissions, skips linked/disabled ones
POST /request/digest MANAGE_REQUESTS: send the admin digest now → RequestsDigestResult

Availability (RequestsSettings.useMediaServerAvailability): RequestsStatusSync asks the media server (falling back to the Jellyfin/Emby connections under Settings > Notifications when none is configured). Jellyfin/Emby: items by Tmdb/Tvdb provider id, episodes counted per season (virtual episodes ignored, multi-episode files count each episode). Plex: movie/show libraries indexed by guid (cached 5 minutes), seasons by leafCount. The normal track is then available only when the server has it (series: every requested season with all aired episodes); library files the server lacks show as processing; a server that cannot answer completes nothing. 4K stays library-based. MediaRequest.availableOn names the server (Settings name, or Jellyfin/Emby/Plex). With no server at all the library decides, as before.

Method Path Purpose
GET /user/:id/permissions UserPermissions {permissions, contentRestrictions, approvalPolicy, requestTypes} (self or MANAGE_USERS)
PUT /user/:id/permissions MANAGE_USERS; the ADMIN bit follows the role; a non-admin cannot edit an admin. contentRestrictions / approvalPolicy / requestTypes are optional (absent: unchanged)
GET /user/:id/quota UserQuotaResponse (self or MANAGE_USERS)
PUT /user/:id/quota MANAGE_USERS: UserQuota overrides (null = global default) → UserQuotaResponse
GET /user/:id/requestsettings UserRequestSettings: personal notification agents, Plex token (masked), watchlist sync, autoRequestFromWatchlist, mediaServerLink (token masked) (self or MANAGE_USERS)
PUT /user/:id/requestsettings Replace them; a masked token keeps the stored one; mediaServerLink is ignored (use the link routes)
POST /user/:id/requestsettings/test Send a test through each enabled personal agent → UserNotificationTestResult
GET /settings/requests RequestsSettings (any signed-in user; the web reads the 4K and partial-request flags)
PUT /settings/requests MANAGE_SETTINGS: replace the section

RequestsSettings also carries applicationUrl (public URL used for links in notifications), digestEnabled and digestHour (0-23, server local time), and approvalBudgets, approvalWindows, autoApproveIfSizeBelowGb, publicPortal (above), autoApproveSubtitleRequests / autoApproveAudioRequests (subtitle and language requests), and defaultRequestTypes (RequestTypes, every type on by default: the request type filter new users start with; see “Request type filter”).

Notification connections (/notification) have six more trigger flags: onRequestCreated, onRequestApproved, onRequestDeclined, onRequestAvailable, onIssueCreated, onIssueUpdated (with matching supportsOn* capabilities; chat agents, webhook and custom script support them, media servers do not). The requests module dispatches every request and issue event to the notifications service, which delivers it to enabled connections with the flag (personal per-user agents keep working as before). With applicationUrl set, events carry deep links: pending requests get Approve (/requests/:id?action=approve), Decline (?action=decline) and View (/requests/:id) actions, issues link to /issues/:id. Discord renders them as an Actions field plus a “Requested by” footer with the requester’s avatar and the poster as thumbnail; Telegram as inline links; ntfy as action buttons; Gotify as markdown links; email/Pushover in the body (Pushover’s URL = the request); webhooks get an actions array next to the raw event. The requestsDigest event (job RequestsDigest) goes to connections with onRequestCreated or onIssueCreated.

Implemented in apps/api/src/modules/requests/language-*.ts (contract: packages/shared/src/language-requests.ts). A signed-in user asks for subtitles in a language (with forced / hearing-impaired modes) or an additional audio language on a title that is in the library with files, for a movie, a whole series or one season. Rows live in language_requests (no foreign keys; open requests of a deleted item are failed). They never count toward media quotas. Needs REQUEST_LANGUAGE (2^30) or REQUEST.

Method Path Purpose
GET /request/language LanguageRequestListQuery (take, skip, filter all/pending/approved/declined/failed/completed, kind all/subtitle/audio, requestedBy, sortDirection, q: title, year, kind, status, language tag, note, decline/failure reason, requester) → LanguageRequestPage; own requests only without MANAGE_REQUESTS/REQUEST_VIEW
GET /request/language/count LanguageRequestCount (total, subtitle, audio, pending) over the visible requests
GET /request/language/options ?mediaType&tmdbId → LanguageRequestOptions: in the library (with files), library ids, seasons with files, the item’s audio/subtitle language chips (as GET /additional-language), open requests of the title (mine flag, no requester names), canRequest, canRequestSubtitles, canRequestAudio (the caller’s request type filter)
GET /request/language/languages The requestable languages (code, name; the subtitles language list)
GET /request/language/:id One LanguageRequest (owner, MANAGE_REQUESTS or REQUEST_VIEW)
POST /request/language CreateLanguageRequest (kind subtitle/audio, mediaType, tmdbId, seasonNumber? series only, language any spelling, forced/hi subtitles only, note ≤ 500, userId? on behalf) → 201 LanguageRequest. 400 for an unknown language, a title not in the library or a scope without files; 409 when an identical pending/approved request exists (a whole-series request covers its seasons), the language was already added with “Add additional…”, or every file of the scope already has it
POST /request/language/:id/approve MANAGE_REQUESTS; optional ApproveLanguageRequest { mode: replace | version } (audio only, default replace)
POST /request/language/:id/decline MANAGE_REQUESTS; optional { reason }
POST /request/language/:id/retry MANAGE_REQUESTS; re-runs a failed approval
POST /request/language/bulk/:action approve, decline, retry or delete; BulkRequestAction → BulkRequestResult (an approval that ends failed is a failure)
DELETE /request/language/:id MANAGE_REQUESTS, or the owner while pending

Auto-approval: requests settings autoApproveSubtitleRequests (default on) and autoApproveAudioRequests (default off), or a requester (or calling manager) with AUTO_APPROVE or MANAGE_REQUESTS; an auto-approved request answers approved and the search runs in the background. Approval calls “Add additional…” (POST /additional-language logic) for the item with searchNow, links the saved records (additionalLanguageIds) and records outcome / outcomeMessage: found, grabbed, alreadyPresent or versionAdded complete the request; failed or noFile (or an error) fail it with failureReason; notFound and searching keep it approved while the wanted machinery keeps searching. RequestsStatusSync and library updates of the item re-check approved requests (the linked records’ status, then whether every file of the scope has the language now). Notifications use the four request events and audiences of video requests, with the language in media.title (“Title (Spanish subtitles)”), the season in request.seasons and no deep links; failures are not notified (as for video requests). Pending language requests are in the admin digest. WebSocket request events of language requests carry language: true.

Implemented in apps/api/src/modules/requests/audio-*.ts (contract: packages/shared/src/audio-requests.ts). Kept apart from video requests: rows live in audio_requests, keyed on MusicBrainz ids (album = release group, artist) or Audible ASINs (book, author). Every route answers 404 MUSIC_DISABLED / AUDIOBOOKS_DISABLED while the domain’s feature flag is off. Discover routes follow the movie/TV discover access rules (public while the portal allows anonymous discover, rate-limited per IP); the rest need sign-in.

Catalogue sources: music home from ListenBrainz (fresh releases of the last 14 days, albums and EPs only; the sitewide release-group chart of the week), with Cover Art Archive release-group/<mbid>/front-250 URLs and no MusicBrainz call; music search, album and artist pages from MusicBrainz; audiobooks from the anonymous Audible catalogue (BestSellers and -ReleaseDate listings, search) and Audnexus, in audiobookMetadata.audibleRegion unless ?region names another marketplace. Answers are cached in the api (home 1 hour, search 15 minutes, details 1 hour); a failing home source leaves its section empty. mediaStatus, requestId, requestStatus and libraryId are computed fresh per answer: album = every track has a file → available, some → partiallyAvailable, monitored without files → processing; artist/author likewise over their monitored albums/books; book = file → available, monitored → processing; an open request without library files → pending (awaiting approval) or processing (approved).

Method Path Purpose
GET /discover/music AudioDiscoverHome: sections music-fresh “Fresh releases”, music-top “Popular this week”
GET /discover/music/search ?query → AudioDiscoverSearchResult (people = artists, titles = albums)
GET /discover/music/album/:mbid AudioAlbumDetail (release group, artist, genres, track list of the preferred edition, more by the artist)
GET /discover/music/artist/:mbid AudioArtistDetail (studio albums and EPs first, newest first)
GET /discover/audiobooks ?region → sections book-bestsellers “Best sellers”, book-new “New releases”
GET /discover/audiobooks/search ?query&region → people = authors, titles = books
GET /discover/audiobooks/book/:asin ?region → AudioBookDetail (more by the primary author)
GET /discover/audiobooks/author/:asin ?region → AudioAuthorDetail
GET /audiorequest AudioRequestListQuery (take, skip, filter, domain all/music/audiobook, requestedBy, sortDirection, q: title, subtitle, year, kind, status, decline/failure reason, requester username and display name) → AudioRequestPage; own requests only without MANAGE_REQUESTS/REQUEST_VIEW
GET /audiorequest/count AudioRequestCount (total, music, audiobook, pending) over the visible requests
GET /audiorequest/:id One AudioRequest (owner, MANAGE_REQUESTS or REQUEST_VIEW)
POST /audiorequest CreateAudioRequest → 201 AudioRequest. Needs REQUEST or REQUEST_MUSIC / REQUEST_AUDIOBOOK; 400 for an id that is not an MBID/ASIN, 409 when an active (pending/approved) request exists or the item is available. Title, artist/author, year, cover and parent come from the discover lookups. Auto-approved when the requester (or the calling manager) holds AUTO_APPROVE or MANAGE_REQUESTS
POST /audiorequest/:id/approve MANAGE_REQUESTS: adds the item to the library and searches (below)
POST /audiorequest/:id/decline MANAGE_REQUESTS; optional { reason }
POST /audiorequest/:id/retry MANAGE_REQUESTS; re-runs a failed approval
DELETE /audiorequest/:id MANAGE_REQUESTS, or the owner while pending
GET /settings/audiorequests MANAGE_SETTINGS: AudioRequestDefaults (settings section audioRequests)
PUT /settings/audiorequests MANAGE_SETTINGS: replace it; root folders must be registered for the media type, profiles must be of the domain (400 otherwise)

Approval: album → the artist is added (monitor none, new albums none) unless present; once its release groups are synced (ArtistAdd, or RefreshArtist for an artist already in the library without the album row) the album and artist are monitored and an AlbumSearch runs, so the request answers approved before libraryId is set. Artist → added with every album monitored and search on add (an existing artist is monitored + ArtistSearch). Book → POST /book logic with an author block and search on add (an existing book is monitored + BookSearch). Author → added with every book monitored and search on add (existing: monitored + AuthorSearch). Root folder and quality profile come from audioRequests, else the first root folder of the media type and the first quality profile of the domain. Any failure (no root folder, album filtered out by the artist’s album-type profile, MusicBrainz down) marks the request failed with failureReason. RequestsStatusSync completes approved requests once the item is available. Notifications use the same four request events and audiences as video requests, with media.mediaType music/audiobook and album/artist/book/author blocks, and no deep links. WebSocket request events of audio requests carry audio: true.

Module apps/api/src/modules/media-servers; contract packages/shared/src/media-servers.ts; clients @arclight/media-servers. A list of Jellyfin/Emby/Plex/Audiobookshelf connections stored in the mediaServers settings section (MediaServersSettings {servers: MediaServerConfig[]}), each {id, kind: 'jellyfin'|'emby'|'plex'|'audiobookshelf', name, url, apiKey, enabled, usedForAvailability, usedForRetention, usedForRefresh, loginEnabled, createUsersOnLogin}. apiKey is a Jellyfin/Emby API key, the Plex owner’s X-Plex-Token or an Audiobookshelf API token (sent as Authorization: Bearer) and reads back as ********; sending the mask (or omitting the key) on update keeps the stored key. Names are unique (case-insensitive); URLs are normalised (host:port gets http://, trailing slash dropped) and must be http(s).

This list is meant to be shared: other modules call mediaServersServiceFor(ctx).clients(purpose) with 'availability' | 'retention' | 'refresh' and get a fresh MediaServerClient for every enabled server with that flag. The list is the source of truth: the requests module’s single-server mediaServer shape (sign-in, user links, GET/PUT /settings/mediaserver) is a view over it (see Media server), requests availability asks clients('availability'), and a stored legacy mediaServer value is moved into the list once at boot (the legacy row is then deleted).

Audiobookshelf (kind: 'audiobookshelf') is a library server, not a sign-in provider: loginEnabled and createUsersOnLogin are always stored false (the settings form hides them), it is never the legacy mediaServer view, and clients('availability' | 'retention') leave it out (those purposes are video). With usedForRefresh it is rescanned after audiobook imports: POST /api/libraries/{id}/scan on each book library whose folder (fullPath) contains the book folder. The connection test reads GET /status (version) and GET /api/libraries (mediaType: 'book' → audiobook, podcasts → other).

Method Path Purpose
GET /mediaserver MediaServerResource[] (keys masked)
GET /mediaserver/:id One server; 404 unknown id
POST /mediaserver MediaServerInput → 201 MediaServerResource; 400 invalid (missing key, bad URL), 409 duplicate name
PUT /mediaserver/:id Replace a server (mask keeps the key)
DELETE /mediaserver/:id 204
POST /mediaserver/test MediaServerTestRequest (unsaved form; id + masked key reuses the stored key) → MediaServerProbeResult
POST /mediaserver/:id/test MediaServerProbeResult: {ok, serverName, version, libraries[]} or {ok: false, message, code} (System/Info + Library/VirtualFolders, Plex / + library/sections, Audiobookshelf /status + /api/libraries)
GET /mediaserver/:id/users MediaServerAccountResource[] {id, name, isAdmin, isDisabled, lastActivityAt} (Jellyfin/Emby Users, Plex server accounts); 502 when the server fails

Clients (@arclight/media-servers, native fetch, no db): createMediaServerClient({kind, url, apiKey, fetch?, timeoutMs?}) → info(), libraries(), users(), listItems(mediaType), findItems({mediaType, tmdbId?, tvdbId?, imdbId?}), playState(item, userId), listPlayStates(userId, mediaType) and refreshLibrary({path?, mediaType?, updateType?}). Jellyfin/Emby: per-user UserData (Played, PlayCount, LastPlayedDate, PlaybackPositionTicks); a series is played when every present episode is; provider-id filters are verified and fall back to a library index. Plex: items found through a guid index of the movie/show sections; the owner’s state is on the items (viewCount, lastViewedAt, viewOffset, viewedLeafCount), other accounts’ state comes from status/sessions/history/all?accountID=. Audiobookshelf: books of the book libraries (paged GET /api/libraries/{id}/items), findItems({mediaType: 'audiobook', asin?, isbn?}) matched client-side, progress from GET /api/users/{id} (mediaProgress); video and music kinds list nothing. Errors are MediaServerError with a code (unauthorized, network, timeout, notFound, …).

Module apps/api/src/modules/retention; contract packages/shared/src/retention.ts; tables retention_rules, retention_candidates. A rule {name, enabled, mediaType, scope, conditions[], graceDays, action, addExclusion, keepIfRequestedWithinDays, keepIfInCollection, dryRun}:

  • scope: {type:'all'}, {type:'tags', tagIds} or {type:'rootFolders', paths}.
  • conditions (all must hold): watchedByAll (every enabled user who can see the item), watchedByAny, watchedByUsers {users: names}, addedOlderThanDays {days}, notWatchedWithinDays {days} (no play in N days and added longer ago; never true for items no server knows), sizeAbove {bytes}. Users are merged by name (case-insensitive) across all enabled servers flagged usedForRetention.
  • action: unmonitor; deleteFiles (unmonitor, then each media file goes to the recycle bin and its row is removed); deleteItem (every file in the folder to the recycle bin, then the series/movie and its folder are deleted via the library service; addExclusion adds an import exclusion). Without a recycle bin files are deleted.
  • keepIfRequestedWithinDays (a request for the TMDB id that recent keeps it), keepIfInCollection (movies in a TMDB collection), dryRun (default true: staged and notified, never applied automatically).

RetentionSweep stages matches as pending candidates (reasons, watchedBy, applyAfter = firstSeen + graceDays), drops waiting ones that stopped matching, and applies due candidates of non-dry-run rules. kept candidates are never applied; failed ones are retried on the next sweep; an applied item that matches again starts a new cycle. When any retention server cannot be read (or none is configured), rules with watch conditions are skipped for that sweep: their candidates are neither dropped nor applied. After a sweep that staged, applied or failed something, a retention notification (summary: staged/applied/failed lines, freed bytes, link to /retention) is dispatched to the notification connections.

Method Path Purpose
GET /retentionrule RetentionRule[]
GET /retentionrule/:id One rule
POST /retentionrule RetentionRuleInput → 201; 400 invalid, 409 duplicate name
PUT /retentionrule/:id Replace; waiting candidates take the new action and grace (from first sighting)
DELETE /retentionrule/:id 204; deletes its candidates
GET /retention/candidates RetentionCandidate[], oldest applyAfter first; filters status, ruleId, mediaType
POST /retention/sweep Body {dryRun?} (optional) → RetentionSweepResult {rulesEvaluated, rulesSkipped[], serverErrors[], staged, dropped, applied, failed, matches[]}; dryRun evaluates only (no staging, applying or notification)
POST /retention/candidate/:id/apply Apply now (ignores grace, pin and dry run) → the candidate (applied or failed with error); 409 when already applied
POST /retention/candidate/:id/keep Pin: status kept; 409 when already applied
DELETE /retention/candidate/:id Remove (unpins; a later sweep may stage it again)

Module apps/api/src/modules/tag-rules; contract packages/shared/src/tag-rules.ts; tables tag_rules, tag_rule_applications (migration 0023). User guide: using/tags.md.

Tag ids are stored in the tags JSON column of movies, series, artists, authors, media_requests, indexers, indexer_proxies, download_clients, import_lists, notifications, delay_profiles, release_profiles and override_rules; in the tag scopes of retention_rules and transcode_rules; in tag rules (removeTagIds, tag conditions); and in the settings requests.<movie|series|movie4k|series4k>.tags and importLists.cleanLibraryTags (references.ts). Usage counts, merge and delete (including DELETE /tag/:id) walk that list. A retention or transcode rule whose tag scope becomes empty is disabled.

A rule TagRuleInput is {name, enabled, appliesTo: ('movie'|'series'|'artist'|'author')[], match: 'all'|'any', conditions[], addTags: string[], removeTagIds: number[], removeWhenUnmatched}. Conditions are Zod-validated JSON, discriminated on kind:

  • {kind:'set', field, op, values}: op any all none present absent, values compared case-insensitively. Fields: genre resolution quality source hdr videoCodec audioCodec audioLanguage originalLanguage certification network studio status seriesType rootFolder collection releaseGroup albumType artistType author narrator bookSeries.
  • {kind:'range', field, min, max} (inclusive, at least one bound; unknown values never match): year rating audioChannels sizeGb addedDays runtime.
  • {kind:'bool', field, value}: monitored hasFile.
  • {kind:'ids', field, op, ids}: qualityProfile requestedBy tag.

TAG_RULE_FIELDS lists which item types each field has values for. addTags entries are labels or templates with {variable} (TAG_TEMPLATE_VARIABLES: genre resolution quality source hdr codec audiocodec language originallanguage certification network studio status seriestype rootfolder collection releasegroup albumtype artisttype author narrator bookseries requester qualityprofile year decade channels); one tag per value (per combination, at most 25), each passed through sanitizeTagLabel (lower case, whitespace to -, letters, digits and -_.:+&, 100 characters). Unknown variables are a 400.

Evaluation (engine.ts, pure) runs the rules in sortOrder per item over the item’s facts (facts.ts, loaded 500 items at a time). A matched rule adds its rendered labels and records itself in tag_rule_applications as an owner, except on a label the item already carries without an owner (added by hand: left alone, never removed). A rule that no longer produces a label it owns drops ownership when removeWhenUnmatched; the tag comes off once no rule owns it. removeTagIds of a matched rule come off whoever added them. Later rules see earlier rules’ tags (tag conditions). Only the net difference per item is written, re-applied to the item’s current tags inside a transaction; missing tags are created.

Rules run for single items on library events (added, updated, fileAdded, refreshed; media analysis publishes updated), import imported (music: the artist, audiobooks: the author) and request created / approved / completed (when the item is in the library), debounced 2 s; and daily as ApplyTagRules. Five starter rules (starterKey genre, resolution, requested-by, 4k-hdr, kids) are inserted disabled the first time the module starts (settings row tagRules.starters remembers it). After a run that changed items, a settings event with section: 'tags' is published (plus library updated per item when at most 50 movies/series changed).

Method Path Purpose
GET /tagrule TagRule[] in run order, each with appliedCount (items it owns a tag on) and lastRun
GET /tagrule/:id One rule
POST /tagrule TagRuleInput → 201 (appended last); 400 invalid or unknown tag ids
PUT /tagrule/:id Replace
PUT /tagrule/order {ids} → TagRule[]; listed rules first in that order, others keep theirs after
DELETE /tagrule/:id ?removeTags=true also takes off the tags only this rule added → 204
POST /tagrule/preview {rule: TagRuleInput, id?} → TagRuleRunResult for that rule alone, never written; id uses that saved rule’s ownership for removals
POST /tagrule/run {ruleIds?, dryRun?} → TagRuleRunResult. No ids: every enabled rule; ids: those rules even when disabled
GET /tagrule/options TagRuleOptions: picker values per set field from the library, users, quality profiles
GET /tagrule/applied ?entityType=&entityId= → TagApplication[] {tagId, ruleId, ruleName}; also for users with the library app area
GET /tag/usage TagUsage[] {id, label, counts: {kind: n}, total, behaviourUses, autoApplied, rules[]}
POST /tag/:id/merge {intoId} → the target Tag; every reference moves, fixed rule labels follow, the source is deleted
POST /tag/delete {ids, force?} → {deleted}; 409 when one is in use and force is false

TagRuleRunResult: {dryRun, itemsEvaluated, itemsMatched, itemsChanged, matchedByType, add: [{label, count, isNew}], remove: [...], samples: [{entityType, id, title, add, remove}] (≤ 25), behaviour: [{label, uses: {kind: n}}] (tags that profiles, indexers, clients ... use), durationMs}.

Module apps/api/src/modules/subtitles (spec: docs/spec/subtitles.md); contract in packages/shared/src/subtitles.ts. Series and movies carry languageProfileId (null = no subtitles wanted); POST /series and POST /movie use the subtitles default profile when the field is omitted, and PUT /series/:id / PUT /movie/:id accept it. Writes and the settings, providers and manual-search routes are admin-only.

Sync-quality scoring (subtitles.sync): with autoCheck on, every download is scored against the audio. checkMethod builtin runs ffmpeg silencedetect (or ebur128, audioDetector) over the first checkWindowMinutes (plus a half-length window at 60 % for long media) and cross-correlates speech onsets with cue starts over offsets up to maxOffsetSeconds and the usual frame-rate ratios; tool runs ffsubsync (--no-fix-framerate) / alass into a scratch *.synccheck.* file and compares cue times. offsetMs is the shift to add to the subtitle, driftPpm its scale error, syncScore 100 at ≤ 0.1 s mean error, 50 at 0.6 s. A score below autoFixBelow runs the sync tool and checks again; a provider download still below autoRetryProviderBelow is blacklisted and the next candidate is downloaded (at most maxProviderRetries per language). History records carry syncScore, syncOffsetMs, syncDriftPpm. Providers whose settings have onlyWhenNoOtherResult (Whisper defaults to it) are searched only for languages where no other provider found a candidate reaching the minimum score.

Method Path Purpose
GET /languageprofile List language profiles
GET /languageprofile/:id One profile
POST /languageprofile Create (LanguageProfileInput: languages with forced/HI never/also/only, cutoff code or *, must(-not)-contain, original format)
PUT /languageprofile/:id Replace a profile
DELETE /languageprofile/:id Delete; series/movies and defaults that used it are cleared
GET /subtitle/languages Language picker options (code, name)
GET /subtitle/settings SubtitleSettings; provider secrets are ********
PUT /subtitle/settings Replace the settings; the mask keeps a stored secret; credential changes reset auth/config throttles
GET /subtitle/providers Provider form schemas plus status (enabled, configured, throttle with retry time)
POST /subtitle/providers/test {id, settings} → {ok, message}
POST /subtitle/providers/reset {onlyAuthOrConfig} clears throttles
GET /subtitle/wanted ?mediaType=series|movie&page&pageSize&q: files missing profile languages, with last search and attempts. q matches series/movie title, episode label (S01E02) and title, and the missing languages (code, en:forced-style key, English name)
GET /subtitle/history Paged history (action, provider, mediaType, seriesId, movieId, mediaFileId filters)
GET /subtitle/blacklist Paged blacklist
POST /subtitle/blacklist {subtitleId, search}: blacklist a downloaded subtitle, delete it, optionally search again
DELETE /subtitle/blacklist/:id Remove one entry
DELETE /subtitle/blacklist Clear the blacklist
GET /subtitle/file ?mediaFileId|seriesId|movieId: per media file, subtitles present (external/embedded, forced, HI, provider, score, syncScore/syncOffsetMs/syncDriftPpm/syncCheckedAt/syncMethod of the last sync check) and missing
POST /subtitle/scan Same scope in the body: rescan disk and embedded streams
GET /subtitle/search ?mediaFileId&language?: manual search, every candidate with score, matches and a one-hour key
POST /subtitle/download {mediaFileId, key}: download a manual-search candidate (410 when the key expired)
POST /subtitle/upload multipart: mediaFileId, language, forced, hi + one file part (5 MB max)
POST /subtitle/sync/:id Run ffsubsync/alass on an external subtitle (manual, ignores thresholds)
POST /subtitle/:id/synccheck Measure sync now (no auto actions) → SubtitleSyncCheckResult {ok, message, check: {method, reliable, syncScore 0-100, offsetMs, driftPpm, meanErrorMs, confidence, matched}, actions, subtitle}; the result is stored on the subtitle and its history entry
GET /subtitle/whisper/status SubtitleWhisperStatus: ARCLIGHT_WHISPER_URL, the whisperai entry (registered, enabled, autoRegistered, onlyWhenNoOtherResult) and a live probe (reachable)
POST /subtitle/extract/:id Extract an embedded text track to a file (ffmpeg stream copy)
DELETE /subtitle/:id Delete an external subtitle file
POST /command/subtitlesearch {seriesId, seasonNumber?} | {episodeIds} | {movieId} | {mediaFileId}: search now, ignoring backoff

Contract: DecisionLog* in packages/shared/src/releases.ts. Every evaluation batch (RSS sync, automatic/user search, interactive search, interactive grab, push, pending re-check) is written to release_decisions: one row per release with its item, quality, custom formats, score, approval, rejections, chosen (grabbed from this batch) and rank (1-based engine preference per item; approved first). Releases without a library item are not kept, except search results, which are logged against the searched item. Per item (series or movie) the last 20 batches are kept; DecisionLogCleanup prunes rows older than releases.decisionLogDays. Grabs carry the batch id into the history record’s data as decisionBatchId, so History details can link to “why this release”.

Method Path Notes
GET /release/decisions DecisionLogQuery: exactly one of seriesId, episodeId, movieId, plus page, pageSize (default 10, max 100). DecisionLogPage of DecisionLogBatch (newest first; rows of the item in rank order; winnerId = the grabbed row, else the best approved; grabbed). Per episode: rows covering the episode. 400 without (or with several) ids
GET /release/decisions/:batchId One whole batch (all items); 404 unknown
GET /release/searchstatus SearchStatusQuery: mediaType (episode or movie) and ids (comma list, 1–500). ItemSearchStatus[] (unknown ids left out): monitored, lastSearch and automatic, below. Read-only for the Library and Wanted app areas; 400 without ids

Search status. Every automatic search (MissingEpisodeSearch, MissingMovieSearch, CutoffUnmetSearch, profile migration), user “Search” and interactive search writes one row per episode / movie it covered to release_search_results (overwritten by the next search; a grab does not clear it). lastSearch is { time, source, outcome, releases, approved, detail }: source automatic / manual / interactive; outcome grabbed (detail = release title), pending (an approved release is held by a delay profile or an unavailable client), found (approved releases, none grabbed: interactive searches), rejected (every release for the item was rejected; detail = the most common first rejection), noResults or error (detail = the message). Releases that matched no library item count for every searched item.

automatic is { state, job, nextSearchAt, searchCount, runsAhead }: scheduled when the item is on the Missing or Cutoff unmet list (job names the job); unmonitored, notAvailable (not aired, or not released under its minimum availability), special (season 0 is not searched automatically), satisfied (a file that meets the cutoff) or jobDisabled (the job is on demand only or switched off). nextSearchAt is an estimate that replays the job: its upcoming cron runs (two weeks ahead), the per-item back-off (searchCount automatic searches without a grab, one day each, at most 7; a grab resets it) and the per-run limit (200 missing episodes, 100 missing movies, 100 cutoff-unmet episodes and movies per run, in list order); runsAhead counts the due runs the limit makes the item sit out. Null when no run within two weeks reaches it. RSS sync can grab the item at any time in between.

releases: music (album search, decisions, grabs)

Section titled “releases: music (album search, decisions, grabs)”

Music releases go through the same pipeline as video (/release, /release/push, /command/search, RSS, pending releases, decision log) when features.music is on. The indexer category picks the parser: Audio categories (3xxx except 3020 Audio/Video and 3030 Audiobook) use the music parser; uncategorised releases use it only when the title names an audio codec or container (FLAC, MP3, 320, V0, 24bit, …) and no series/movie matched, and only a library hit counts. Matching: artist by name or alias (accents, punctuation, a leading “The” ignored, “&” = “and”); Various Artists releases by the album alone; album by title without edition words, year within one (another year is accepted when the title is unique for the artist); discography and multi-album packs map to the artist’s albums inside the stated years that pass the album-type profile. Album current quality is its lowest track-file quality; a pack only counts (and is tracked) for the albums it fills or strictly upgrades.

Method Path Notes
GET /release?albumId= Interactive search for one album (its artist implied). ?artistId= searches the artist (an artist query, then each wanted album). Runs the album plan (below) and stops after the first query with an approved release; nothing is grabbed. Answers ReleaseDecision[], approved first, then by rank
POST /release ReleaseGrabRequest: guid, indexerId, optional artistId and/or albumIds to pin the mapping of an ambiguous release (albumIds alone implies their artist). The user may grab a rejected release
POST /command/search {artistId, albumIds?} searches the artist’s wanted albums (monitored, released, missing or below cutoff), or only albumIds; {albumIds} alone searches those albums (one artist). SearchCommandResponse
GET /release/pending PendingRelease gains mediaType: "music", artistId, albumIds
GET /release/decisions DecisionLogQuery also takes artistId or albumId (rows covering the album). Music rows carry mediaType: "music", artistId, albumIds
GET /release/searchstatus mediaType also takes album; jobs MissingAlbumSearch / CutoffUnmetAudioSearch

Music fields of ReleaseDecision (contract packages/shared/src/releases.ts; absent on video decisions). The interactive album search shows these next to the usual release columns:

{
mediaType: 'music',
artistId: number, // matched artist (also set when only the album failed to match)
artistName: string,
albumIds: number[], // albums the release fills or upgrades (a pack: only the wanted ones)
albums: { id: number; title: string; year: number | null }[], // same order as albumIds
music: { // ReleaseMusicInfo: what the parser read from the name
artist: string | null, album: string | null, year: number | null, edition: string | null,
isVariousArtists: boolean,
packType: 'discography' | 'multiAlbum' | null, discographyYears: { start, end } | null,
releaseTypeHint: string | null, // EP, Single, Live, Compilation, Soundtrack, Bootleg
media: string, // CD, WEB, Vinyl, SACD, Cassette, DVD, BD, Unknown
catalogNumber: string | null, discCount: number | null,
codec: string, bitrateKbps: number | null, bitDepth: number | null, sampleRateHz: number | null,
hasLog: boolean, logScore: number | null, hasCue: boolean,
},
quality: QualityModel, // a music quality (FLAC, MP3-320, FLAC-24bit, ...)
languages: [], fullSeason: false,
}

Unmatched music releases come back with approved: false, mediaType: "music", music (when parsed) and one rejection: unknownArtist, unknownAlbum (ambiguous titles too; artistId set), emptyPack, unableToParse or musicDisabled.

Music rejection reasons (besides the shared ones such as qualityNotWanted, customFormatMinimumScore, blocklisted, alreadyInQueue, diskCutoffMet, minimumAgeDelay): artistNotMonitored, albumNotMonitored, wrongArtist, wrongAlbum, albumNotReleased (automatic only), discographyNotAllowed and packTooFewWanted (automatic only, see /settings/releases), audioSizeImplausible (too small, or for single albums too large, for the codec and the album’s running time), belowMinimumSize / aboveMaximumSize (quality definition MB/min × album duration; packs have no upper bound), diskNotUpgrade (a pack that upgrades none of its albums).

Search plans. Album: t=music with artist + album (+ year), then q="{artist} {album}", then the title without edition brackets; Various Artists albums by the album alone. Artist: one q="{artist}" query first (packs and several albums come back on it), then each album it did not settle. Indexers without t=music fall back to t=search with the same words. Each query is decided (and, for automatic searches, grabbed) before the next one runs.

Grabs and failures. A music grab records one grabbed history row with artistId and albumIds; the tracked download and the queue item (QueueItem.artistId / albumIds, mediaType: "music") carry the same. A failed download writes downloadFailed, blocklists the release under the artist and, with mediaManagement.autoRedownloadFailed, searches those albums again. Delay profiles (selected by the artist’s tags) park music releases as pending like video.

Scheduled jobs. MissingAlbumSearch (04:10 daily, 50 albums per run) and CutoffUnmetAudioSearch (04:30 daily, 50 per run) walk the Wanted lists with the same per-item back-off as the video searches (release_search_state, media_type = 'album', a day per search without a grab, at most 7; a grab resets it). Several albums of one artist are searched together (artist query first). Both are no-ops while features.music is off.

releases: audiobooks (book search, decisions, grabs)

Section titled “releases: audiobooks (book search, decisions, grabs)”

Audiobook releases go through the same pipeline as video and music (/release, /release/push, /command/search, RSS, pending releases, decision log) when features.audiobooks is on. The indexer category picks the parser: 3030 Audio/Audiobook (alone or with the bare 3000 parent) uses the audiobook parser, and so does any search for a book or an author. An uncategorised release is tried as an audiobook only when its title carries an audiobook marker (m4b, Unabridged / Abridged, narrated by / read by, audiobook, an ASIN) and the book matcher finds it in the library; otherwise it goes on to the video and music matchers.

Matching: an ASIN in the name maps straight to the book with that ASIN. Otherwise the matcher looks for an author (any credited author: name, sort name “Last, First” or alias, with accents and punctuation ignored; initials match full given names, so “J.R.R. Tolkien”, “JRR Tolkien” and “John Ronald Reuel Tolkien” all agree). Then it looks for a book among that author’s own and co-authored books: by title (a leading article is ignored and the subtitle is optional), else by series + position. Names in the form “Title - Author - Narrator” are tried with title and author swapped. Packs (“Series Books 1-5”, “Series - Complete Series”) map to the series’ books inside the range, counting whole positions only, so a novella at 2.5 is left out. A pack named without an author is looked up by series across the library. A book’s current quality is its lowest file quality. A pack only counts (and is tracked) for the books it fills or strictly upgrades.

Method Path Notes
GET /release?bookId= Interactive search for one book (its author implied). ?authorId= searches the author: an author query, then each wanted book. It runs the book plan (below) and stops after the first query with an approved release. Nothing is grabbed. Returns ReleaseDecision[], approved first, then by rank
POST /release ReleaseGrabRequest: guid, indexerId, plus optional authorId and/or bookIds to pin the mapping of an ambiguous release (bookIds alone implies their author). The user may grab a rejected release
POST /command/search {authorId, bookIds?} searches the author’s wanted books (monitored, released, no file or below cutoff), or only bookIds. {bookIds} alone searches those books. Returns SearchCommandResponse
GET /release/pending PendingRelease gains mediaType: "audiobook", authorId, bookIds
GET /release/decisions DecisionLogQuery also takes authorId or bookId (rows covering the book). Audiobook rows carry mediaType: "audiobook", authorId, bookIds
GET /release/searchstatus mediaType also takes book; the jobs are MissingBookSearch / CutoffUnmetAudioSearch

Audiobook fields of ReleaseDecision (contract packages/shared/src/releases.ts; absent on other decisions):

{
mediaType: 'audiobook',
authorId: number, // the books' owner (also set when only the book failed to match)
authorName: string,
bookIds: number[], // books the release fills or upgrades (a pack: only the wanted ones)
books: { // ReleaseBookRef, same order as bookIds
id: number, title: string,
series: string | null, seriesPosition: string | null, // the primary series entry
year: number | null,
}[],
audiobook: { // ReleaseAudiobookInfo: what the parser read from the name
authors: string[], title: string | null, subtitle: string | null,
series: string | null, seriesPosition: string | null, // "3", "2.5", a range "1-5" for packs
narrators: string[], year: number | null,
formatType: string, // unabridged, abridged, dramatized, unknown
container: string, // m4b, m4a, mp3, flac, opus, unknown
codec: string, bitrateKbps: number | null,
asin: string | null, isbn: string | null,
isPack: boolean,
language: string | null, // ISO 639-1 when the name states one
},
quality: QualityModel, // an audiobook quality (AB-AAC, AB-MP3, AB-MP3-High, ...)
languages: string[], // [audiobook.language] when stated
fullSeason: false,
}

An unmatched audiobook release comes back with approved: false, mediaType: "audiobook", audiobook (when parsed) and one rejection: unknownAuthor, unknownBook (this covers ambiguous titles too, with authorId set), emptyPack, unableToParse or audiobooksDisabled.

Audiobook rejection reasons, besides the shared ones such as qualityNotWanted, blocklisted, alreadyInQueue, diskCutoffMet and minimumAgeDelay:

  • authorNotMonitored, bookNotMonitored, wrongAuthor, wrongBook.
  • bookNotReleased: automatic grabs only.
  • bookPackNotAllowed and packTooFewWanted: automatic grabs only; see the settings below.
  • abridgedNotWanted: an abridged release of a book the catalogue lists as unabridged.
  • wrongBookLanguage: the language in the name is neither one of the author’s languages (or the audiobookMetadata.languages default) nor the book’s language. A release that states no language passes.
  • audioSizeImplausible: too small, or (for single books) too large, for the codec and the catalogue runtime.
  • belowMinimumSize / aboveMaximumSize: the quality definition’s MB/min × the book runtime. Packs have no upper bound, and books without a runtime are not size-checked.
  • diskNotUpgrade: a pack that upgrades none of its books.

Settings (/settings/releases): allowBookPacks (default false) lets automatic searches and RSS grab series packs, and minPackWanted (shared with music, default 2) is how many wanted books such a pack must bring. allowAbridged (default false) accepts abridged releases of unabridged books. Interactive grabs ignore all three.

Search plans. Book: t=book with author + title, then q="{author} {title}", then q="{title}" when the title has three or more significant words, then q="{asin}" when the book has one. Author: one q="{author}" query first (packs and several books come back on it), then each book it did not settle. Indexers without t=book fall back to t=search with the same words. Each query is decided (and, for automatic searches, grabbed) before the next runs.

Grabs and failures. An audiobook grab records one grabbed history row with authorId and bookIds. History lists filter by authorId / bookId, and history and blocklist records carry authorId / bookIds. The tracked download and the queue item (QueueItem.authorId / bookIds, mediaType: "audiobook") carry the same ids. A failed download writes downloadFailed and blocklists the release under the author. With mediaManagement.autoRedownloadFailed it then searches those books again; history “redownload” does the same. Delay profiles, selected by the author’s tags, park audiobook releases as pending. The AuthorSearch / BookSearch commands and search-on-add run through the same searches.

Scheduled jobs. MissingBookSearch runs at 04:20 daily and searches up to 50 books per run: monitored, released books of monitored authors that have no file, newest first. CutoffUnmetAudioSearch (04:30) also covers books whose lowest file quality is below the author’s profile cutoff. Both use the per-item back-off (release_search_state, media_type = 'book'), group several books of one author into one search, and do nothing while features.audiobooks is off.

Contract: packages/shared/src/scene-mapping.ts. Per-series scene numbering rules in scene_mappings (source manual or feed). Kinds: season (scene season, optional episode anchor, to library season/episode or library absolute; anchors are piecewise offsets until the next anchor), absolute (scene absolute anchor, optionally within sceneSeason, to library season/episode or absolute), title (sceneTitle alias; with tvdbSeason, releases matched through it land in that season). The release matcher tries title aliases first, then maps the parsed numbers: manual rules, then feed rules, then for seriesType: anime the absolute fallback (library absolute numbers, else derived by counting episodes across seasons; S01E27 past the end of season 1 reads as absolute 27).

Method Path Notes
GET /series/:id/scenemapping Rules of the series, manual first
POST /series/:id/scenemapping SceneMappingInput; creates a manual rule (201)
PUT /series/:id/scenemapping/:mappingId Replace a manual rule; feed rules are read-only (400)
DELETE /series/:id/scenemapping/:mappingId Delete a manual rule (204); feed rules 400
POST /series/:id/scenemapping/apply Re-map the series’s library files (scene name, else path) under the current rules; changed files are re-registered with the new episode links (bytes untouched; new file ids), files that map onto another kept file’s episodes are skipped; then the imports rescan runs. SceneMappingApplyResult
GET /scenemapping/feed SceneMappingFeedStatus
POST /scenemapping/feed/sync Sync the community feed now; 502 on failure

Feed file (DEFAULT_SCENE_MAPPING_FEED_URL, feeds/scene-mappings.json on the repository’s main branch): { "schemaVersion": 1, "generatedAt"?: ISO, "series": [ { "tvdbId"?, "tmdbId"?, "title"?, "mappings": [SceneMappingInput, ...] } ] }. Series match by tvdbId, then tmdbId; each sync replaces all feed rows; invalid entries are counted as rejected. A 404 is “not published” (info, never stale). Registered in the feeds registry as sceneMappings.

Contract: packages/shared/src/history.ts.

Method Path Purpose
GET /history Paged, sortable, filterable history (eventType, mediaType (series, movie, music), seriesId, episodeId, movieId, artistId, albumId, downloadId, quality, language, include*, q). q matches source title, event type, download id, the series / movie (and original) title, artist and author name, album and book titles, the episode title and s01e02 code, and from data the indexer, download client (downloadClient, downloadClientName), message, reason and releaseGroup. Music rows: one row per event for the whole release, mediaType: "music", artistId and albumIds set (absent on video rows), seriesId/episodeId/movieId null
GET /history/since Records since date (optional eventType)
GET /history/series Records of a series (seriesId, seasonNumber?, eventType?)
GET /history/movie Records of a movie (movieId, eventType?)
POST /history/failed/:id Mark the grab failed ({message?, skipRedownload?}): writes downloadFailed, blocklists the release, and unless skipRedownload is set (or mediaManagement.autoRedownloadFailed is off) re-searches the record’s episodes/movie in the background (wiring.ts, priority-100 handler). Answers the new downloadFailed records
POST /history/:id/redownload Re-run the automatic search for the record’s movie, its albums (music), or its episode plus every episode recorded under the same download id and event type. Answers 202 with the started HistoryRedownload Job; 404 unknown id, 400 for a record without an episode or movie, 503 when release search is not wired

Contract: packages/shared/src/history.ts.

Method Path Purpose
GET /blocklist Paged, sortable list (mediaType, seriesId, movieId, artistId, protocol filters; q: source title, indexer, message, source, the series / movie / artist / author name, album and book titles, the blocked episodes’ titles and s01e02 codes). Music entries are scoped to the artist (a release blocked for one artist is fine for another) and list the blocked grab’s albumIds
GET /blocklist/series Entries of a series (seriesId)
GET /blocklist/movie Entries of a movie (movieId)
POST /blocklist Add an entry by hand (BlocklistAddRequest: mediaType, seriesId + episodeIds or movieId, sourceTitle, quality, protocol, optional indexer, hash, size, message), 201
POST /blocklist/clear Remove every entry, answers {deleted}
DELETE /blocklist/bulk Body {ids: number[]} (at least one positive id; anything else is 400), answers {deleted}
DELETE /blocklist/:id Remove one entry

Contract: packages/shared/src/calendar.ts. Episodes by air date, movies once per release date (cinema, digital, physical) and, while features.music is on, albums by release date (CalendarAlbumItem: type: "album", date, artistId, artistName, album with statistics, artist summary; albums MusicBrainz dates only to the month or year are left out; in the iCal feed as all-day Artist - Album (Type) events). Week and day views are client-side over start/end.

Method Path Purpose
GET /calendar Items in [start, end] (defaults: today, +7 days). Filters: mediaType (all default, series, movie, music), tags (any of), unmonitored (include unmonitored items, default false), plus includeSeries, includeEpisodeFile
GET /calendar/feed {token, path} of the iCal feed
POST /calendar/feed/regenerate Admin. Rotates the feed token, answers {token, path}; the old feed URL answers 401 from then on
GET /calendar/feed.ics Public, ?token= required; pastDays, futureDays, tags, unmonitored

Contract: packages/shared/src/media-analysis.ts; results land in MediaFile.mediaInfo (packages/shared/src/library.ts: codec/profile/bit depth/resolution/fps/scan type, dynamic range SDR/HDR10/HDR10Plus/HLG/PQ/DV/DV HDR10/DV HDR10Plus/DV HLG/DV SDR plus doviProfile, every audio track (codec, profile, channels, language, bitrate) and subtitle track (language, forced, hearing impaired), container, duration, overall bitrate).

ffprobe is found through ARCLIGHT_FFPROBE or PATH (the Docker image ships ffmpeg). Files are queued in media_analysis_jobs on any library fileAdded (imports, rescans, manual imports) and by the command below; the AnalyzeMedia job analyzes them ARCLIGHT_ANALYSIS_CONCURRENCY (default 2) at a time and publishes library updated per item. Health check FfprobeMissing (warning) when ffprobe cannot be found.

Method Path Purpose
POST /command/analyze Body: exactly one of seriesId, movieId, all: true; optional force (default true for an item, false for all = only files without media info). Answers {queued, ffprobeAvailable}
GET /mediaanalysis/queue Queue counts (pending, running, done, failed), ffprobe path, concurrency, last 20 failures

Contract: packages/shared/src/transcode.ts. Rules (transcode_rules) pick library files by media type, scope ({kind:'all'}, {kind:'tags', tagIds}, {kind:'profiles', qualityProfileIds} of the parent series/movie) and when conditions on the file’s media info (container, videoCodec, audioCodec (any track), hdr, resolutionAbove (height px), bitrateAboveKbps, sizeAboveGb (GiB); all must hold, files without media info only match rules without media conditions). Actions: remux (copy into options.container), transcode (video copy/h264/hevc/av1, audio copy/aac/eac3 + channels), stripTracks (copy, keep only keepLanguages audio/subtitles, dropSubtitles) and addAudioTrack (copy everything, add one compatibility track, default AAC stereo, from the first kept audio track). keepLanguages applies to every action and never drops all audio; untagged tracks are always kept. mp4 output gets -movflags +faststart, text subtitles as mov_text (image subtitles are dropped) and hvc1 for HEVC.

Hardware encoding (options.hwaccel): vaapi (-vaapi_device /dev/dri/renderD128 + hwupload, *_vaapi, crf as -qp), qsv (*_qsv, -global_quality), nvenc (-hwaccel cuda, *_nvenc, -rc vbr -cq, presets mapped to p1-p7), videotoolbox (-q:v, no AV1). none uses libx264/libx265/libsvtav1 with -crf/-preset. No tone mapping: an HDR source encoded to h264 stays PQ/HLG-tagged 8-bit.

ffmpeg is ARCLIGHT_FFMPEG, else the ffmpeg next to ARCLIGHT_FFPROBE, else PATH.

Jobs (transcode_jobs) are drained by TranscodeWorker one file at a time. A rule’s schedule window ({start, end} HH:MM, server-local time, may cross midnight, equal = all day, null = any time) gates when its rule and import jobs may start; a job that started runs to the end. Manual single-file runs (POST /transcode/run {mediaFileId}) start at once, ignoring the window. Output goes to .<name>.arclight-transcode.<ext> next to the source, then:

  • keepOriginal: false: the original goes to the recycle bin (mediaManagement.recycleBinPath, same as imports; deleted when no bin is set), the result takes its name (new extension if the container changed).
  • keepOriginal: true: if the result has the same name the original becomes <name>.original.<ext>; after a container change the original stays where it is, no longer tracked by the library.

Then media_files path/relative_path/size_bytes are updated, media_info is cleared and the file is queued for media analysis, a fileTranscoded history row is written (data: sourcePath, path, action, rule, sizeBefore, size, recycleBinPath/originalPath, hardwareFallback when the job fell back to software), library updated is published and a fileTranscoded notification is sent (connections with onRename). runAfterImport rules are checked on every import imported event against the item’s newest file; files still without media info wait (up to 6 h, in memory) for analysis to publish library updated. Running a rule by id skips files it already completed. Deleting a rule cancels its queued jobs. Jobs left running by a restart go back to queued.

Hardware fallback: when a job that encodes video on a hardware backend (vaapi, qsv, nvenc, videotoolbox) exits non-zero and its stderr shows a device/session init failure (Device creation failed, Failed to set value ... for option 'init_hw_device', No VA display found, Cannot load libcuda, Error creating a MFX session, No supported child device type is enabled …), it is retried once with hwaccel: none (backend-native presets dropped) when the transcode settings section has fallbackToSoftware: true (default). The job message (and the history row’s hardwareFallback) records the backend, ffmpeg’s reason and that software was used. With fallbackToSoftware: false the job fails with a message naming the probable fix. Other failures are never retried.

Method Path Purpose
GET /transcoderule List rules
GET /transcoderule/:id One rule
POST /transcoderule Create (TranscodeRuleInput; 201)
PUT /transcoderule/:id Replace
DELETE /transcoderule/:id Delete (204); queued jobs of the rule are cancelled
GET /transcode/jobs Jobs, newest first (?status=queued|running|completed|failed|cancelled); progress 0-100, command (display)
POST /transcode/run {mediaFileId}: queue for the first enabled matching rule (400 when none, 409 when the file already has an active job); {ruleId}: queue every matching file. Answers {queued, jobs}
POST /transcode/jobs/:id/cancel Queued: cancelled; running: ffmpeg is killed and the temp file removed (409 when finished)
GET /transcode/hwaccel Capability test per backend (?refresh=true runs it again; otherwise the cached result): TranscodeHwaccelResponse {ffmpegPath, methods, backends, results, testedAt}
GET /settings/transcode TranscodeSettings {fallbackToSoftware} (default true)
PUT /settings/transcode Replace the section

GET /transcode/hwaccel does not trust ffmpeg -hwaccels (methods, which lists what the build was compiled with): every backend the build has runs a 1-frame encode of -f lavfi -i testsrc2=size=256x256:rate=1 with the same init flags and H.264 encoder a job would use (-vaapi_device /dev/dri/renderD128, -init_hw_device qsv=hw, -hwaccel cuda …), one at a time with a 20 s limit each. results holds {backend, available, reason} for every TranscodeHwaccel in order; reason is ffmpeg’s failure line (No VA display found ...) or this ffmpeg build has no ... support for backends the build lacks. backends lists the ones that passed, plus none whenever ffmpeg exists. The result is cached in memory until a refresh or a restart; concurrent requests share one run. Without ffmpeg: {ffmpegPath: null, methods: [], backends: [], results: [], testedAt: null}.

additional-languages (“Add additional…”)

Section titled “additional-languages (“Add additional…”)”

Contract: packages/shared/src/additional-languages.ts. Records (additional_languages) are scoped to a movie, a series, one season (seasonNumber) or one episode (episodeId); languages are normalised tags (spa -> es).

  • kind: 'subtitle': item-level extras merged with the item’s language profile when the subtitles module computes wanted (profile cutoff does not apply to them; items without a profile want just the extras). useWhisper runs a second pass with the whisperai provider (enabled or not, as long as its settings are valid) for languages no provider had.
  • kind: 'audio', mode: 'replace': the decision engine requires the language plus every audio language of the file being replaced (missingRequiredAudioLanguage). A release that carries them may replace a file that already meets cutoff at the same or a better quality (languageReplacementLowerQuality otherwise). Files lacking a required language fail the cutoff check, so CutoffUnmetSearch keeps looking. Release audio is judged from the name (named languages; MULTi adds the original language; untagged = original language); file audio from media analysis, else the languages parsed at import.
  • kind: 'audio', mode: 'version': adds or reuses a version slot with audioLanguages: [lang] (MediaVersion.audioLanguages); releases and imports that carry the language prefer that slot.

Searches run as one AdditionalLanguageSearch task (scheduler.runTask); POST waits up to 20 s and reports searching for whatever is still running. Each request and outcome writes an additionalLanguage history row (data.kind, language, outcome, message, mode).

Method Path Purpose
GET /additional-language ?movieId= or ?seriesId=[&seasonNumber=][&episodeId=]: ItemLanguages (audio and subtitle chips with present/added, records)
POST /additional-language/preview AdditionalLanguageRequest: per-item plan, nothing saved
POST /additional-language Save and search: AdditionalLanguageResult (jobId of the task)
DELETE /additional-language/:id Remove a record (204); a version slot it created stays

Contract: packages/shared/src/library-hygiene.ts. The OrphanScan job walks every root folder (skipping hidden/system folders, extras, the recycle bin and nested root folders) and replaces the library_hygiene_findings table:

  • orphan: a video file no media file row points at, with the parser’s guess and up to three suggestions (score 1 when inside the item’s folder, else title similarity; series suggestions carry the parsed episodes’ ids);
  • duplicate: episode/movie (a file for an episode/movie that already has one, a second library row for one movie, a series file no episode links to) or content (same size and sha256 of the first MiB; hard links excluded);
  • empty: the topmost folders without any (non-junk) file, except an item’s own folder.

Health check OrphanFilesFound (warning) from 25 orphans; the health contract has no info level.

Method Path Purpose
GET /library/hygiene Findings of the last scan; filters type (orphan, duplicate, empty) and rootFolderPath. Answers {lastScan, counts, records}
POST /library/hygiene/scan Start an OrphanScan job; 202 with the Job
POST /library/hygiene/:id/map Import an orphan via manual import (HygieneMapRequest: seriesId + episodeIds or movieId, importMode default move; omitted fields use the best suggestion). Files already inside the item’s folder are registered in place. 400 without a usable target, 409 when the import fails
DELETE /library/hygiene/:id Delete the file (through the recycle bin, keeping its path below the root folder) or the empty folder (409 when it is no longer empty). A library duplicate also loses its media file row (library fileDeleted). Answers {recycledTo, removedMediaFileId}

Contract: packages/shared/src/health-scan.ts; module apps/api/src/modules/health-scan. Admin only. Checks every library file (media_files and audio_files): stat (missing, empty, unreadable), ffprobe (won’t parse, no audio / video stream, duration against the metadata runtime for truncated files and samples, audio languages against the quality profile and “Add additional…” audio languages), ffmpeg decode to null (quick: 10 s at start, middle and end; deep: the whole file), plus album track-number gaps, audiobook part order and book length from the database. ffmpeg / ffprobe run through nice -n 19 / ionice -c 3 when lowPriority is on and those exist (Linux). Tables health_scan_files (per file: size, mtime, mode, checked at) and health_findings (one row per problem: type, severity, status open / ignored / fixed, details, data, firstSeen, lastChecked).

Jobs: HealthScan (whole library; weekly, Sunday 03:00 by default, or the window start; the cron follows the settings, off leaves it on demand only) and HealthCheck (chosen items or files). Whole-library runs skip files whose size and mtime are unchanged since a check in the same or a deeper mode (unless force), and scheduled runs stop at the end of the time window. After a run with new findings a healthScan notification goes to connections with On Health Issue.

Method Path Purpose
GET /library/health HealthListQuery: status (open default, ignored, fixed, all), type, severity, mediaType (series, movie, music, audiobook), seriesId, movieId, artistId, albumId, authorId, bookId, ids. Answers HealthListResponse {lastScan, running, filesChecked, filesTotal, ffprobe, ffmpeg, counts: {open, ignored, fixed, byType, bySeverity}, records}; byType / bySeverity count open findings
POST /library/health/scan HealthScanRequest: all: true (whole library, resumable; force checks every file), or any of seriesIds, movieIds, artistIds, albumIds, authorIds, bookIds, files ([{source: media | audio, id}]), always checked now. mode overrides the configured one. 202 with the HealthScan / HealthCheck Job; 400 without a scope
POST /library/health/action HealthActionRequest {action, ids} (1-2000 finding ids). replace: blocklist the release from the file’s import history (video), recycle or delete the file per the recycle-bin setting, remove it from the library, then one HealthReplaceSearch job for every affected movie, series (episodes merged), album and book. recheck: a HealthCheck job over the findings’ files (album / book findings: all their files). ignore: hidden until the file’s size or mtime changes; unignore. delete: recycle or delete the file and remove it from the library, no search. Album / book findings (source: null) cannot be replaced or deleted. Answers HealthActionResult {action, succeeded, failed, results: [{id, ok, error?, blocklisted?, recycledTo?}], jobId}
GET /settings/healthscan HealthScanSettings: schedule (weekly default, daily, off), mode (quick default, deep), concurrency (1-4, default 1), lowPriority (default true), window {enabled (false), start, end} (HH:MM; may cross midnight), truncatedPercent (10-99, default 75), checkLanguages (default true), notify (default true)
PUT /settings/healthscan Replace the settings (validated as above); reschedules HealthScan

Contract: packages/shared/src/bulk.ts; module apps/api/src/modules/bulk. Admin only. Each search command answers at once with BulkCommandResponse {queued, skipped, job} (job is null when nothing matched); the job reports through GET /jobs and job events.

Method Path Purpose
POST /command/bulksearch BulkSearchCommand: movieIds, seriesIds, episodeIds, or allMovies / allEpisodes (everything on the matching Wanted list), and mode: all (default, a user-invoked search per item), missing (Wanted → Missing rules) or upgrade (Wanted → Cutoff unmet rules: profile quality and custom-format cutoff, version slots; profiles with upgradeAllowed: false are skipped). all* needs missing or upgrade. q and the release filter (released, releasedAfter, releasedBefore; with allMovies / allEpisodes only) narrow “everything” to the Wanted rows the list shows, with the same conditions as GET /wanted/*/episodes and /movies (so “select all” on a searched Wanted page searches what the page shows); explicit ids ignore them. Items not released yet are never searched: they count in skipped and in unreleased. Episodes are searched per series and season. 202, one BulkSearch job
POST /rename/bulk BulkRenameRequest (seriesIds, movieIds): the rename preview of every item, {items, failed}
POST /command/bulkrename Same body plus files (media file ids to limit to): renames through the per-item rename; answers RenameResult
POST /command/subtitlesearch/bulk mediaFileIds, or wanted: series | movie (every file on Subtitles → Wanted): 202, one BulkSubtitleSearch job

Contract: packages/shared/src/stats.ts.

Method Path Purpose
GET /stats/storage Per root folder total/free bytes (volume) and bytes/files used by Arclight; top top (default 10) series and movies by size; buckets by quality, video codec, resolution (2160p/1080p/720p/SD/Unknown from media info, else quality) and dynamic range; growth over 30/90 days and daily points from stats_snapshots
GET /stats/library Series (monitored, continuing, ended), episodes (downloaded, missing = monitored + aired, unaired, unmonitored, cutoff unmet), movies (monitored, downloaded, missing as in /wanted/missing/movies, cutoff unmet), files and size
GET /stats/activity Grabs, imports and failures per UTC day for days (default 30, max 365) from history; season packs count once (per download id / imported path)
GET /stats/indexers Proxy of GET /indexer/stats (days, default 30)

Contract: packages/shared/src/mobile.ts. Phones paired through the Arclight app (user guide). Every route is open to any signed-in user (portal access) and acts on the caller’s own devices; admins may list (?all=true), remove and test anyone’s. Registering needs a signed-in user (API-key callers without one get 400 NO_USER). No response ever includes a device’s relay secret. Notifications follow the caller’s web push choices (GET/PUT /push/preferences); delivery and the relay are described in OPERATIONS.md.

Method Path Purpose
GET /mobile/pairing { serverUrl, pushEnabled }: the Application URL (Settings > Requests) or null, and false when the server runs with ARCLIGHT_PUSH_RELAY=off. The web UI encodes arclight://pair?url=<encoded base URL> in its QR code, using its own origin when serverUrl is null
GET /mobile/devices The caller’s devices (MobileDeviceResource: id, userId, relayDeviceId, platform ios/android, deviceName, appVersion, createdAt, lastSeenAt, lastError). ?all=true: every user’s, with userName (admins; others 403 FORBIDDEN)
POST /mobile/devices Body: the app’s ArclightShell.registerPush() result plus appVersion ({ relayDeviceId, relaySecret, platform, deviceName, appVersion? }). Upserts by relayDeviceId and binds the device to the caller; 201 with the resource
DELETE /mobile/devices/:id Unpair. 204; 404 when it is not the caller’s (admins: any)
POST /mobile/devices/:id/test Send a test notification now: { ok, outcome: sent | removed | rate_limited | failed | disabled, message? }. A device the relay reports gone is deleted (removed). One test per device per 10 s, else 429 RATE_LIMITED

Contract: packages/shared/src/search.ts; module apps/api/src/modules/search. The topbar search’s server-side lookups in one round trip. Open to every signed-in user; what comes back is scoped to the caller (below).

Method Path Purpose
GET /search/universal?q=&limit=20 UniversalSearchQuery: q (1–200 characters after trimming; empty is 400), limit hits per kind (1–50, default 20). Answers UniversalSearchResponse {query, results: [{kind, total, hits}]}

Each hit is UniversalSearchHit {kind, id, title, subtitle, poster, status, parentId, score}. Kinds, in the order results lists them (a kind with no match is left out):

Kind Searched subtitle / status / parentId
series, movie title year; monitored/unmonitored (series), downloaded/missing/unmonitored (movie); poster image
episode title (TBA skipped) Series · S01E02; file status; parentId = series id
artist, album name, title disambiguation / artist name; monitored status; poster or cover; album parentId = artist id
author, book name, title – / author name; monitored or file status; headshot / cover; book parentId = author id
indexer, downloadClient, notification, importList name implementation; enabled/disabled
qualityProfile, customFormat, tag name, label media domain (profiles)
user display name and username username or role; role
task scheduled job name (id is the name) interval; enabled/disabled
queue download title (imported/failed/ignored rows left out) state (the client status while downloading, e.g. paused)
history source title, newest first eventType · YYYY-MM-DD; event type
request, issue title type · year / issue type; request or issue status; poster

Ranking uses the shared matcher (rankMatches): exact title, then prefix, then word-prefix, substring and loose (in-order letters, e.g. dn prt finds “Dune: Part Two”); shorter titles break ties. Per kind at most UNIVERSAL_SEARCH_SCAN (200) candidates are read with an SQL prefilter on the longest query word: first rows containing it, then, while room is left, rows holding its letters in order. total counts the matches among those candidates. The prefilter is ASCII case-insensitive and not accent-folded, so amelie does not find “Amélie”.

Scoping: admins and the API key see every kind. Others see the library kinds (series through book) with the library app area, queue and history with activity; every request with Manage requests or View requests (else their own), every issue with Manage issues or View issues (else their own). Settings entities, users, tags and tasks are admin only.

Contract: packages/shared/src/alerts.ts.

Method Path Purpose
GET /settings/alerts AlertSettingsResponse: quietHours {enabled (false), start "22:00", end "07:00", days [0..6]} (server local time; a window ending before it starts runs past midnight and belongs to its start day), dailySummary {enabled (true), time "08:00"}, plus heldAlerts and quietNow
PUT /settings/alerts Body AlertSettings; reschedules the DailySummary job (no cron while off)
GET /summary/latest The newest DailySummary, or null
GET /summary?limit=30 Stored summaries, newest first (kept 30 days)
POST /summary Body {notify?: false}: build one now (notify also sends it)

A summary covers the 24 hours before it: timeline counts (grabbed, imported, failed = import failures + download errors, stalled, recovered, replaced = entry types containing “replac”), kill-switch lockdowns overlapping the period (count, total time), free space per root folder and download-client folder with the change since the previous summary, up to 10 problems, open health issues and what is still downloading. The bus event summary generated announces it.

Notification connections have seven more trigger flags: onDownloadStalled, onKillSwitch (one alert per lockdown with the reason; a lockdown lasting 30 minutes sends an urgent reminder to connections that held the first alert), onKillSwitchResumed, onImportProblem (import failed/blocked with the message), onDiskSpaceLow (the disk-space health checks entering warning or error; errors are urgent), onDownloadRecovered (every other timeline entry that is not routine, e.g. recovered or a replaced release, with the entry’s message) and onDailySummary. respectQuietHours (default true) holds non-urgent alerts during quiet hours in notification_held; the AlertsTick job sends them as one heldAlerts message (a single held alert as itself) once quiet hours end.

Implemented in apps/api/src/modules/config-export (contract: packages/shared/src/operations.ts). Format, managed mode and env overrides: OPERATIONS.md.

Method Path Purpose
GET /config/export?format=yaml|json&includeSecrets=false Admin. One document: every settings section plus tags, custom formats, quality/delay/release profiles, download clients, indexer proxies, indexers, remote path mappings, root folders, notifications, import lists, language profiles, keyed by name. Secrets are ********; includeSecrets=true also needs the header X-Confirm: export-secrets (else 400 CONFIRMATION_REQUIRED). Served as an attachment (arclight-config.yml / .json)
POST /config/import Admin. Body {document} or {content} (YAML/JSON text) plus dryRun, prune; or a raw body with Content-Type: application/yaml and ?dryRun=true&prune=true. Upserts by name, deletes only with prune (and only inside collections the document lists), all in one transaction. Returns ConfigImportResult {dryRun, applied, changes[{kind,key,op,fields?}], summary, errors, warnings}; 422 with the same body when an apply has errors, 400 for unparsable text
POST /config/validate Admin. Same body; {valid, errors, warnings} without writing
GET /config/managed-keys Settings keys and resource keys owned by ARCLIGHT_CONFIG_FILE / ARCLIGHT_SETTING__* (the UI locks them): {file, appliedAt, settings: {section: keys[]}, resources: {kind: keys[]}, env[], errors[]}

Implemented in apps/api/src/modules/migrate (contract: packages/shared/src/migrate.ts). All routes are admin only. The web UI is System > Import; the walkthrough is in migrating.md. Source apps are only read.

Method Path Purpose
GET /migrate/sources Candidate database files found in the container (/config, /import, /migrate, /mnt/import, ARCLIGHT_MIGRATE_DIRS)
GET /migrate/discover Old apps found on the Docker network (names and ports probed with DNS and TCP only)
POST /migrate/test Test one source (API or files): version, or which other app it is
POST /migrate/plan Single-app plan from a database file (no writes)
POST /migrate/apply Single-app import as a background job; 202
GET /migrate/status State of the single-app import
POST /migrate/stack/preview Wizard preview for all chosen sources (per-area counts, warnings, conflicts, reasons); no writes
POST /migrate/stack/apply Start the whole-stack import (MigrateStack job); 202. Dry run by default in the UI; a real run takes a backup first when asked
POST /migrate/stack/resume Continue a failed or interrupted real run; 400 when there is none
GET /migrate/stack/status Progress, per-source state and log of the current or last run
GET /migrate/stack/report The report; ?runId= picks a run, ?download=1 serves it as a JSON attachment (secrets masked)
POST /migrate/stack/source-cleanup Pause, or remove (files kept), the adopted torrents in qBittorrent after a run (API mode only)

A source app that refuses Arclight (wrong key) or does not answer is reported as 502, never 401/403, so the web client does not mistake it for a lost Arclight session.

Implemented in apps/api/src/modules/audit. Every mutating /api/v1 request is recorded by an onResponse hook; login.success / login.failed come from the auth service. Admin only.

Method Path Purpose
GET /audit?page&pageSize&user&action&from&to&q AuditPage, newest first. user: username (case-insensitive) or user id; action: exact or prefix (settings matches settings.update); from inclusive, to exclusive (ISO-8601; 400 when not a date); q: text search over username, action, target, method, route, ip, status code and the details JSON
GET /audit/actions Distinct action names (filter list)
GET /audit/export?user&action&from&to&q Same filters (and q) as CSV (time,username,userId,ip,action,target,method,route,statusCode,details)
GET/PUT /settings/audit {enabled, retentionDays} (default true, 90; 0 keeps events forever)

Implemented in apps/api/src/modules/metrics. The scrape endpoint lives at the root, not under /api/v1.

Method Path Purpose
GET /metrics (root) Prometheus text format 0.0.4. Same credentials as the API (X-Api-Key, ?apikey=, Basic, session); anonymous when metrics.allowAnonymous
GET/PUT /settings/metrics {allowAnonymous} (default false)

Implemented in apps/api/src/modules/transfer-metrics (contract: packages/shared/src/transfer-metrics.ts). A sampler reads the queue (with the built-in engine’s live overlay), the engine totals and the VPN status every 10 s and writes one averaged point per minute (1m, kept 7 days); TransferMetricsRollup folds finished hours into 1h points (kept 90 days). VPN transitions (connect/disconnect, kill switch, forwarded port, warnings) are stored as they are seen, from samples and vpn events. range is 1h, 6h (1-minute points), 24h (5-minute) or 7d (hourly); missing steps are gaps. Annotations (oldest first, at most 200) are download timeline entries and VPN transitions.

Method Path Purpose
GET /metrics/downloads/:queueId?range= Rates, peers, seeds and progress of one tracked download; annotations: its timeline plus VPN drops and kill-switch changes. 404 when the id was never tracked
GET /metrics/engine?range= Built-in engine totals (rates, peers, downloading + seeding torrents); annotations: stalls, errors and kill-switch pauses of any download plus VPN drops
GET /metrics/vpn?range= Share of samples connected / kill switch engaged / with a warning, forwarded port per step, uptime over the window; annotations: every VPN transition

Implemented in apps/api/src/modules/discord-bot. A Discord bot user (not a webhook) that connects to the gateway over the Node global WebSocket (Identify with intents GUILDS | GUILD_MESSAGES, jittered heartbeat, Resume on the resume_gateway_url, reconnect with exponential backoff from 1 s up to 60 s; a missing heartbeat ACK, op 7 and op 9 reconnect; close codes 4004/4010-4014 stop with state: error). On Ready it bulk-overwrites the slash commands on guildId (instant) or globally. Settings are the discordBot row of the settings table (DiscordBotSettings); saving restarts the bot.

Method Path Purpose
GET/PUT /settings/discordbot Admin. DiscordBotSettings {enabled, botToken, guildId, requestChannelId, adminRoleId}; botToken reads back as ******** and sending the mask keeps it; ids are Discord snowflakes; enabling needs a token
GET /discordbot/status Admin. DiscordBotStatus: state (disabled/connecting/connected/reconnecting/error), bot user, connectedAt, lastError, reconnectAttempts, commandsRegistered, linkedUsers
POST /discordbot/test Admin. Optional DiscordBotSettings body (mask = stored token). GET /users/@me with the token, then posts a test embed to requestChannelId when set → DiscordBotTestResult
POST /user/me/discord/link-code Signed-in user (not API key). One-time 8-character code valid 10 minutes (a newer code replaces older ones) → DiscordLinkCode {code, expiresAt, linkedDiscordUserId}
DELETE /user/me/discord/link Removes the caller’s Discord link (204)

The link is users.settings.discordUserId (in ADMIN_MANAGED_USER_SETTINGS, so PUT /user/me cannot set it); one Arclight user per Discord account (a new /link moves it).

Slash commands (every interaction is acknowledged with an ephemeral deferral, then the original response is edited):

Command Behaviour
/link <code> Links the caller to the Arclight user who created the code
/request <title> [year] [4k] TMDB multi-search; up to 5 matches as buttons. A click creates the request as the linked user (requests.create, series request all seasons), so permissions, quotas, approval policies and content caps apply. Unlinked users are told to create a code under Profile > Discord
/status [title] The linked user’s requests (up to 10), filtered by title
/search <title> TMDB matches with library / request status (normal and 4K)
/pending Up to 5 pending requests with Approve/Decline buttons. Allowed for linked users with MANAGE_REQUESTS (who act as themselves) and for members holding adminRoleId (who act as the system; modifiedBy stays empty). Checked again on each click

Request events (created, approved, completed) are posted as embeds to requestChannelId through POST /channels/{id}/messages with the bot token. Health check DiscordBotDisconnected (warning): enabled but not connected for 2 minutes, or stopped on a fatal close code.

Method Path Purpose
GET /settings/torrent Engine settings plus effectiveDownloadsRoot and resumeDir. The transport options encryption (preferred / required / disabled), tcp, utp, ipv6 and webSeedConnectionsPerTorrent are filled with the engine defaults when never saved. Also diskGuard (boolean, default true), maxActiveChecks (1 to 16, default 2) and metadataTimeoutMinutes (0 to 10080, default 60, 0 = never)
PUT /settings/torrent Any subset; omitted keys keep their stored value. Applied live: limits, queue, diskGuard, maxActiveChecks, metadataTimeoutMinutes, encryption and webSeedConnectionsPerTorrent at once; tcp / utp / ipv6 re-open the sockets on the same port; dht restarts the engine
GET /torrent/status Engine state, bound address/port, VPN binding, dhtNodes, dht6Nodes, utpConnections, peers, encryptedPeers, encryptedPeerRatio (0..1), rates, counts per state. While the VPN tunnel guard limits the engine: tunnelGuard (downloadLimit, uploadLimit, connectionLimit; bytes/s, null = not limited). With the disk guard on: diskGuard (downloadLimit in bytes/s or null when not limiting, writeRate, backlogBytes, backlogLimitBytes, checksActive, checksQueued). limits holds the configured maxConnections, maxConnectionsPerTorrent, maxActiveDownloads and maxActiveSeeds
GET /torrent/torrents ?id=&category=: detailed torrents. Each peer has encrypted and transport (tcp / utp); each torrent has webSeeds (url, rate, downloaded, active requests, failures, retryAt, lastError), superSeeding and limits
POST /torrent/:id/recheck Re-hash the payload
POST /torrent/:id/pause / resume Pause / resume (resume is 409 while the VPN kill switch holds)
POST /torrent/:id/move { savePath }
POST /torrent/:id/priority { files?: [{ index, priority 0..7 }], sequential? }
POST /torrent/:id/limits { uploadLimit?, downloadLimit?, peerUploadLimit?, peerDownloadLimit? } in bytes/s (0 = unlimited); omitted keys keep their value; persisted in the resume data
POST /torrent/:id/superseed { enabled }: BEP 16 super-seeding
GET /torrent/speed Current speed mode: mode (normal / alternative), reason (manual / schedule), slowMode, slowModeUntil, nextChange, effective downloadLimit / uploadLimit in bytes/s. Also speed in /torrent/status
POST /torrent/slowmode { enabled, until? }: manual slow mode (alternative limits) until turned off or until until (ISO-8601). Applied live; mirrored to qBittorrent’s alternative limits when mirrorToQbittorrent
GET /settings/torrent/speed altDownloadLimitKbps (default 1024), altUploadLimitKbps (256), scheduleEnabled, schedule: [{ days: 0..6 (0 = Sunday), start, end: HH:MM }] in server-local time (an end at or before the start runs past midnight), mirrorToQbittorrent (true)
PUT /settings/torrent/speed Any subset; applied live. The schedule is re-checked every 30 s
GET /settings/torrent/seedingrules { rules: [...] }: per rule id, name, enabled, filters categories / indexerIds / trackers (host names, subdomains match; empty = any), ratio and/or seedTimeMinutes (either reached), action (pause / remove), deleteFiles
PUT /settings/torrent/seedingrules Replaces the list; the first enabled matching rule applies
POST /torrent/seedingrules/apply Run one pass now (the ApplySeedingRules job runs every 5 minutes): { checked, paused, removed, held: [{ infoHash, reason }] }, null while the engine is not running

Seeding rules never act before a tracker’s hit-and-run requirement is met, only remove downloads Arclight has imported, and delete files only when the download’s folder does not overlap a library folder (root, series or movie folder) or another torrent’s data; otherwise the torrent is removed and the files kept. Pauses and removals are written to the download timeline with data.reason = seedingRule.

:id is the 40-character info-hash. Errors: 400 validation, 404 unknown torrent, 409 engine not running. The builtin download client settings also carry the defaults for new torrents: superSeeding, uploadLimitKbps, downloadLimitKbps, peerUploadLimitKbps, peerDownloadLimitKbps (KiB/s, empty or 0 = unlimited). AddTorrentInput.skipChecking (cross-seeding) makes the built-in client trust a payload whose file sizes match (fast-resume data with every piece) and qBittorrent send skip_checking.

engine-agent (Arclight as a remote engine)

Section titled “engine-agent (Arclight as a remote engine)”
Method Path Purpose
GET /settings/engineagent { enabled, path, engineRunning }
PUT /settings/engineagent { enabled? } (admin)
* /engine-agent/* The engine agent protocol (see REMOTE-ENGINE.md) over this instance’s built-in engine, for another Arclight’s remoteEngine download client. Requires engineAgent.enabled (else 404) and the API key in X-Api-Key (a browser session is refused, 401). Errors use the agent shape { error, code }. PUT /engine-agent/listen-port is always 409: this instance’s port follows its own torrent/VPN settings
GET (WS) /engine-agent/events Agent event stream (hello, added, removed, stateChange, completed, metadata, listenPort)

The remoteEngine download client kind has settings url, token (write-only, masked like passwords), category, postImportCategory, pullMode, localDownloadPath, bandwidthLimitKbps and followVpnPort. Health check RemoteEngineUnreachable reports each enabled remote engine whose agent did not answer the last queue poll.

Method Path Purpose
GET /indexer/:id/tracker Tracker account settings of a torrent indexer (defaults when never saved)
PUT /indexer/:id/tracker { enabled, ratioRequired?, hnrRule: { minSeedHours?, minRatio? }, freeleechOnly, bufferTracking, minBufferBytes?, announceHosts[], scrapeAccount }; 400 for usenet indexers, 404 unknown
GET /tracker/stats Per tracker: engine totals of its torrents (torrents, seeding, uploaded, downloaded, seedingTimeMs, hnrPending), the scraped account page (accountRatio, accountUploaded, accountDownloaded, accountBuffer, accountHnrCount, accountSeedBonus, accountError), the effective ratio, buffer and ratioStatus (ok / warning within 10 % / low / unknown)
POST /tracker/stats/sync Run the TrackerStatsSync now; returns { message, stats }

Engine torrents are attributed to an indexer by: a cross-seed record, the grab snapshot of the tracked download, the account’s announceHosts (exact), else the indexer’s site hosts (subdomains match). Hooks: the H&R rule of an enabled account is attached to the engine Session; the builtin client reports a finished torrent of that tracker as read-only and not removable, and removeItem throws rejected, until it has seeded minSeedHours or reached minRatio. trackerStatsServiceFor(ctx).releaseFilter(indexerId, release) returns { reason: 'notFreeleech', message } for a non-freeleech release of an indexer in freeleech-only mode (null otherwise) for the decision engine. Health check TrackerRatioLow: error below the required ratio, warning within 10 % above it, below minBufferBytes, or when the account page reports hit-and-runs. Definitions may declare an optional account block (path, method, inputs, fields: { ratio, uploaded, downloaded, buffer, hnrCount, seedBonus } as selector blocks), scraped logged-in with the html engine.

Method Path Purpose
GET /settings/crossseed { enabled, indexerIds[], sizeTolerancePercent (0.5), onImport, schedule (cron, default daily), excludeTags[], downloadClientId, category ('cross-seed'), linkDir, searchIntervalDays, maxPerRun }
PUT /settings/crossseed Full settings; reschedules CrossSeedSearch
GET /crossseed ?mediaFileId=&state=seeding|failed: recorded cross-seeds (infoHash, indexer, client, savePath, state, message)
POST /crossseed/search Exactly one of { mediaFileId | seriesId | movieId }: search now; returns { filesSearched, candidates, added[], skipped[{ title, indexerId, reason }] }

For each library file the enabled torrent indexers are searched (TV: season + episode, movie: ids + year). A release qualifies when its size is within sizeTolerancePercent (exact sizes first), its parsed title names the same episode(s) / movie (season packs, multi-season and other episodes are skipped) and its info-hash is not already a cross-seed, a tracked download or an engine torrent. The .torrent is downloaded through the indexer; magnets and multi-file torrents are skipped. The single file must have the library file’s exact size; the torrent seeds in place when it names the file the same, otherwise from a hard link (symlink fallback) under <linkDir>/<infohash>/. The first and last pieces are hashed against the file before adding it seed-only (skipChecking) to the chosen client (Built-in first, or qBittorrent).

Registered through scheduler.register (defined in apps/api/src/scheduler.ts). Run any of them with POST /jobs/run.

Job Cron Module What it does
HealthCheck */5 * * * * health Evaluates checks, publishes a health event
RefreshQueue * * * * * download-clients Polls clients; returns immediately when idle
RssSync from Settings → Indexer options (default */15 * * * *; on demand only at 0) indexers Fetches RSS from all indexers and dispatches new releases; rescheduled with scheduler.update when the interval changes
IndexerStatusCheck 0 * * * * indexers Hourly indexer status check
IndexerStatsCleanup 20 3 * * * indexers Deletes indexer_stats rows older than 90 days
IndexerDefinitionFeedSync 41 */12 * * * from indexers.definitionFeed.intervalHours (on demand only when disabled or manual) indexers Definition feed sync (see Definition feed)
IndexerCommunityCatalogueSync 23 4 * * * from indexers.communityCatalogue.intervalHours (on demand only when disabled); also run 90 s after start when never synced or overdue indexers Community catalogue sync (see Community catalogue)
GuidesSync 17 4 * * * from guides.syncIntervalHours (17 */N * * * below a day; on demand only when disabled) guides Guide fetch + diff; applies or stores the pending diff per mode
ThemesSync minute 41 every themes.syncIntervalHours hours (24 by default; on demand only when disabled); also run 90 s after start when overdue themes Downloads new and updated theme packs from themes.sourceUrl
RefreshSeries 0 3 * * * metadata Refresh all series from TMDB
RefreshMovie 30 4 * * * metadata Refresh all movies from TMDB
RefreshArtists 20 */6 * * * metadata Music: refreshes artists due by next_refresh_at (oldest first) for at most 20 minutes; the next refresh is 1 day out after a release in the last 90 days or an upcoming one, else 7 days (continuing) or 30 days (ended). New release groups follow the album-type profile and monitorNewAlbums. Nothing while music is off
RefreshAuthors 40 3,15 * * * metadata Audiobooks: refreshes authors due by next_refresh_at (oldest first) for at most 20 minutes; the next refresh is 1 day out while a monitored book is still to be released, else 7 days. New books follow monitorNewBooks. Nothing while audiobooks are off
MetadataCacheCleanup 50 5 * * * metadata Deletes metadata_cache rows that expired more than 30 days ago
LibraryImportScan, LibraryImport ad hoc imports Library import of existing music / audiobook folders: the scan (read tags, group, propose matches) and the import of chosen candidates (see Library import)

| MissingEpisodeSearch | 0 3 * * * | releases | Searches the episodes GET /wanted/missing/episodes lists (no specials), in list order, items past their back-off only (a day per search without a grab, at most 7), up to 200 a run, grouped per season | | MissingMovieSearch | 20 3 * * * | releases | The same for GET /wanted/missing/movies, up to 100 a run | | CutoffUnmetSearch | 40 3 * * * | releases | Searches the items GET /wanted/cutoff/* lists (same cutoff checker), in list order, items past their back-off only, up to 100 episodes and 100 movies a run; GET /release/searchstatus estimates the next run per item | | MissingAlbumSearch | 10 4 * * * | releases | Music: searches the albums GET /wanted/missing/albums lists, in list order, past their back-off only, up to 50 a run, grouped per artist. Nothing while music is off | | MissingBookSearch | 20 4 * * * | releases | Audiobooks: searches monitored, released books of monitored authors without a file, newest first, past their back-off only, up to 50 a run, grouped per author. Nothing while audiobooks are off | | CutoffUnmetAudioSearch | 30 4 * * * | releases | Music and audiobooks: the same for albums whose lowest track quality, and books whose lowest file quality, is below the profile cutoff, up to 50 of each a run, each only while its feature is on | | RequestsStatusSync | */5 * * * * | requests | Recomputes media status from the library, or from the media server when useMediaServerAvailability is on (Jellyfin/Emby provider ids, Plex guids, per-season episode counts), completes requests (music and audiobook requests too), sends available notifications | | WatchlistSync | */15 * * * * | requests | Pulls Plex watchlists (users with a token) and Jellyfin/Emby favourites (linked users); auto-requests when autoRequestFromWatchlist, AUTO_REQUEST* and the per-type sync flag allow | | RequestsPolicySweep | 0 * * * * | requests | Re-evaluates requests held by approval windows: approves those the policies now approve, releases the rest to pending when no window holds them | | RequestsDigest | 0 * * * * | requests | Hourly tick; once a day at digestHour (when digestEnabled) sends one notification summarising pending requests and open issues (nothing when both are empty) | | ApplyTagRules | 15 4 * * * | tag-rules | Forgets ownership of deleted items, then runs every enabled tag rule over the library | | RetentionSweep | 30 3 * * * | retention | Pulls play state from servers flagged usedForRetention, stages rule matches, applies candidates past their grace period, sends the retention summary | | SubtitleSearchWanted | */15 * * * * | subtitles | Searches wanted subtitles, least recently searched first, up to search.batchSize items; adaptive backoff per language; stops when every provider is throttled | | SubtitleUpgrade | 30 3 * * * | subtitles | Re-searches recent downloads (upgrade window) scoring below perfect; needs old score + 2 | | SubtitleScan | 0 4 * * 0 | subtitles | Re-indexes external and embedded subtitles of every file, prunes orphaned rows | | SubtitleSyncCheck | 20 * * * * | subtitles | While sync.autoCheck is on, scores up to sync.checkBatchSize external subtitles never checked and applies autoFixBelow / autoRetryProviderBelow | | WhisperAutoDetect | 7 * * * * (and once at boot) | subtitles | Probes ARCLIGHT_WHISPER_URL (HEAD /docs or /asr); when it answers, adds the whisperai provider last with onlyWhenNoOtherResult (a hand-configured entry is left alone) | | AnalyzeMedia | */15 * * * * (and on demand when files are queued) | media-analysis | Queues files without media info, then runs ffprobe over the queue N at a time | | TranscodeWorker | */5 * * * * (and on demand when jobs are queued) | transcode | Runs queued transcode jobs one at a time; rule/import jobs wait for their rule’s window | | OrphanScan | 0 2 * * 1 | library-hygiene | Orphan, duplicate and empty-folder scan of every root folder | | StatsSnapshot | 15 0 * * * | stats | Daily per-root-folder storage snapshot (keeps 400 days) | | DecisionLogCleanup | 50 4 * * * | releases | Deletes release_decisions rows older than releases.decisionLogDays (default 14) | | SceneMappingSync | 23 5 * * * | scene-mapping | Pulls the community scene-mapping feed and replaces the feed rules (404 = not published) | | DownloadEventsCleanup | 41 3 * * * | download-clients | Deletes download timeline entries (download_events) older than 30 days | | TransferMetricsRollup | 2 * * * * | transfer-metrics | Folds finished hours of 1-minute transfer points into hourly points | | TransferMetricsPrune | 53 3 * * * | transfer-metrics | Deletes 1-minute transfer points older than 7 days, hourly points and VPN transitions older than 90 | | AuditCleanup | 17 3 * * * | audit | Deletes audit_events older than audit.retentionDays (nothing at 0) | | TrackerStatsSync | */30 * * * * | tracker-stats | Engine totals per tracker, then the account pages of enabled accounts with scrapeAccount | | CrossSeedSearch | crossSeed.schedule (default 0 4 * * *) | cross-seed | Searches up to maxPerRun files not searched within searchIntervalDays, newest first (nothing while disabled) | | ProfileMigration | from profileMigration.intervalMinutes (default */30 * * * *) | profile-migration | Searches up to searchesPerRun queued mismatched items (within maxSearchesPerDay), skipping active downloads and root folders below the minimum free space | | ProfileFitRefresh | 10 4 * * * | profile-migration | Full file-fit re-evaluation; prunes done work-list rows after 30 days | | ProfileMigrationSearch | on demand | profile-migration | “Search all now” for one profile |

The RSS and status crons can be overridden through registerIndexerJobs options.

GET /api/v1/events upgrades to a WebSocket. Messages are JSON, discriminated on type (ServerEvent in packages/shared/src/events.ts). Payloads carry ids; clients refetch.

type Fields
hello serverTime
job job (the Job resource)
health health (HealthResponse)
library action (added updated deleted fileAdded fileDeleted refreshed), mediaType (series movie), id
queue action (changed)
download action (event), event (a DownloadEvent: new timeline entry of a tracked download)
torrent action (lockdown unlocked), optional reason, infoHashes (torrents the kill switch paused / resumed)
release action (grabbed rejected pending), title, optional mediaType, mediaId
import action (imported failed manualRequired), title, optional mediaType (series movie music audiobook), mediaId (series/movie id; the artist id for music), albumIds (music), message
indexer action (failed recovered), id, name
history action (added)
settings section
request action (created updated approved declined failed completed deleted), id
issue action (created updated deleted), id
subtitle action (downloaded upgraded deleted uploaded synced scanned blacklisted profiles settings), optional mediaType, mediaId, mediaFileId
profile action (created updated deleted), kind (quality customFormat), id
profileMigration action (changed): file fit or the work list changed

release, import and history are defined in the contract (the file is uncommitted and being edited), but their producing modules are in progress, so nothing emits them yet.

Implemented in apps/api/src/modules/compat (settings contract: packages/shared/src/compat.ts). Third-party tools that speak Sonarr or Radarr (Overseerr/Jellyseerr, Bazarr, Prowlarr, Notifiarr, Homarr, Recyclarr, LunaSea, nzb360, dashboards) can point at Arclight as if it were either app. The native /api/v1 API is unaffected.

Mounting. compatApi(ctx) is registered at the root (not inside /api/v1) and serves /api/v3/* plus the probes /api, /ping, /initialize.json, /initialize.js. It installs the same auth hook as /api/v1 (installAuthHook with its own public-route predicate) on its own encapsulated scope, so the same credentials work: X-Api-Key header, ?apikey=, session cookie, Basic, and the local bypass. Only the four probes are public (/initialize.json never contains the API key). compatModule adds GET/PUT /api/v1/settings/compat.

One prefix, both shapes. Both apps use /api/v3, so resources are split:

Shape Resources
Sonarr series (+ /lookup), episode (+ /monitor), episodefile, calendar, wanted/missing, wanted/cutoff
Radarr movie (+ /lookup, /lookup/tmdb, /lookup/imdb), moviefile, collection
Sonarr + harmless Radarr fields qualityprofile, qualitydefinition (+ /update), customformat, delayprofile, releaseprofile, rootfolder, tag, language, languageprofile, indexer (+ /schema, /test, /testall), downloadclient, notification, importlist, history (+ /since, /series, /movie), blocklist (+ /bulk), queue (+ /status, /details, /bulk), command, release (+ /push), system/status, system/task, system/backup, health, diskspace, filesystem, update, config/host, config/naming, config/mediamanagement, config/ui

Movie rows in shared resources (history, queue, blocklist) carry movieId and Radarr event names (movieFileDeleted), with seriesId/episodeId set to 0 so Sonarr-only clients do not choke.

Writes supported: series and movies (POST add by tvdbId/tmdbId/imdbId with addOptions, PUT, DELETE with deleteFiles / addImportListExclusion / addImportExclusion), episode monitoring, episode/movie file delete, root folders, tags, quality profiles (POST/PUT, Recyclarr-style items/groups/formatItems), quality definitions, indexers (Prowlarr application sync: Torznab/Newznab fields[], masked apiKey keeps the stored key, seed times in minutes), queue removal (removeFromClient, blocklist, skipRedownload), blocklist delete, interactive grab (POST /release), release push, commands.

Paging. page, pageSize, sortKey, sortDirection (ascending/descending/default) on queue, history, blocklist, wanted/*; responses are {page, pageSize, sortKey, sortDirection, totalRecords, records}.

Commands. POST /api/v3/command {name, ...} returns a CommandResource whose id is a row in the jobs table; GET /command/:id reads it back (queued/started/completed/failed/cancelled).

Command Runs
SeriesSearch {seriesId}, SeasonSearch {seriesId, seasonNumber}, EpisodeSearch {episodeIds}, MoviesSearch {movieIds} user-invoked release search + grab
MissingEpisodeSearch, MissingMoviesSearch, CutoffUnmetEpisodeSearch/CutoffUnmetMoviesSearch, RssSync the scheduler job of the same purpose
RefreshSeries / RefreshMovie (with ids: those items; without: the full refresh job) metadata refresh
RescanSeries / RescanMovie imports rescan
RenameFiles, RenameSeries, RenameMovie imports rename
DownloadedEpisodesScan, DownloadedMoviesScan, RefreshMonitoredDownloads RefreshQueue job
ManualImport {files, importMode} imports manual import
anything else naming a registered job (HealthCheck, …) that job; unknown names answer 400

System status and versions. system/status reports appName: "Arclight", instanceName (setting), the real Arclight version, plus isProduction, branch, startupPath, urlBase, appData, osName, runtimeVersion, authentication, migrationVersion. Overseerr/Jellyseerr check version loosely (presence; the major decides v3/v4 behaviour). Every compat response also carries X-Application-Version (setting advertisedVersion, default 4.0.4.0), which Prowlarr requires to be at least the Sonarr/Radarr minimum before it syncs.

Settings (compat section, GET/PUT /api/v1/settings/compat): enabled (default true; false makes every compat route and probe answer 404), instanceName (default Arclight), advertisedVersion.

Errors. 404 {message}; 400 is an array of {propertyName, errorMessage, severity} (e.g. This series has already been added); other failures {message}.

Known gaps.

  • SignalR (/signalr/messages) is not emulated: clients that wait for push updates fall back to polling or show stale state. The native WebSocket is /api/v1/events.
  • calendar and wanted/missing are episode-only (Sonarr shape); Radarr’s movie calendar is not served on the shared path.
  • Lookups are TMDB-backed: tvdbId is 0 unless TMDB knows it; tvdb: lookups go through TMDB’s external-id search. TV Rage / TV Maze ids are always 0; airTime is UTC, not network time.
  • Read-only: customformat, delayprofile, releaseprofile, downloadclient, notification, importlist, config/*, system/backup (always empty). Their writes go through /api/v1.
  • Root folders list both series and movie folders (with an extra mediaType); a POST without a media type guesses from the path (movie/film means movies).
  • POST /indexer/test validates the body only; it does not contact the indexer.
  • Stored indexer base URLs are normalised (trailing slash dropped), so Prowlarr may report a difference and re-send the indexer on its next sync.
  • Queue records expand multi-episode downloads into one record per episode (ids encode the download); queue quality comes from the grab’s history row.
  • Custom format specification values are best-effort upstream encodings; languageprofile is a single fixed “Any” profile for Sonarr v3 clients.