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.
List text search (q)
Section titled “List text search (q)”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).
Authentication
Section titled “Authentication”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:
- Session cookie
al_session(fromPOST /auth/loginorPOST /setup/admin): httpOnly,SameSite=Lax,Securewhen the request arrived over https (directly or viaX-Forwarded-Proto). Only a sha256 of the token is stored. Sessions lastsessionDays(default 30) and slide forward once past half their lifetime;rememberMe: falsegives a browser-session cookie and a one-day server lifetime. - HTTP Basic credentials, only when the auth method is
basic. - API key in the
X-Api-Keyheader or?apikey=query. One key per install, generated on first boot and stored in thesecuritysettings section. It grants admin access. - Local bypass: once setup is complete and an admin exists, requests where the socket peer and every
X-Forwarded-Forhop are loopback, RFC 1918, IPv4 link-local, IPv6 ULA or link-local skip sign-in when the method isnoneorrequireForLocalis false. Public addresses always need credentials, sononenever 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 methodapp.get('/series', { config: { access: { areas: ['library'] } } }, h); // read-only, users granted the Library areaapp.get('/rootfolder', { config: { access: { areas: ['library'], permissions: ADVANCED_REQUESTERS } } }, h);app.get('/user/:id/quota', { config: { access: { portal: true } }, preHandler: requireSelfOrAdmin }, h); // own account onlyapp.get('/thing', { preHandler: requireAdmin }, handler); // belt and braces for admin-onlyportal 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 |
system
Section titled “system”| 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 |
health
Section titled “health”| 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) |
events
Section titled “events”| Method | Path | Purpose |
|---|---|---|
| GET (WebSocket) | /events |
Live feed: hello on connect, then every event bus message |
settings
Section titled “settings”| 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 |
theme (theme packs)
Section titled “theme (theme packs)”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.
metadata
Section titled “metadata”| 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.
library
Section titled “library”| 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_tagrecords the slot a file fills; one file per (movie, tag) and per (episode, tag) (series links live inepisode_version_files). An untagged file on a versioned item belongs to the first configured slot.Episode/Movieexposefiles[]({id, versionTag, quality, sizeBytes, relativePath}, primary first);hasFileis true when any version exists;episodeFileId/movieFileIdpoint 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, withwantedVersions: 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.versionTagsays which. One grab per slot. - Imports: a file goes into the best-fitting slot and replaces only that slot’s file
(
ManualImportRequestItem.versionTagpins it). Naming:{Version}token; settingnaming.versionLayout:sideBySide(default, appends- <tag>when the format has no{Version}) orsubfolder(<folder>/<tag>/<file>). - Requests: the 4K track maps to slot
4K(an item already in the library keeps its copy as slotStandard); a file in slot4Kmakes 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.
audiobooks
Section titled “audiobooks”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=®ion= → 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).
Manual import, audio mode
Section titled “Manual import, audio mode”| 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.
Audiobook import
Section titled “Audiobook import”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.
Manual import, audiobook mode
Section titled “Manual import, audiobook mode”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 /bookwith anauthorblock (monitorNewBooks: none, no search): a new author’s other books arrive unmonitored throughAuthorAdd, and the author’s folder becomes the item’s nearest ancestor named like the author (a flatAuthor - Titlelayout keeps<root>/<Author>). - Music: an album with that release group is used; otherwise the credited artist is added like
POST /artistwithmonitorOption: none,monitorNewAlbums: noneand 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/audiowithimportMode: 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).
profiles
Section titled “profiles”| 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.
profile-migration
Section titled “profile-migration”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.
guides
Section titled “guides”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 |
indexers
Section titled “indexers”| 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.
Definition catalogue
Section titled “Definition catalogue”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) |
Indexer catalogue (add-indexer chooser)
Section titled “Indexer catalogue (add-indexer chooser)”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).
Community catalogue
Section titled “Community catalogue”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 |
Definition feed
Section titled “Definition feed”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 |
Indexer proxies
Section titled “Indexer proxies”| 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?}) |
download-clients
Section titled “download-clients”| 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.
requests
Section titled “requests”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).
Requests
Section titled “Requests”| 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 |
Approval policies
Section titled “Approval policies”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 lastperiodDaysplus 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 onlyuserIds);role: per role (optionally onlyrole);all: shared. Any applicable budget with room approves.approvalWindows[] {days 0-6, startHour, endHour (exclusive; <= start wraps midnight), action}in server local time. Inside anautoApprovewindow 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 aholdwindow 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 throughPUT /user/:id/permissions):autoApproveinherit/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.
Content caps (kids accounts)
Section titled “Content caps (kids accounts)”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.
Request type filter
Section titled “Request type filter”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.
Public request portal
Section titled “Public request portal”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) |
Discover sliders
Section titled “Discover sliders”| 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 |
Issues
Section titled “Issues”| 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 |
Override rules (MANAGE_SETTINGS)
Section titled “Override rules (MANAGE_SETTINGS)”| Method | Path | Purpose |
|---|---|---|
| GET | /overriderule |
All rules |
| POST | /overriderule |
OverrideRuleInput → OverrideRule |
| PUT | /overriderule/:id |
Replace a rule |
| DELETE | /overriderule/:id |
Delete |
Watchlist
Section titled “Watchlist”| 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.
Users, quotas and settings
Section titled “Users, quotas and settings”| 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”).
Sitewide request notifications
Section titled “Sitewide request notifications”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.
Subtitle and language requests
Section titled “Subtitle and language requests”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.
Music and audiobook requests
Section titled “Music and audiobook requests”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®ion → 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.
media-servers
Section titled “media-servers”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, …).
retention
Section titled “retention”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 flaggedusedForRetention.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;addExclusionadds 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) |
tag-rules (tag management and tag rules)
Section titled “tag-rules (tag management and tag rules)”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}:opanyallnonepresentabsent, 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}.
subtitles
Section titled “subtitles”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 |
releases (decision log)
Section titled “releases (decision log)”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.bookPackNotAllowedandpackTooFewWanted: 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 theaudiobookMetadata.languagesdefault) 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.
scene-mapping
Section titled “scene-mapping”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.
history
Section titled “history”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 |
blocklist
Section titled “blocklist”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 |
calendar
Section titled “calendar”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 |
media-analysis
Section titled “media-analysis”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 |
transcode
Section titled “transcode”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).useWhisperruns a second pass with thewhisperaiprovider (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 (languageReplacementLowerQualityotherwise). Files lacking a required language fail the cutoff check, soCutoffUnmetSearchkeeps 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 withaudioLanguages: [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 |
library-hygiene
Section titled “library-hygiene”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) orcontent(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} |
Library health scan
Section titled “Library health scan”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 |
bulk (library and Wanted bulk bar)
Section titled “bulk (library and Wanted bulk bar)”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) |
mobile (companion app)
Section titled “mobile (companion app)”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 |
search (universal search)
Section titled “search (universal search)”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.
summary (daily summary, quiet hours)
Section titled “summary (daily summary, quiet hours)”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.
config (config as code)
Section titled “config (config as code)”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[]} |
migrate (import your existing stack)
Section titled “migrate (import your existing stack)”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) |
metrics
Section titled “metrics”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) |
transfer-metrics (transfer history)
Section titled “transfer-metrics (transfer history)”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 |
discord-bot
Section titled “discord-bot”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.
torrent (built-in engine)
Section titled “torrent (built-in engine)”| 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.
tracker-stats (private trackers)
Section titled “tracker-stats (private trackers)”| 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.
cross-seed
Section titled “cross-seed”| 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).
Scheduler jobs
Section titled “Scheduler jobs”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.
WebSocket events
Section titled “WebSocket events”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.
Compatibility API (Sonarr / Radarr v3)
Section titled “Compatibility API (Sonarr / Radarr v3)”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. calendarandwanted/missingare episode-only (Sonarr shape); Radarr’s movie calendar is not served on the shared path.- Lookups are TMDB-backed:
tvdbIdis 0 unless TMDB knows it;tvdb:lookups go through TMDB’s external-id search. TV Rage / TV Maze ids are always 0;airTimeis 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/filmmeans movies). POST /indexer/testvalidates 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;
languageprofileis a single fixed “Any” profile for Sonarr v3 clients.