Configuration & API Reference
Every Media Preview Generator setting, environment variable, path mapping and REST API endpoint, with defaults, request and response examples.
On this page
Important
This page is the source of truth for configuration precedence, settings, and REST API behavior. For installation and setup flows, use Getting Started. For operations, webhooks, and troubleshooting, use Guides & Troubleshooting.
Contents
- Configuration Priority
- Media Servers
- Processing Options
- Nodes
- Environment Variables
- Web Interface Settings
- Webhook Settings
- Intro & Credits
- Plex loudness
- Path Mappings
- REST API
- WebSocket Events
- Rate Limiting
Related Docs
Configuration Priority
settings.json (at /config/settings.json) is the sole source of truth for application configuration. On first start, environment variables are migrated into settings.json as seed values. After that, all configuration is managed via the Web UI (Setup Wizard and Settings page).
Infrastructure environment variables remain active and are not migrated (see Infrastructure Variables).
Media Servers
Every server the app talks to (any number of Plex, Emby and Jellyfin entries)
is stored as an array under media_servers in settings.json. Manage it from
the Servers page or the REST API below. Each entry looks like this:
{
"id": "plex-household",
"type": "plex",
"name": "Household Plex",
"url": "http://192.168.1.100:32400",
"enabled": true,
"auth": {
"method": "token",
"token": "..."
},
"verify_ssl": true,
"timeout": 30,
"libraries": [
{"id": "1", "name": "Movies", "remote_paths": ["/data/movies"], "enabled": true, "kind": "movie"},
{"id": "2", "name": "TV Shows", "remote_paths": ["/data/tv"], "enabled": true, "kind": "show"}
],
"path_mappings": [
{"remote_prefix": "/data", "local_prefix": "/media", "webhook_prefixes": []}
],
"exclude_paths": [],
"output": {"plex_config_folder": "/plex"},
"server_identity": "…",
"markers": {"enabled": false, "library_ids": null},
"loudness": {"enabled": false, "library_ids": null}
}
| Key | Notes |
|---|---|
type |
plex, emby or jellyfin. |
verify_ssl |
Default true. |
timeout |
Request timeout in seconds. Default 30. |
libraries[].enabled |
The Previews switch for that library on the Libraries tab. |
exclude_paths |
Per-server rules, each {"value": "...", "type": "path"\|"regex"}. See Exclude Paths. |
server_identity |
Plex machineIdentifier or Emby/Jellyfin ServerId, saved when a connection test succeeds. Used to route webhooks when you have several servers of one type. |
markers |
Intro & Credits block. See Per-server settings. |
loudness |
Plex loudness block. See Plex loudness. Plex only. |
health_dismissals |
Setup Health check ids you dismissed. Managed through the previews-readiness/dismiss routes, not the server PUT. |
output holds where and how previews are written:
| Key | Servers | Default | Notes |
|---|---|---|---|
plex_config_folder |
Plex | none | Plex config folder as this container sees it. Must be an absolute path that exists, or the save is refused. |
chapter_thumbnails |
Plex | false |
See below. |
frame_interval |
all | the global thumbnail_interval |
Kept in step with the global setting when it changes. |
width |
Emby, Jellyfin | 320 |
Preview image width in pixels. |
adapter |
all | by server type | plex_bundle, emby_sidecar or jellyfin_trickplay. |
save_with_media |
Jellyfin | true |
false writes trickplay into Jellyfin's config folder instead of next to the video. |
jellyfin_config_folder |
Jellyfin | none | Jellyfin's config folder as this container sees it. Used when save_with_media is false. |
webhook_public_url |
Plex | none | URL Plex posts to for the direct Plex webhook. Set by the register call. |
Per-vendor notes:
| Vendor | auth.method values |
Extra fields |
|---|---|---|
| Plex | token (OAuth is how you get it; the result is stored as auth.token) |
output.plex_config_folder (where BIF bundles are written) |
| Emby | password, api_key |
auth.user_id, auth.access_token |
| Jellyfin | password, quick_connect, api_key |
auth.user_id, auth.access_token |
Plex's output.chapter_thumbnails setting is a JSON boolean, default false.
The API rejects strings such as "false" and numeric values. The Chapter thumbnails section in
Servers → Edit → Processing controls it. It adds chapter images to existing Previews jobs and updates existing
chapter image references in Plex's database. It requires a supported local Plex database, or a Plex database node beside
a Plex on another machine; Intro & Credits can stay off. Currently supported: Plex Media Server 1.43.4.x or 1.43.5.x.
See the chapter thumbnail guide for setup and retry behavior.
Runtime state, not persisted. Whether Jellyfin's Media Preview Bridge plugin is installed is checked live and returned in the
/previews-readinesspayload. It isn't stored on the server entry.
Legacy flat keys. Top-level plex_url, plex_token, plex_config_folder, selected_libraries,
path_mappings and exclude_paths in settings.json are moved into the first Plex entry of
media_servers[] on first start. GET /api/settings still returns them, projected from that entry.
Write server settings through /api/servers.
Tip
Use the Setup Wizard to sign in. Plex OAuth, Jellyfin Quick Connect and Emby username/password exchange all happen there without pasting tokens by hand. The wizard configures one server. Add more from the Servers page.
Processing Options
Worker groups (worker_groups)
Named groups are the only worker allocation. Edit them in Settings → Workers or the setup wizard. The dashboard's Manage groups button opens the same page. See the worker guide for scheduling and scaling behavior.
A group is a name, an on/off switch and weekly hours, holding up to 8 devices. Each device is a member with its own worker count and allowed jobs.
| Group field | Rules |
|---|---|
id |
Unique. Letters, digits, _ and -; up to 80 characters; starts with a letter or digit. |
name |
1–80 characters. |
enabled |
Boolean. A disabled group keeps its saved counts but provides no capacity. |
availability |
{"mode":"always","windows":[]} or {"mode":"scheduled","windows":[...]}. A scheduled group needs at least one window. |
availability.windows[] |
days (0 = Monday through 6 = Sunday, the day the window starts), start and end (HH:MM, different from each other). Up to 32 windows per group. Times use the app timezone. Overnight windows are allowed. |
members |
1–8 devices. One member per device: one CPU member and one per GPU. |
| Member field | Rules |
|---|---|
id |
Unique within the group. Same character rules as the group id. |
resource |
cpu or gpu. |
device |
GPU device identifier for a GPU member. null for CPU. |
count |
Integer 1–64. Remove the member to use zero. |
job_types |
At least one of previews, intro_credits, loudness. GPU members cannot take loudness. |
node |
The id of a node to run this member's workers on that machine's CPU or GPU. Left out for this app's own devices. One member per device on each machine. |
On a group with exactly one member, GET also echoes that member's resource, device, count and job_types at the
group level (read-only, kept for older clients). PUT accepts a group without members that carries those four
fields and treats it as one member. When members is present, a group-level field that disagrees with the only member
is rejected with 400.
Up to 64 groups. An empty list is valid and means no workers. Overlapping windows in one group form a union; different groups and devices add their counts. A save is refused when the weekly peak would exceed 64 CPU or 64 GPU workers on one machine (this app, or any one node). These are worker slots, not CPU cores.
Nodes
Other machines that run work for this app, added on the Nodes page (overview: Remote nodes;
details: remote nodes guide). A worker node runs the files sent to it; a Plex database node opens
Plex's database for a Plex on another machine or on Windows. Stored in settings.json["nodes"], a list of:
| Field | Rules |
|---|---|
id |
Made by the app. Worker group members (node) and Plex servers (markers.plex.agent.node_id) use it. A record written into the file without one gets one when the app starts. |
node_uid |
The id the machine reported last; recorded by the app, never posted. Each request to a Plex database node names it, so another machine answering at that address refuses the request and writes nothing. |
name |
1–80 characters, no line breaks, tabs or hidden characters. Unique: names that look the same (Café, CAFÉ) count as one. |
url |
http or https address, e.g. http://nuc.lan:9494. A trailing / is dropped and the scheme and host are lower-cased. 400 for a query string, fragment, username or password, or a host name that can't be used. |
key |
The node's key: its NODE_KEY, or the key it printed in its log on first start. Up to 200 printable ASCII characters, no spaces. Never returned or logged; posting **** (or leaving it out) keeps the stored key. |
enabled |
Boolean. A node turned off is never checked and gets no work. |
path_mappings |
Up to 50 rows of {"app_prefix", "node_prefix"}: a folder as this app sees it (starts with /) and the same folder as the node sees it (a Windows path such as D:\Media is fine). |
added_at |
When it was added. |
nodes_revision goes up by one on every change. A node that a worker group member or a Plex server uses can't be
removed (409, naming what uses it).
A node's own settings
Environment variables of the node's container (the app's image started with MODE=node, or the
Plex database node image). See the remote nodes guide.
| Variable | Default | What it does |
|---|---|---|
MODE |
node starts the app's image as a node instead of the app. |
|
NODE_NAME |
the hostname | The name the node reports; Add node suggests it. |
NODE_ROLES |
worker |
worker, plex_db, or both: worker,plex_db. |
NODE_KEY |
made on first start | The key the app sends: 16 to 200 printable ASCII characters, no spaces. Without it the node makes one, keeps it in /config/node.json and prints it once in its log. |
NODE_HOST |
0.0.0.0 |
The address the node listens on inside the container. :: listens on IPv6 only. |
NODE_PORT |
9494 |
The port the node listens on inside the container; change the published port (-p) to match. |
NODE_TEMP_DIR |
/tmp/mpg-node |
Where a worker node makes a folder for each file it is sent. Point it at a bigger or faster disk, or at a volume in a read-only container. |
FILE__<NAME> |
Sets <NAME> to the contents of the file it names (a Docker secret), e.g. FILE__NODE_KEY=/run/secrets/node_key. |
|
PUID / PGID |
1000 |
The user the node runs as: one that can read your media and, for the Plex database role, owns Plex's database. The Plex database image reads them only through its compose file; with docker run, use --user <uid>:<gid>. |
PLEX_CONFIG_DIR |
Plex database role: Plex's config folder as the container sees it. The database path is derived from it; the app never sends a path. | |
CONFIG_DIR |
/config |
The node's own folder: its id, its key and the loudness undo record. Mount it so they survive an update. |
LOG_LEVEL |
INFO |
How much the node logs; DEBUG for more. |
Per-GPU tuning (gpu_config)
Settings → Processing → GPU device tuning sets FFmpeg threads per worker for each detected GPU. Worker
counts belong to groups. Each entry in gpu_config has:
| Field | Type | Description |
|---|---|---|
device |
string | GPU device identifier (e.g. /dev/dri/renderD128) |
name |
string | Display name (e.g. "Intel UHD Graphics 630") |
type |
string | nvidia, intel, amd, apple |
ffmpeg_threads |
int | CPU threads each GPU worker's FFmpeg may use (0–32, 0 = no cap). Default 2. |
enabled, workers |
Legacy fields kept for compatibility. Groups control allocation. |
Other Processing Settings
These are the top-level keys POST /api/settings accepts, besides frame_reuse, markers and the webhook keys described below. Other keys are ignored. The legacy Plex fields (plex_url, plex_token, plex_name, plex_verify_ssl, plex_config_folder, media_path, plex_videos_path_mapping, plex_local_videos_path_mapping, path_mappings) are written into the first Plex server instead.
| Setting | Settings page | Default | Description |
|---|---|---|---|
cpu_threads |
none | 1 |
Legacy CPU count. Once groups exist, posting it returns 409; use the worker-groups API. |
incoming_job_priority |
Processing → Job execution → Incoming job priority | high |
Priority of jobs the app creates itself from webhooks and Recently Added scans. high, normal, low (or 1, 2, 3). |
loudness_nice_level |
Processing → Job execution → Loudness analysis nice level | 10 |
How far loudness analysis FFmpeg yields the CPU to previews and Intro & Credits detection when the CPU is busy. 0–19; 0 runs it at the same priority. Read per file, so a change applies to the next file analysed, on remote worker nodes too. |
webhook_retry_count |
Processing → Retry policy → Retry count | 5 |
Attempts for files a server hasn't indexed yet. The page allows 0–10; 0 turns retries off. |
webhook_retry_delay |
Processing → Retry policy → Initial retry delay | 30 |
Base delay in seconds (the page allows 10–300). At 30, retries wait 1 min, 2 min, 5 min, 15 min, then 1 h. |
auto_requeue_on_restart |
Processing → Restart recovery | true |
Re-queue jobs a restart interrupted. |
requeue_max_age_minutes |
Processing → Restart recovery → Max requeue age | 720 |
Interrupted jobs older than this are marked failed instead. The page allows 5–1440. |
scan_workers |
Processing → Library scanning → Files checked at once | 0 (Auto) |
Full scans only: how many files are checked in parallel for an existing preview. Checking is light disk I/O and adds no FFmpeg or GPU load. 0 = Auto (max(32, workers)). Otherwise 1–256. Lower it on a single spinning disk. |
thumbnail_interval |
Processing → Thumbnails | 10 |
Seconds between preview images. The page allows 1–60. |
thumbnail_quality |
Processing → Thumbnails | 4 |
Preview quality 1–10, lower = better quality (2 = highest) |
tonemap_algorithm |
Processing → Thumbnails → HDR tone mapping | hable |
hable, reinhard, mobius, clip, gamma or linear. |
selected_libraries |
Servers → Edit → Libraries | all | Legacy Plex library IDs. The per-server libraries[].enabled switches are the source of truth. |
log_level |
Logging | INFO |
DEBUG, INFO, WARNING, ERROR or CRITICAL. |
log_rotation_size |
Logging → Rotation size | 10 MB |
Size at which the log file rotates. |
log_retention_count |
Logging → Retention | 5 |
Rotated log files kept. |
job_history_days |
Logging → Job history → Keep history for | 30 |
Days finished jobs are kept. |
config_backup_keep |
Backups → Keep last N backups per file | 10 |
Snapshots kept per config file. 1–100. |
config_backup_max_age_days |
Backups → Drop backups older than | 0 |
Drop snapshots older than this many days. 0 turns age pruning off. 0–365. |
exclude_paths |
Servers → Edit → Exclude paths | none | See Exclude Paths. |
sort_by is set per run, not globally: Start new job and schedules choose it. newest and oldest order by the date
added to the library (on Plex, Emby and Jellyfin). default keeps server order. random shuffles the combined selected
libraries and servers. Parallel checks and workers can start or finish out of order. A job or schedule with no value
uses newest.
Per-job media filters
Start new job → Filter media and scheduled Whole library runs can limit
the media selected for a run, for Previews, Intro & Credits and Plex loudness alike. All filters are optional and default to unrestricted.
They are stored in the job or schedule's config, not in global settings.
- Shared — Added to library:
added_filterisall(default),last_days, ordate_range.last_daysrequires a positive integeradded_last_days; its rolling window is calculated once when the job starts.date_rangerequiresadded_fromandadded_toasYYYY-MM-DD. Both calendar dates are included, using the app's timezone (TZor the container's local timezone). - TV shows — Most recent available seasons:
latest_seasonsis a positive integer, ornull/omitted for all seasons. It counts distinct, positive season numbers available for each show in each selected server library. For example, the most recent two of seasons 1, 3, and 5 are seasons 3 and 5. Specials (season 0) are excluded when this filter is active. Movies are unaffected. - Movies — Release year:
movie_year_fromandmovie_year_toare inclusive integer bounds from 1 to 9999. Either bound may be omitted ornull; both omitted means all years. Episodes are unaffected.
The added date is the media server's catalog date (addedAt on Plex,
DateCreated on Emby/Jellyfin), not the release date or file modification time.
Applicable filters combine with AND. The latest available seasons are
determined before applying added-date filters or checking existing previews;
older seasons are never substituted when the latest ones have no matches.
All versions of qualifying media remain eligible.
Items missing metadata required by an active filter are excluded; the scan logs aggregate exclusion counts. Library enumeration is still needed to select the latest seasons, even though excluded files are not queued for generation. Changing library selection in the UI clears filters for media types no longer selected. Clear restores unrestricted selection.
These options apply only to whole-library jobs. Process a file or folder file/folder
jobs, webhooks and Recently Added schedules keep their own selection rules. POST /api/markers/jobs and
POST /api/loudness/jobs take the same keys in an optional scan_filters object. Relative windows on scheduled
runs are recalculated for each execution; a saved date range stays fixed. sort_by applies to Previews and
loudness (POST /api/loudness/jobs takes it too); Intro & Credits keeps each season together.
Frame Reuse Cache (frame_reuse)
When the same canonical file fires multiple webhooks within the cache TTL (e.g. Sonarr fires immediately, Plex's library.new follows 30 min later), this cache reuses the FFmpeg-extracted frames across siblings instead of re-running FFmpeg. The global frame_reuse setting controls it, under Settings → Processing → Smart caching across servers:
| Field | Default | Description |
|---|---|---|
enabled |
true |
Master toggle for cross-server frame reuse |
ttl_minutes |
60 |
How long to keep extracted frames in the cache. Minimum 1. |
max_cache_disk_mb |
2048 |
Disk cap for the cache in MB (oldest entries evicted first). Minimum 64. |
Frames are reused only when they were extracted with the current thumbnail interval, quality and HDR tone-mapping settings. After you change one, the next job extracts fresh frames. Previews already on disk still need Regenerate.
Tip
Multi-disk libraries (unraid shfs, mergerfs, JBOD): pick Random as the Processing Order in Start new job or on a scheduled full-library scan. With alphabetical order, parallel workers tend to read sequential files from the same physical disk; shuffling spreads reads across disks so disk I/O stops being the bottleneck. Webhook jobs and Recently Added scans are unaffected — they touch too few files for ordering to matter.
Note
When a GPU worker can't process a file (unsupported codec, hardware-accelerator error, driver crash), the file goes to another GPU that hasn't tried it. Once no GPU is left, the last worker retries on CPU in-place and the UI shows a warning badge with the reason. There is no separate fallback pool.
Environment Variables
Infrastructure Variables (always active)
These are not migrated to settings.json and remain in effect:
| Variable | Default | Description |
|---|---|---|
CONFIG_DIR |
/config |
Directory for settings.json, auth, schedules |
WEB_PORT |
8080 |
Web server port |
PUID |
1000 |
User ID (Unraid: 99) |
PGID |
1000 |
Group ID (Unraid: 100) |
TZ |
Host | Timezone (e.g. America/New_York) |
CORS_ORIGINS |
* |
Allowed CORS origins (comma-separated) |
HTTPS |
false |
Mark session cookies secure. Set to true when the app is served over HTTPS. |
DEV_RELOAD |
false |
Start gunicorn with live reload (development) |
WEB_AUTH_TOKEN |
Auto-generated | Fixed authentication token (overrides wizard-set token) |
AUTH_METHOD |
internal |
Set to external to disable built-in auth when using a reverse proxy or VPN (see below) |
FLASK_SECRET_KEY |
Auto-generated | Override the Flask session signing key. Auto-generated and persisted to /config/flask_secret.key if not set. Set this only when you need a fixed key across rebuilds. |
LOG_FORMAT |
pretty |
Log output format. json writes one JSON object per log line, for log shippers such as Loki or Datadog. |
PLEX_DATA_ROOT |
/ |
Restricts where Plex config folders can be validated. Defaults to the whole filesystem; set it to e.g. /plex to refuse anything outside that root. |
MEDIA_ROOT |
/ |
Same as PLEX_DATA_ROOT but for media paths (manual jobs, the inspector, the file picker). |
RATELIMIT_STORAGE_URL |
memory:// |
Backend for rate-limit counters. The default in-memory store is fine for a single container. |
REQUESTS_CA_BUNDLE |
Built-in list | A certificate file the app trusts for https instead of its built-in list, e.g. for a node behind https with your own certificate. Keep the public authorities in it: it applies to every https connection. |
CONFIG_BACKUP_KEEP |
10 |
Snapshots kept per config file. Used only until you set it in Settings → Backups. 1–100. |
CONFIG_BACKUP_MAX_AGE_DAYS |
0 |
Drop snapshots older than this many days (0 = off). Used only until you set it in Settings → Backups. 0–365. |
Developer / harness-only
Not for deployment. These override paths used in development and the accuracy harness, and are never migrated into
settings.json:
| Variable | Default | Description |
|---|---|---|
MEDIA_PREVIEW_TEXTDET_MODEL |
the Docker image's own copy (/app/models/ch_PP-OCRv4_det_infer.onnx) |
Overrides the on-screen credit text model path, for development and the tools/markers_eval credits-text harness. |
MEDIA_PREVIEW_TEXTREC_MODEL |
the Docker image's own copy (/app/models/latin_PP-OCRv5_rec_mobile.onnx) |
Overrides the path of the model that reads the card the credits start on (epilogue text), for development and the same harness. |
External Authentication (AUTH_METHOD)
If you secure access via a reverse proxy (Authelia, Authentik, Caddy Security, nginx basic auth, etc.) or a VPN (Tailscale, WireGuard), you can disable the built-in login screen:
environment:
- AUTH_METHOD=external
When set to external:
- The login page is bypassed; all browser and API requests are treated as authenticated.
- Webhook authentication (
webhook_secret/ Bearer token) is not affected: Radarr, Sonarr and other callers still need the shared secret. - A browser's
POST/PUT/PATCH/DELETEstill needs the page's CSRF token (the app's pages send it). Scripts should send the API token (X-Auth-TokenorAuthorization: Bearer); see REST API → Authentication. - The setup wizard still runs on first boot.
- Removing the variable (or setting it back to
internal) instantly re-enables built-in auth.
Caution
Only use AUTH_METHOD=external when you are certain that network-level access control is in place. Without it, anyone who can reach the web UI has full access.
Deprecated (no longer used)
These variables log a warning at startup and are not used as settings. Configure them in the web UI instead:
| Variable | Replacement |
|---|---|
GPU_SELECTION |
Worker groups (Settings → Workers). On first start it seeds the saved GPU list only. |
GPU_THREADS |
Worker groups. On first start it seeds the initial allocation only; it never overrides saved groups. |
FFMPEG_THREADS |
Per-GPU ffmpeg_threads in Settings → Processing → GPU device tuning. On first start it seeds the saved value only. |
PLEX_LIBRARIES |
Per-server library switches (Servers → Edit → Libraries). On first start it seeds the selection only. |
REGENERATE_THUMBNAILS |
Tick Regenerate when starting a job. |
SORT_BY |
Pick Processing Order when starting a job. |
NICE_LEVEL |
Removed. Loudness analysis has its own nice level under Settings → Processing options. |
FALLBACK_CPU_THREADS |
Removed. A GPU worker retries on the CPU in place. |
One-time seed values (migrated on first start)
On first run, these variables are copied into settings.json. After that, settings.json is the source of truth:
PLEX_URL,PLEX_TOKEN,PLEX_CONFIG_FOLDER,PLEX_VERIFY_SSL,PLEX_TIMEOUTPLEX_BIF_FRAME_INTERVAL/THUMBNAIL_INTERVAL(alias),THUMBNAIL_QUALITY,TONEMAP_ALGORITHM,CPU_THREADSMEDIA_PATH,TMP_FOLDER,LOG_LEVELPLEX_VIDEOS_PATH_MAPPING,PLEX_LOCAL_VIDEOS_PATH_MAPPING(see Path Mappings)
Web Interface Settings
The web UI is served by gunicorn with thread workers. Docker starts it, so you only need this outside Docker. The port and related settings (WEB_PORT, CORS_ORIGINS, HTTPS, DEV_RELOAD) are in Infrastructure Variables.
Webhook Settings
Settings for automatic preview generation when media is imported or added. Manage them on the Automation page (Triggers tab).
| Setting | Default | Web UI | Description |
|---|---|---|---|
webhook_enabled |
true |
Yes | Master enable/disable for webhook processing |
webhook_delay |
60 |
Yes | Default initial delay for all webhook ingestion URLs (UI slider: 10–300; runtime bounds: 1–3600). Universal/per-server routes wait per job; source-specific routes reset their batch timer on new files, capped by a maximum batch age of the greater of 10 minutes and its longest accepted delay. An optional delay URL parameter overrides this value for that request; see Webhook delay parameter. |
webhook_secret |
(empty) | Yes | Dedicated secret for webhook auth (falls back to API token) |
webhook_retry_count |
5 |
Processing → Retry policy | Retry attempts for files a server hasn't indexed yet. See Other Processing Settings. |
webhook_retry_delay |
30 |
Processing → Retry policy | Base retry delay in seconds. |
The Plex direct webhook (/api/webhooks/plex, needs Plex Pass on the server-owner account) is set up per Plex server. Its public URL is stored in that server's output.webhook_public_url and is set by POST /api/settings/plex_webhook/register.
Webhook processing respects each server's enabled libraries. Paths outside them are ignored.
The Recently Added scanner is a schedule type, not a settings key (see Schedules Endpoints). Create one on the Automation page (Schedules tab) with Files → Recently added.
Important
The Plex direct webhook and Recently Added schedules trigger only on new library items (new ratingKeys). They do not detect in-place file upgrades — Plex keeps the same item when Sonarr/Radarr replaces a file. Use the existing Sonarr/Radarr webhooks (which fire on On Upgrade) for that case.
Tip
Configure webhooks on the Automation page (/automation, Triggers tab) in the web UI. See Webhook Integration for setup instructions. The legacy /webhooks and /schedules URLs still work — they 302-redirect to the relevant tab.
Intro & Credits
Skip Intro / Skip Credits markers for Plex, Jellyfin and Emby. See the Intro & Credits guide for setup and troubleshooting; this section is the settings/API reference. The feature is off everywhere by default.
Global settings (settings.json["markers"])
Shared detection settings — one file is detected once, whatever the publish rule decides. Managed from Settings → Intro & Credits.
{
"detect": {"intro": true, "credits": true, "recap": false},
"credits_window": {"tv_s": null, "movie_s": null},
"sources": [
{"id": "chapters", "enabled": true},
{"id": "theintrodb", "enabled": false, "api_key": ""},
{"id": "introdb", "enabled": true},
{"id": "skipdb", "enabled": true},
{"id": "season_audio", "enabled": true},
{"id": "credits_text", "enabled": true},
{"id": "server_markers", "enabled": true}
]
}
| Key | Type | Default | Notes |
|---|---|---|---|
detect.intro |
bool | true |
TV episodes only. |
detect.credits |
bool | true |
TV episodes and movies. |
detect.recap |
bool | false |
Jellyfin's player is the only one with a Skip Recap button. |
credits_window |
object | {"tv_s": null, "movie_s": null} |
Settings → Intro & Credits → Advanced → "Where to look for credits". How far from the end of a file on-screen credit text is searched for; credits already rolling where it begins are followed back 120 s at a time, only as far as a start the last-quarter rule and the movie cap would still keep. tv_s applies to TV episodes, movie_s to movies and files of unknown kind. Each is null (Automatic: last 450 s of an episode, 900 s of a movie) or one of 300, 600, 900, 1200, 1800 seconds; anything else is refused with a 400 naming the key. A key left out means Automatic, and a partial post merges over the stored value. A longer window decodes longer for every file. A window you choose also lets credits start that far before the end even where the last-quarter-of-the-file rule would refuse them, and a movie window above 15 minutes raises the 900 s cap on how far before the end a movie's credits may start. Automatic keeps both rules exactly as before. Changing it decides files again, and files already read on another window are read again. A stored value that isn't valid is treated as Automatic (logged). |
sources |
array | see above | Evidence sources, in checking/precedence order. Reordering in the UI reorders this array. |
sources[].id |
one of chapters, theintrodb, introdb, skipdb, season_audio, credits_text, server_markers |
— | credits_text runs where text detection is available (see GET /api/markers/sources/local); it decides credits alone. season_audio runs where ffmpeg has chromaprint (see GET /api/markers/sources/local); it decides an intro alone when nothing else answers, and confirms one another source found. Its previous-season hint is stored as season_audio_previous evidence (not a settings id). |
sources[].enabled |
bool | varies | theintrodb defaults to false (used without the vendor's written permission); the rest default to true. |
sources[].api_key |
string | "" |
theintrodb only. Optional. GET/POST /api/settings mask a set key as ****; posting **** back keeps the stored key unchanged. Never logged. |
There is no publish rule setting. Every file is decided the same way: chapters decide alone unless two other
independent sources agree on something different; any other source needs an independent source to agree, except that
on-screen credit text (credits) and season audio (intros) may decide alone. IntroDB, TheIntroDB, SkipDB, the
previous-season hint (season_audio_previous) and markers already on servers never decide alone, and season audio (or season_audio_previous) with markers already on servers isn't an agreeing
pair on its own. An agreeing server marker doesn't hold season audio back (it decides as if alone, credited to
season_audio only); the hint with only a server's marker decides nothing. An old publish_when key in a saved or posted block is ignored.
An intro season audio decided alone keeps asking the
online sources on their schedule.
After an update that raises a detector's or reader's version (credit text, season audio, server markers, chapters,
online parsers), the next manual or scheduled Find markers run refreshes stale answers as needed and applies the
current decision rules. Startup creates no upgrade jobs and completion creates no further upgrade batches. Each
run that decides a file records its rules version (decide_rules in version_reruns). A marker that changed rules
alone would leave out stays while a server has it and no new or changed answer disagrees, with reason
"kept: published before a rule change". Legacy queued jobs keep their saved batch (version_rerun_files) for restart
recovery, but do not schedule another batch. Use a user-created Find markers cron schedule for recurring checks.
Per-server settings (media_servers[].markers)
Whether — and where — a server actually receives markers. Managed from Servers → (server) → Edit → Processing → Intro &
Credits tab; library_ids from the Libraries tab's Intro & Credits column (shown while enabled is on), which
is separate from the Previews column's libraries[].enabled.
{
"enabled": false,
"library_ids": null,
"plex": {
"db_write_confirmed_at": null,
"on_plex_redetect": "restore",
"agent": {"enabled": false, "node_id": null}
}
}
An Emby server's block has "emby": {"on_emby_redetect": "restore"} in place of plex.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | false |
Off until turned on for this server. On a Plex server, setting this true requires plex.db_write_confirmed_at to already be set (or included in the same request) — 400 otherwise. |
library_ids |
array of strings | null |
null |
null = every library except sports-type ones (name matched, whole word "sport"/"sports" — no vendor exposes an actual sports library kind). An explicit list is taken literally, including a deliberate sports library. |
plex |
object | (Plex servers only) | Absent on Emby/Jellyfin entries. |
emby |
object | (Emby servers only) | Absent on Plex/Jellyfin entries. |
emby.on_emby_redetect |
"restore" | "keep_emby" |
"restore" |
Edit → Processing → Intro & Credits "When Emby has its own markers": restore is "Use ours", keep_emby is "Keep Emby's". Markers from Emby's own intro detection (Emby Premiere) or another plugin: restore has the Media Preview Bridge for Emby plugin replace them with ours (ReplaceOwn); keep_emby leaves a type Emby has markers of and shows ours only for the other types (row message e.g. "1 marker(s); keeping Emby's intro"). The plugin still stores ours for a kept type and shows them once Emby's are gone; the next job that checks the file records them as ours again (or writes them, when it finds none of Emby's left). Remembered per server item in markers.db. A marker you adjust or lock in the Inspector overrides this setting for its type: it is sent with ReplaceOwn anyway and the row says "Replaced Emby's own marker…". |
plex.db_write_confirmed_at |
ISO-8601 timestamp | null |
null |
Set once the one-time "Send intro & credits markers to Plex?" confirmation is accepted. Clearing it while enabled stays true in the same request is rejected (400) — send enabled: false in the same PUT to revoke. |
plex.on_plex_redetect |
"restore" | "keep_plex" |
"restore" |
Edit → Processing → Intro & Credits "When Plex has its own markers": restore is "Use ours", keep_plex is "Keep Plex's". What a job does when Plex shows markers of a decided type that aren't ours: restore writes ours over them; keep_plex keeps Plex's markers of that type on every later run (forced ones included) until the setting is switched to restore or Plex has none of that type left (row message e.g. "Keeping Plex's credits", or "1 marker(s); keeping Plex's credits"). Under keep_plex, "not ours" means not what the job would write and not what the item record says this app left there, so markers on an item with no record of that type (a first publish, a reset markers.db, a re-added server) are kept too. Decided per type, and remembered per server item in markers.db. Markers that are gone are written again either way. A type whose every Plex marker can't be right for the file (it starts or ends after the file does, or has no length) is never kept: the file is read for that type and ours are written. A type whose Plex markers were made for an earlier file at the path (no pv: record of them on the file Plex has now, and older than its media_parts.updated_at) doesn't stop the file being read and isn't a second opinion; ours replaces it when the file gives an answer (row message e.g. "Replaced Plex's intro: it was detected for an earlier file"), and it is kept when it gives none. A marker you adjust or lock in the Inspector overrides this setting for its type: its rows and pv: key are written over Plex's own and the row says "Replaced Plex's own marker…". |
plex.agent |
object | {"enabled": false, "node_id": null} |
Edit → Connection → Plex database: which Plex database node opens this Plex's database, for a Plex on another machine or on Windows. Plex servers only. The node's address and key are on its record in nodes. |
plex.agent.enabled |
bool | false |
false: this app opens Plex's database itself. true: the node named by node_id does, for Intro & Credits, chapter thumbnails, loudness and Library Health counts. true needs node_id (400 otherwise: "Choose the node that opens this Plex server's database before turning it on"). The database-write confirmation (plex.db_write_confirmed_at) still applies. |
plex.agent.node_id |
string | null |
null |
The id of a node in nodes. A newly chosen node must still be on the Nodes page and, once it has answered, have its Plex database role on (400 otherwise). Kept while enabled is false. An older block holding url and token becomes a node when the app upgrades. |
Plex database node
The container for a Plex on another machine (nodes/plex-db/, image ghcr.io/stevezau/plex-marker-agent), or the
Windows program for Plex on
Windows. Add it on the Nodes page and choose it per server in plex.agent. Setup, compose file and version rules are in
its README. The app side is
media_preview_generator/markers/publishers/plex_remote.py. It takes a node's own settings
(A node's own settings), plus:
| Variable | Notes |
|---|---|
AGENT_TOKEN |
The old name of NODE_KEY. Used when NODE_KEY isn't set. |
AGENT_PORT |
The old name of NODE_PORT. Used when NODE_PORT isn't set. |
PLEX_CONFIG |
Compose only: Plex's config folder on the node's host, mounted at /plex. Required. |
AGENT_BIND |
Compose only: the host address to publish on. Default 0.0.0.0. |
Every request carries the key and the protocol the app speaks. Every answer carries the node's version and its
protocols. A mismatch in either direction is refused before anything is written. Once the app has recorded the node's
id (node_uid), every request names it too (protocol 2), so another machine given the same key and address refuses
the request. The app checks the node again while that work waits.
Job kind intro_credits
Intro & Credits jobs are a distinct kind (alongside previews) on the same Job/jobs.db row shape as preview
jobs (see Jobs Endpoints) — same queue, priority levels, pause/cancel and dashboard, routed to
markers-specific check/process functions instead of the preview pipeline. config (the job's config_json
column) holds:
| Key | Type | Notes |
|---|---|---|
kind |
"intro_credits" |
Always this value for a markers job. |
source |
string | What created it: manual, schedule, inspector (re-detect, or the job an editor save queues), inspector_season (Season view Publish), season (a Season follow-up job), reconcile (Check servers), recently_added (the follow-up of a scheduled Recently Added scan), or a webhook source name (sonarr, radarr, plex, retry, …). |
libraries |
[{"server_id", "library_id"}] |
Libraries to enumerate. Empty with no file_paths = every library Intro & Credits goes to. |
file_paths |
array of strings | Explicit files/folders instead of libraries (webhook follow-ups, Inspector re-detect, retries). |
follows_job_id |
string | null |
The Recently Added preview job that listed this job's files, shown as its companion. Never waited for. |
files_sealed |
bool | Present once a webhook follow-up has read its file_paths: no more files join it after that. Also retained for legacy Season jobs. |
force |
bool | Re-detect files already decided, asking every source again. |
webhook_item_id_hints |
{path: {server_id: item_id}} |
Item ids a vendor webhook already supplied, so the job skips a lookup. |
server_id |
string | Present only on a job pinned to one server: it publishes there only. A follow-up gets its preview job's pin as the preview workers resolve it per file (the webhook's or schedule's own pin; else an Emby or Jellyfin webhook, or a file a Recently Added scan listed from Emby or Jellyfin, pins it to that server). Its retries and verify job keep it. |
retry_attempt |
int | Present only on a retry job: which retry this is (1-based). |
verify_chain |
bool | Present only on a retry queued by a verify job or by another retry in its chain: it queues no verify job. |
chain_attempt |
int | Present only on a verify job queued by a retry: the retries already used, so the verify job's own retry goes on counting. |
retry_delay |
int | Present only on a retry or verify job: seconds waited before it took a slot. |
retry_not_before |
ISO-8601 timestamp | Present only on a job queued to wait (a retry, verify job or version batch; also legacy TheIntroDB recheck jobs): the due time. It survives a restart without waiting again in full, and the restart revival ages the job from it (requeue_max_age_minutes), not from when it was queued. When automatic restart recovery is enabled, a global or per-job pause exempts held work from the age limit; the existing hold still blocks processing. Ordinary unpaused stale work retains the configured limit. |
slot_wait_since |
ISO-8601 timestamp | The last time the job was seen waiting for a slot (preview jobs carry it too), refreshed at most once a minute while it waits. A restart ages a job from the latest of this and when it started or was created; a job that never started also from its due time (retry_not_before, or a preview retry's scheduled_at) and, for a follow-up, its preview job's end. A job queued behind a long scan is therefore aged by the downtime only. A Re-run drops it. |
verify |
bool | Present only on a verify job: the delayed check of files published after they were replaced. It queues no further verify job, and doesn't retry a file gone from disk. |
reconcile |
bool | Present only on a Check servers job: it lists the files of drifted published items (and of items whose last publish failed, files with a locked marker a server never received, and decided files to ask servers again about) instead of libraries or paths. |
paused_by_schedule |
bool | Set when a schedule's stop time paused the job: that schedule's next start (or Run now) resumes it. Any resume, and a pause by hand, clears it (a job cancelled while so paused keeps it); a Re-run drops it. |
Retries (files not yet on disk, not yet in a server's library, or on a Plex whose Plex Pass check didn't answer)
reuse the webhook preview-retry backoff (webhook_retry_count / webhook_retry_delay) and cap at 500 files per
retry job — a bigger backlog waits for the next run. A retry job's file_paths are the paths the job was given (a
webhook's own paths, not the first mapped disk's), with their webhook_item_id_hints. A job that publishes to
replaced files also queues one verify job (verify: true, named "Verify: …") for them, due after three times the
first retry delay (at least 600 s); none when webhook_retry_count is 0. Only jobs for sent files (webhooks and
retries, not manual/inspector or library runs) queue one. A job whose read-back of a server failed completes with
the warning Couldn't check what N file(s) show on <server>. A job where an online source's daily budget ran out
partway through completes with one warning per source that ran out (see GET /api/markers/sources/usage above for
the same state in Settings), and doesn't queue a retry for those files — nothing was stored for the source, so the
next scheduled or manual run for the same files asks it again on its own. Recurring checks use user-created Find markers cron schedules; there is no automatic weekly online re-check or
post-reset TheIntroDB job. A database's stored "no entry" is asked again by the next run that includes the file once
it is older than 14 to 28 days (fixed per file, from its files row id). TheIntroDB also isn't
asked about a series (keyed by the tmdb/tvdb/imdb id it's sent) for 7 days once 3 of its episodes got "no entry"
while none has an answer: none recorded in series_lookups, and no file under the show's folder with a stored
TheIntroDB answer holding a marker. The 7 days run from the pause's start (series_pauses in markers.db); "no
entry" answers during it don't extend it, the next pause needs 3 new ones after it ends, an answer with a marker ends
it, and a forced run (force, Inspector re-detect) always asks.
When another episode changes a season's intro-chapter checks or season audio answers, affected episodes are
refreshed by the next manual or scheduled Find markers run that includes them. No additional source: "season"
job is created on completion, cancellation, failure, or restart. Inspector Season view Publish remains an
explicit way to run the season. Legacy queued Season jobs can still finish their saved files and retain their
history; they do not pass pending sibling requests into new jobs. Every job that completes, except Season, retry and verify
jobs, then starts a background cleanup once it has given back its slot (at most one running, and at most one start an
hour). It checks up to 2,000 fingerprinted files on disk for at most 60 s, those checked longest ago first, and clears
the cached fingerprints (and the matches made with them) of files gone from a folder that still exists, or marked missing for 30 days. The app log
line, not the job's, is Cleared the cached audio fingerprints of N file(s) no longer on disk. A webhook's file joins a
webhook job that hasn't read its files yet (renamed "Intro & Credits · N files") while it stays within 500 files,
instead of queuing another: one from the same sender with the same pin, or one already covering its season folder.
Check servers (reconcile: true, source: "reconcile", named "Intro & Credits · Check servers") is created by
POST /api/markers/reconcile, and by an internal job that runs it by default at 04:30 while Intro & Credits is on
anywhere (during quiet hours or Pause All the queued job waits for processing to resume). It is not a user schedule. LOW priority unless the request sets one.
While it runs, its config also carries check_servers_listing (the files it listed), so a run revived after a restart checks those same files; the key is dropped when the job ends.
Only one is queued or running at a time: asking again (a Re-run of a finished one included) returns that job. It
reads back every item this app published (item_publish_state with status written) on each enabled server with
Intro & Credits on, 500 items per call (Plex one item per connection under the lock proof; Jellyfin and Emby one
request per item, stopping a server after 20 failed reads in a row; a read that got no HTTP answer isn't followed by an
"is the item gone?" request), and lists only the files of items that aren't ours any more (missing, replaced,
versions_changed, gone, or a kept type on a server now set to restore). For missing, replaced and
versions_changed the Plex item's current version files (optimized copies left out, mapped through the server's path
mappings) are listed too. Items whose last publish failed (status failed) aren't read back: their files are listed 1
day after the failure, then 2, 4, 8 and 16 days after each retry, at most 5 retries per failure (a success, or a new
failure after one, starts over); an item whose files don't fit the run's remaining files waits untaken. A pause during
the read-back holds the job until resume. It
also lists files with decided credits or preview whose stored answer from an enabled server (Intro & Credits on or not)
is empty or unusable and that show none of ours on that server's item, re-reading that server's own markers once the
answer is 1 day old, then 2, 4, 8 and 16 days after each re-read that stays empty or fails, and not after the fifth; a
file taken for a server isn't taken again within a day. At most 500 files per run (100 of them kept for those re-reads
while more drifted files wait); drifted items take turns across runs, with the warning "N more changed file(s) are
checked on a later run". An item the server no longer has is dropped quietly when no runnable file here belongs to it,
or when its file's row was "not in library" and the server confirmed the item missing: that file gets one retry job
(the normal retry above, source: "reconcile", attempt 1; like manual jobs, it and its retries queue no not-on-disk
retry and no verify job) and the item is dropped only once that retry is queued. A run cancelled or failed before
then, or with webhook_retry_count 0, drops nothing, so the next run confirms the item again. Check servers queues no
other retry (a later run lists what still waits). Other warnings: Skipped <server>: <reason>,
Couldn't check <server>, Couldn't check <server>: no connection to it, Couldn't read what N item(s) show on <server>.
With nothing to list it completes at once (log "Every server checked still shows what this app
published").
Outcome keys
Per-file outcomes (markers.outcomes.FileOutcome, shown in the job's Files panel and progress breakdown):
| Key | Label | Meaning |
|---|---|---|
markers_published |
Markers written | The job changed what at least one server shows (a forced restore included); the reason names any other marker type that wasn't found |
markers_up_to_date |
Up to date | Every enabled server already showed these markers (read back before saying so) |
markers_waiting |
Waiting | A server hasn't indexed the file yet, or Plex didn't answer its Plex Pass check |
markers_none |
No markers found | No source found an intro or credits for this file, or nothing confirmed one; the server row's message gives the reason |
markers_no_owners |
No server with Intro & Credits on | No enabled server with Intro & Credits on holds this file |
skipped_file_not_found |
Not Found | File not found on disk |
markers_skipped |
Skipped | Every server that owns this file can't take markers right now (see the capability states — a plugin missing, Plex not ready, etc.), or the file is a trailer or other extra (reason "Extras aren't checked for markers") |
failed |
Failed | Processing failed |
Per-server row statuses (markers.outcomes.ServerStatus) use the same markers_written / markers_up_to_date /
markers_skipped / markers_waiting / failed keys, plus markers_none (nothing to
publish on that server). A file's overall outcome shows what still needs something, first match wins: any server
failed → failed; any server waiting with a retry queued (not indexed yet, Plex Pass unconfirmed) → waiting; any
written → published; any server waiting → waiting; any up to date → up to date; any with nothing to publish → no
markers; otherwise skipped. So one server that is still
waiting (or failed) is never hidden behind another server that was written or is up to date. Retries and verify jobs
read the per-server rows, not this outcome.
Extras are never checked: a file with a Plex extra suffix (-trailer, -featurette, -behindthescenes,
-deleted, -interview, -scene, -short, -other, -sample) or directly inside an extras folder (Trailers,
Featurettes, Extras, Behind The Scenes, Deleted Scenes, Interviews, Scenes, Shorts, Other, Samples)
is markers_skipped before it is probed or looked up, whether it came from a folder, a webhook or a library listing.
Decided-by counts
An Intro & Credits job's progress.marker_sources (in GET /api/jobs/{id} and the live job_progress event;
null on other jobs, and on an Intro & Credits job until its first file finishes) counts the files each source
decided, per marker type:
{"intro": {"chapters": 40, "theintrodb+skipdb": 6}, "credits": {"chapters": 40, "credits_text": 9, "theintrodb+server_markers": 3}}
Any job's progress.cpu_fallback_files counts the files a GPU worker ran on the CPU because its GPU failed (0
when none did); the job summary shows it as "N files ran on the CPU because the GPU failed".
A file counts once per decided marker type, under one group: its marker's sources (source ids from Settings, joined
with +, in your source order). A marker a chapter set counts as chapters even when other sources agreed (a chapter
decides on its own; they only confirmed or trimmed it); markers already on servers (server_markers, importer copies
included) are named only when they were the one other opinion a single source needed; the user's own marker is
user. A file counts when its run ends with any outcome but failed, so a file whose every server was skipped still
counts what its sources decided, while a file not found, with no server, or an extra never counts. A job revived after a restart counts the files it carries from what the markers store holds for them. The
Dashboard shows these as Decided by under the job's per-server breakdown, biggest group first, at most five groups
per type (the rest add up under "other").
Intro & Credits Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/markers/jobs |
Start an Intro & Credits job |
| GET | /api/markers/servers/{server_id}/status |
This server's Intro & Credits status (Edit tab) |
| GET | /api/markers/sources/usage |
Today's online-lookup usage per source |
| GET | /api/markers/item |
Inspector data for one file (see also Inspector Endpoints) |
| POST | /api/markers/item/redetect |
Re-run Intro & Credits for one file, asking every source again |
| POST | /api/markers/item/markers |
Save your own markers for one file, lock them and publish to every owning server |
| DELETE | /api/markers/item/markers |
Unlock one or more marker types of one file |
| GET | /api/markers/season |
Inspector Season view data for one episode's season |
| POST | /api/markers/season/publish |
Queue an Intro & Credits job for the episodes of one episode's season |
| GET | /api/markers/sources/local |
Whether season audio matching and on-screen credit text can run in this container |
| POST | /api/markers/reconcile |
Queue Intro & Credits · Check servers |
All of them require the same X-Auth-Token / Authorization: Bearer auth (or a logged-in session) as the rest of the
API. A script sending the API token can call any of the POST routes directly; from a signed-in browser they also
need the page's CSRF token, like every other state-changing route (see Authentication).
POST /api/markers/jobs
Request: {"libraries": [{"server_id", "library_id"}]} or {"file_paths": [...]} — omit both for every library
Intro & Credits goes to. Optional: priority (1-3 or high/normal/low, default low), force (re-detect
files already done), library_name (job title), scan_filters (the media filters, for
libraries only).
Response: 201 with the created job; 400 for an invalid body (both libraries and file_paths given, a bad
priority, invalid scan_filters, …); 503 when the config directory isn't writable.
GET /api/markers/servers/{server_id}/status
Response: 200 with server_id, server_type, enabled, settings (as stored), capability ({state,
message, details, warning}, checked as if Intro & Credits were already on — one of ready, disabled,
needs_confirmation, needs_plugin, plugin_outdated, needs_pass, needs_local_db,
agent_unavailable (the Plex database node didn't answer, refused the key, is the wrong version or is beside another Plex),
needs_plex_detection_once, unsupported_schema, unreachable, misconfigured, or unknown when the check itself
failed; warning is "" unless a ready Plex couldn't confirm Plex Pass, in which case jobs wait instead of
writing; on Emby, needs_plugin carries details.catalog_listed: true when Emby's plugin catalog lists the plugin,
false when it doesn't, null when the catalog couldn't be read; on a reachable Emby, details.intro_skip_registered
is Emby's GET /Registrations/dvr IsRegistered (whether its Emby Premiere key lets viewers skip intros), left out
when that couldn't be read or Emby rejected the credentials), can_show (marker types this server type can
display) and libraries (with each one's default selection).
404 for an unknown server; 500 with a JSON error when the status can't be built. A server turned off on the
Servers page isn't contacted (capability.state is disabled).
GET /api/markers/sources/usage
Response: 200 with {source_id: {day, used, limit, remaining, has_key, low_priority_exhausted, resets_at}}
for theintrodb, introdb and skipdb. has_key is only meaningful for theintrodb; the key itself is never
returned. low_priority_exhausted is true once a full-library backfill (LOW priority) lookup against that source
would be refused right now — no budget left, or only the share reserved for higher-priority jobs remains.
resets_at is always the daily budget's day boundary in the user's words ("00:00 UTC"), never the source's own
reset header (TheIntroDB's can't be trusted — see ratelimit.py).
GET /api/markers/item
Query: either path (a file inside a server library), or server_id + item_id, optionally with
version_file: the version's file as a server's search result gave it (needed for Plex, where every version
of an item shares its id). By id, the version asked for is the one version_file names, else the one whose own id is
item_id (a Jellyfin version, a merged Jellyfin item's primary version, or an Emby item, whose media sources list
its other versions too), else the item's only version; another version is never opened in its place. A Plex
version_file that names none of the item's versions any more counts as a version that isn't here. Only a Plex item
with several versions asked for without version_file opens the first version whose file is on this disk.
Response: 200 with known, canonical_path, duration_ms, is_movie, decisions (by marker type),
evidence rows (each with its label: a chapter's title, season audio's "10/10", or ""), and servers (one row per owning server: current markers as read live, published markers that
are ours, plan — will_add / will_replace / will_remove / up_to_date / waiting / keeps_plex (Plex's own
detection replaced ours and on_plex_redetect is keep_plex) / keeps_emby (the same for Emby and keep_emby) /
not_enabled / nothing_to_publish / unknown — with plan_reason (names the types the server keeps, e.g. "Keeping
Plex's credits", on any plan; on Emby also "Emby skips to the end of the file" when decided credits end before the file
does), and version_count: a
Plex item's number of versions (Media entries other than optimized copies), which share one marker set (null for
other servers or when it can't be read); a server whose state can't be read gets a degraded row with error set
instead of failing the whole response). 400 when the path isn't a file inside a server library, the
query is incomplete, or item_id isn't shaped like an id that server's type uses (a Plex rating key is digits
only, e.g. 42, not /library/metadata/42; a Jellyfin or Emby id is up to 36 ASCII hex digits and dashes starting
with a hex digit, or ASCII digits; checked before any server is contacted). 404 for an unknown server, or a
server_id+item_id with no file on this app's disk: {"error": "This version's file isn't on this disk", "reason":
"version_not_here"} when the server has the version asked for but its file isn't here. 409 when the
server is disabled. 500 with a JSON error when the file's data can't be built.
POST /api/markers/item/redetect
Request: {"path"}.
Response: 202 with {"job_id"} — a HIGH-priority, forced, single-file job. If this same file already has a
re-detect queued or running, that job's id is returned instead of starting a second one (a double-click, or clicking
again before the first finishes, doesn't spend the online sources' daily budget twice). 400 when the path isn't a
file inside a server library. 503 when the config directory isn't writable.
POST /api/markers/item/markers
Saves your own markers for one file, locks them, and publishes them to every owning server inside the request. Saving is locking: there is no adjusted-but-unlocked marker. The save lands before any server is contacted, so a server that fails can't lose it.
Request: the file, as path (inside a server library) or server_id + item_id (+ version_file, as in
GET /api/markers/item), plus markers: a non-empty list with one entry per type.
{
"path": "/media/tv/Show/Season 1/Show - S01E02.mkv",
"markers": [
{"type": "intro", "start_ms": 30000, "end_ms": 92000},
{"type": "credits", "start_ms": 1320000, "end_ms": null}
]
}
type is intro, credits, recap or preview. start_ms and end_ms are whole milliseconds; end_ms null or
left out means "runs to the end of the file", and an end up to 2 s past the file's length is clamped to it. Only two
bounds apply to your own marker: it must be inside the file and must end after it starts. The 3 s minimum, the intro
length cap and the position windows that catch a wrong source are not applied.
Response: 200 with the file's stored markers and one row per owning server:
{
"canonical_path": "/media/tv/Show/Season 1/Show - S01E02.mkv",
"duration_ms": 1440000,
"markers": {
"intro": {"type": "intro", "start_ms": 30000, "end_ms": 92000, "locked": true, "locked_at": "2026-09-21T10:15:02+00:00"}
},
"servers": [
{
"server_id": "plex-default",
"server_name": "Plex",
"server_type": "plex",
"result": "written",
"message": "…",
"can_show": ["intro", "credits"],
"cant_show": [],
"notes": [],
"replaced_own": []
}
],
"queued_job_id": null
}
markers holds every stored marker of the file, keyed by type. Per server, result is written, unchanged,
waiting, failed, not_enabled (Intro & Credits is off there) or nothing_to_publish; message is
the same wording a job's row carries. can_show is what that server type can display and cant_show the saved types it
can't (Plex and Emby take no recap or preview). notes are per-field notes: on Emby an edited credits end is accepted
and published start-only, and the note says so. replaced_own lists the types whose own markers that server lost to
your lock although it is set to keep its own (keep_plex / keep_emby).
The publish is bounded: each call to a server waits at most 8 s, and a server the publish hasn't started on 25 s after
it began is not contacted: its row is failed with "Couldn't publish to this server in time; the next Intro & Credits
run publishes it". These are per-call limits and a start gate, not a cap on the whole request. A job already running on
the same file gives a waiting row, "Intro & Credits is running for this file; the next run publishes your marker".
When any row is waiting or failed, the save queues that next run: one HIGH-priority, not forced, single-file Intro &
Credits job (source: "inspector") that publishes the saved markers and retries a server that hasn't indexed the file
yet. The response's queued_job_id is its id (null when every server took the save); a job for the file that hasn't
started yet (an earlier save's, or a queued re-detect) is reused.
Errors: 400 for a body it can't save from (a duplicate type, a marker outside the file or ending before it
starts, a non-integer time), a path outside every server library, or a type no enabled owner can show. 404 for an
unknown server or item. 409 when the server is off, no server with Intro & Credits on has the file
("reason": "no_marker_owner"), the file was never analysed ("reason": "not_analysed") or changed on disk since it was
analysed ("reason": "file_changed"). 503 when the config directory isn't writable.
DELETE /api/markers/item/markers
Drops your lock on one or more marker types. Nothing is published: the servers keep showing your times until the next run decides those types again.
Request: the file as above, plus types: a non-empty list of intro, credits, recap, preview.
{"path": "/media/tv/Show/Season 1/Show - S01E02.mkv", "types": ["intro"]}
Response: 200 with canonical_path, unlocked (the requested types that were locked; the rest were already
unlocked), markers (the file's remaining stored markers) and decisions, by type, for the requested types that have
one: {"intro": {"status": "no_evidence", "reason": "unlocked; the next run decides this type again"}}. 400, 404
and 503 as above; 409 when the server is off or the file was never analysed.
GET /api/markers/season
Query: path (a TV episode file inside a server library; any episode of the season).
Read from this app's own marker database only — no server is contacted, so a whole season is one quick request (the
per-episode GET /api/markers/item stays the place for what a server shows right now).
Response: 200 with:
folder,show,season— the season folder, and the show and season as the Publish job names them: the show folder's name (the folder itself when episodes sit straight in it) and"Season N"or"Specials"from the episode's name.servers— the enabled servers holding the episode, in server order:server_id,server_name,server_type,markers_enabled(Intro & Credits is on there and this library is selected).episodes— the episodes matched as one season (the show's folders for that season on every disk of the library, and the same season number; at most the 40 nearest in a flat folder of hundreds; extras left out), each withpath,name,episode("E01"),known(the app has looked at it),duration_ms,introandcredits({status, reason, marker, proposed}as inGET /api/markers/item),evidencechips ([{source, label}]: the sources with intro or credits evidence, markers already on servers left out; only season audio has alabel, e.g."10/10") andserversdots ({server_id: {state, message}},stateone ofok— last publish wrote our markers,none— nothing of ours there or never published,waiting,failed,skipped, oroff— Intro & Credits off there, or this episode's library isn't selected or is excluded).counts—episodes(the episodes listed),total_episodes(the season's size before the 40-nearest cap) andready(at least one decided marker of any type: what Publish sends).
400 {"error": "Path is not a file inside any server library"} (also for a missing path) or {"error": "Not a TV
episode"} (no SxxEyy in its name). 500 {"error": "Couldn't build the Season view for this file"}.
POST /api/markers/season/publish
Request: {"path"} — any episode of the season.
Response: 202 with {"job_id"} — a NORMAL-priority, not forced Intro & Credits job named
Intro & Credits: <show> · Season N (or · Specials) for exactly the episodes GET /api/markers/season lists for that path (same
folder and season number, at most the 40 nearest), so a folder holding several seasons only sends this one: decided
episodes are sent to every server that doesn't show them yet, undecided ones are checked again. While a Publish of the
same episodes is still queued or running, its id is returned instead of starting a second one. For example
{"path": "/tv/Show/S01E02.mkv"} in a flat Show folder that also holds S02E01.mkv queues "Intro & Credits: Show ·
Season 1" for S01E01.mkv and S01E02.mkv only, and answers 202 {"job_id": "…"}. 400 {"error": "The request
body must be a JSON object with a path"}, {"error": "Path is not a file inside any server library"} or {"error":
"Not a TV episode"}. 503 when the config directory isn't writable.
GET /api/markers/sources/local
Response: 200 with {"season_audio": {"available", "ffmpeg", "message"}, "credits_text": {"available",
"message"}} — season audio matching needs an ffmpeg with the chromaprint muxer (jellyfin-ffmpeg in the amd64
image; the arm64 image has none). ffmpeg is the binary found (null when none), and message says why it isn't
available ("" when it is): no ffmpeg lists the muxer, or one didn't answer the check (then it is asked again
after 10 minutes), e.g.
{"season_audio": {"available": true, "ffmpeg": "/usr/lib/jellyfin-ffmpeg/ffmpeg", "message": ""}}.
credits_text.available is false when ONNX Runtime/OpenCV aren't installed, the text detection or recognition
model isn't at its expected path or isn't the expected file, or the check didn't answer (checked again after 10 minutes; arm64
has no GPU path but is still available on the CPU). message names the reason ("" when available), e.g.
{"credits_text": {"available": true, "message": ""}} or
{"credits_text": {"available": false, "message": "Needs the text detection model, which the Docker image includes; it isn't at /app/models/ch_PP-OCRv4_det_infer.onnx"}}.
A check that fails outright answers "available": null for that source only (with "ffmpeg": null for season audio
and a message such as "Couldn't check whether credit text can run here"); the other source's answer is unaffected,
and Settings leaves a null row as it is.
POST /api/markers/reconcile
Request: optional JSON body {"priority"} (1-3 or high/normal/low, default low); no body is fine.
Response: 202 {"job_id": "…", "already_queued": false} for a new Intro & Credits · Check servers job, or
{"job_id": "…", "already_queued": true} with the one already queued or running (whatever priority was asked for),
plus "paused": true when that job is paused.
200 {"job_id": null, "reason": "Intro & Credits is off on every server"}. 400 {"error": "The request body must
be JSON"}, {"error": "The request body must be a JSON object"} or {"error": "priority must be 1, 2, 3, high, normal
or low"}. 503 when the config directory isn't writable (checked before the body).
Note
There is no separate "install the plugin" route for Intro & Credits — the Edit tab's Install/Update button (Jellyfin
and Emby) calls the existing POST /api/servers/{id}/install-plugin (see
Servers).
Plex loudness
Audio loudness analysis on eligible CPU worker groups (this app's or its nodes') (Plex loudness). Plex servers only; set up in Servers → Edit → Processing → Loudness; off until turned on per server.
Per-server settings (media_servers[].loudness)
| Key | Type | Notes |
|---|---|---|
enabled |
bool | Default false. Independent of Intro & Credits and its write confirmation. Requires Plex 1.43.4.x or 1.43.5.x, with its database on this machine, or a Plex database node (markers.plex.agent, version 1.2.0 or newer) beside a Plex on another machine. Setup Health checks readiness. |
library_ids |
array of strings | null |
Libraries it goes to. null = every movie and TV library. An explicit list selects video libraries only; known music and other non-video types are refused. |
Job kind loudness
Same Job row, queue, priorities, pause and cancel as the other kinds (see Jobs Endpoints). config
holds kind ("loudness"), source, libraries ([{"server_id", "library_id"}]; empty with no file_paths = every
library loudness goes to), file_paths, and the originating server_id when pinned to one server. A webhook's file
joins a waiting webhook loudness job from the same sender with the same pin. Automatic jobs start, a webhook's after
the webhook delay, as soon as a loudness worker is free. File outcomes:
| Outcome | Meaning |
|---|---|
loudness_written |
At least one audio track was analysed and stored, or the item marked analysed. |
loudness_up_to_date |
Plex already has loudness for every track, and the item is marked analysed, or the file has no audio track. |
loudness_no_owners |
No Plex server with loudness on has the file in a chosen library. |
loudness_not_in_library |
Plex hasn't added the file yet; a hidden retry attempt checks it again using the global retry count and backoff settings. The original job row shows the countdown. |
loudness_waiting |
Plex's database couldn't be written just then (Plex restarting or busy); the retry job checks it again. |
loudness_skipped |
Plex lists the file as a trailer or other extra. Plex doesn't take loudness for extras. |
skipped_file_not_found |
The file isn't on disk. A webhook's file is checked again by the retry job. |
skipped_source_gone |
The file isn't on disk because a newer file replaced it, or an import webhook said it was deleted. No retry. |
failed |
A track couldn't be analysed or written (the reason names the track). |
Plex loudness Endpoints
| Method | Path | Notes |
|---|---|---|
POST |
/api/loudness/jobs |
Start a job. Body: libraries or file_paths (inside MEDIA_ROOT), priority (1-3 or high/normal/low, default low), library_name, scan_filters (the media filters), sort_by (default, newest, oldest or random). 201 with the job. |
Path Mappings
Important
Essential for Docker deployments where your media server sees files at
different paths than this container does. Path mappings are stored
per-server — each Plex / Emby / Jellyfin entry in media_servers[]
carries its own list — because different servers can mount the same media
at different paths.
Why Path Mappings?
| Component | Sees files at |
|---|---|
| Media server (Plex / Emby / Jellyfin) | /data/media/Movies/film.mkv |
| This Container | /media/Movies/film.mkv |
Without mapping, the app can't find the files and jobs report them as not found.
Configuration (Web UI)
Open Servers, click Edit on the server that needs mapping, and add rows on the Path mappings tab. Each row has:
- Path on media server — The folder path the media server reports for the file
(e.g.
/data). Calledremote_prefixin the API. - Local path on this app — The folder path this app uses for the same files
(e.g.
/mnt/data). Calledlocal_prefixin the API. - Path on Apps (Sonarr, Radarr, Webhooks) — Only needed when Sonarr, Radarr, Tdarr,
etc. use a different path than the media server (e.g. they use
/datawhile Plex uses/data_disk1). Leave blank if they match. Calledwebhook_prefixesin the API.
Add as many rows as you need (e.g. one per disk when the server has multiple roots). Each server manages its own list independently. Apply to all servers copies one server's list to the others.
Legacy env (semicolon pair)
| Variable | Description |
|---|---|
PLEX_VIDEOS_PATH_MAPPING |
Path(s) as Plex sees them; semicolon-separated for multiple roots (seed value, first-boot only) |
PLEX_LOCAL_VIDEOS_PATH_MAPPING |
Path as this app sees them (seed value, first-boot only) |
The saved path_mappings on each media_servers[] entry take precedence.
Existing semicolon-based values are converted into mapping rows on the first
enabled Plex entry at migration time.
When a server uses multiple roots (e.g. mergerfs)
If a server has several roots (e.g. /data_disk1, /data_disk2) but
Sonarr/Radarr see one path (/data):
- Add one row per server root, each with the same Local path on this app (e.g.
/data). - In Path on Apps, enter
/dataon one of the rows so imports from Sonarr/Radarr still match.
Examples
| Situation | Path on media server | Local path on this app | Path on Apps |
|---|---|---|---|
| Different paths in Docker | /data |
/mnt/data |
(blank) |
| Multiple disks, Sonarr sees one path | /data_disk1 |
/data |
/data |
| Same (second disk) | /data_disk2 |
/data |
(blank) |
How to Find Your Paths
- Plex path: Plex Web → Settings → Libraries → Edit → Folders.
- Emby path: Emby Dashboard → Libraries → Edit → Folders.
- Jellyfin path: Jellyfin Dashboard → Libraries → Edit → Folders.
- Container path: Check your
-vvolume mount.
No Mapping Needed
If both Plex and this container see files at the same path (e.g., both use /media), skip this configuration.
Exclude Paths
In Servers → Edit → Exclude paths you can add paths or folders to skip for preview generation. Each server has its own list (exclude_paths). Rules are applied to the local path (as this app sees the file after path mapping).
- Path prefix — Any file under this folder is skipped (e.g.
/mnt/media/archiveskips everything under that path). - Regex — The full local path is matched against the pattern (e.g.
.*\.iso$to skip ISO files).
Add one row per path or pattern. Excluded items are not queued for full-library runs and are skipped for webhook-triggered runs.
REST API
All API endpoints require authentication except /api/health and /api/setup/status. Until the setup wizard is finished, the setup, settings, Plex-OAuth, server and worker-group routes also answer without a token.
Authentication
Include the authentication token in requests using one of these methods:
# X-Auth-Token header
curl -H "X-Auth-Token: YOUR_TOKEN" http://localhost:8080/api/jobs
# Authorization Bearer header
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8080/api/jobs
Get your token from Authentication Token, or set a fixed token with WEB_AUTH_TOKEN.
Scripts: always send the API token in one of these headers. A request with a valid API token needs nothing
else. A wrong one gets 401 {"error": "Authentication required"}.
CSRF token (browser sessions). A request that relies on the signed-in browser session instead of the API token
must also send the page's CSRF token on every POST, PUT, PATCH and DELETE. This stops a page on another site
from using your signed-in browser to change settings or start jobs. The app's own pages do it for you: every page
carries the token in <meta name="csrf-token"> and sends it as the X-CSRFToken header, and the login form sends it
as a hidden csrf_token field. Without it (or with a stale one) the request is refused with 400:
{"error": "This page's security token is missing or out of date. Reload the page and try again. Scripts: send the API token in an Authorization: Bearer or X-Auth-Token header instead."}
GETrequests never need it.- The webhook receivers (
POST /api/webhooks/radarr,/sonarr,/sportarr,/custom,/plex,/incomingand/server/{server_id}) never need it: they check their own webhook secret on every call. - The setup wizard sends it too, including before setup is finished when its routes need no sign-in.
- With
AUTH_METHOD=external, browser requests still need it; scripts behind the proxy should send the API token. - A browser request that says it came from another origin (the browser-set
Sec-Fetch-Siteheader is anything butsame-originornone) is refused even with a valid token: another app on the same host, on a different port, counts as the same site for cookies. Requests without the header (older browsers, scripts) rely on the token alone. - The token lasts as long as the browser's signed-in session, up to 7 days after its last visit. Signing in starts a
new session, so nothing from before it (a token another page may have read) stays valid. With
AUTH_METHOD=externalthis app never sees a sign-in, so theSec-Fetch-Sitecheck above is what refuses such a token; its Logout only clears this app's session (sign out at your proxy or VPN). - Signing out (
POST /logout, the nav's Logout; opening/logoutonly asks), or changing the API token, ends this browser's session, and a tab still open from before has to be reloaded. Other browsers stay signed in: each session lives in a signed cookie in its browser, and the app keeps no list of sessions to end, even when the API token changes. To sign every browser out, deleteflask_secret.keyfrom the config folder (or changeFLASK_SECRET_KEY, if you set it) and restart the container: every session cookie stops being valid, and each browser signs in again with the current token.
Setup & Settings Endpoints
GET /api/setup/status
Check if setup is complete. No authentication required.
{
"configured": true,
"setup_complete": true,
"current_step": 0,
"plex_authenticated": true
}
GET /api/setup/state
Get current setup wizard state.
{
"step": 2,
"data": {
"server_name": "My Plex Server"
}
}
POST /api/setup/state
Save setup wizard progress.
Request:
{
"step": 2,
"data": {
"server_name": "My Plex Server"
}
}
POST /api/setup/preview-file-path
Check one real file against draft path mappings, without saving the mappings, opening file contents, contacting a media server or starting work. Available during initial setup; authentication is required after setup completes. The normal setup request/CSRF protections apply.
Request:
{
"path": "/server/media/Movies/Example.mkv",
"path_mappings": [
{"remote_prefix": "/server/media", "local_prefix": "/media", "webhook_prefixes": []}
]
}
path_mappings is optional and defaults to an empty list. plex_prefix is accepted as the legacy name
for remote_prefix. Absolute Windows drive and UNC source paths can be mapped to container paths.
The resolved path must stay within the configured MEDIA_ROOT, including symlink resolution.
Response: HTTP 200 with input_path, local_path, mapping_applied, exists, is_file and
readable. A missing file or directory is a valid check result, not an HTTP error. readable reports
the container's read-access check; it does not validate video contents, exclusions or library ownership.
HTTP 400 with error rejects invalid paths/mappings or paths outside the permitted root. Paths are
limited to 4,096 characters, with at most 100 mapping rows and 100 webhook aliases per row. Parent-directory
segments and NUL characters are rejected; the normal 1 MiB request-body limit applies.
POST /api/setup/complete
Mark setup as complete. Returns {"success": true, "redirect": "/"}.
GET /api/setup/token-info
Get information about the current authentication token (used by Step 5 of the setup wizard).
{
"env_controlled": false,
"token": "abc123xyz...",
"token_length": 43,
"source": "config"
}
| Field | Type | Description |
|---|---|---|
env_controlled |
boolean | Whether token is set via WEB_AUTH_TOKEN env var |
token |
string | The current authentication token |
token_length |
number | Length of the token |
source |
string | Either "environment" or "config" |
POST /api/setup/set-token
Set a custom authentication token during setup.
Request:
{
"token": "my-custom-password",
"confirm_token": "my-custom-password"
}
Returns {"success": true} on success, or {"success": false, "error": "..."} with details:
"Tokens do not match.""Token must be at least 8 characters long.""Token is controlled by WEB_AUTH_TOKEN environment variable and cannot be changed."
GET /api/settings
Get current settings. Secrets are masked as "****".
{
"plex_url": "http://192.168.1.100:32400",
"plex_token": "****",
"plex_name": "My Server",
"plex_verify_ssl": true,
"plex_config_folder": "/plex",
"selected_libraries": ["1", "2"],
"media_path": "/media",
"path_mappings": [
{"remote_prefix": "/data", "local_prefix": "/mnt/data", "webhook_prefixes": []}
],
"exclude_paths": [],
"gpu_config": [
{"device": "/dev/dri/renderD128", "name": "Intel UHD 630", "type": "intel", "enabled": true, "workers": 4, "ffmpeg_threads": 2}
],
"cpu_threads": 2,
"worker_groups": [],
"worker_groups_revision": 0,
"scan_workers": 0,
"thumbnail_interval": 10,
"thumbnail_quality": 4,
"tonemap_algorithm": "hable",
"log_level": "INFO",
"log_rotation_size": "10 MB",
"log_retention_count": 5,
"job_history_days": 30,
"webhook_enabled": true,
"webhook_delay": 60,
"webhook_retry_count": 5,
"webhook_retry_delay": 30,
"webhook_secret": "****",
"auto_requeue_on_restart": true,
"requeue_max_age_minutes": 720,
"incoming_job_priority": 1,
"loudness_nice_level": 10,
"config_backup_keep": 10,
"config_backup_max_age_days": 0,
"frame_reuse": {"enabled": true, "ttl_minutes": 60, "max_cache_disk_mb": 2048},
"markers": {"detect": {"intro": true, "credits": true, "recap": false}}
}
The Plex fields and path_mappings / exclude_paths are projected from the first Plex server. The response also includes
plex_videos_path_mapping, plex_local_videos_path_mapping, gpu_threads and ffmpeg_threads (legacy values).
incoming_job_priority is 1 (high), 2 (normal) or 3 (low).
path_mappings keys:
remote_prefixis the canonical key (works for Plex, Emby and Jellyfin). The oldplex_prefixis still accepted as an alias on read.
POST /api/settings
Update settings. Send only the fields to change. Returns {"success": true} (plus a warning when no workers are configured). A bad value returns 400 with error and saves nothing.
{
"gpu_config": [{"device": "/dev/dri/renderD128", "ffmpeg_threads": 2}],
"thumbnail_interval": 10,
"plex_url": "http://192.168.1.100:32400"
}
Use the dedicated worker-groups API for allocation. Once groups exist, posting legacy
cpu_threads or GPU enabled/workers allocation fields returns 409 with guidance instead of overwriting groups.
GPU tuning-only entries merge into stored device settings. GET /api/settings also returns worker_groups (with the single-member echo above) and
worker_groups_revision. Zero workers cause resource waiting, not a global pause. Saving capacity never clears a
manual or schedule hold.
markers (the Intro & Credits block) may be partial: posted keys merge over the stored block — detect key by key,
sources by id (a list naming every source sets their order; a shorter one updates those sources where they are;
naming a source twice is a 400). api_key: "****" keeps the stored TheIntroDB key and "" clears it. GET
/api/settings returns the block with the key masked.
Plex OAuth Endpoints
POST /api/plex/auth/pin
Create a new Plex OAuth PIN.
{
"id": 12345,
"code": "ABCD1234",
"auth_url": "https://app.plex.tv/auth#?clientID=...&code=ABCD1234"
}
GET /api/plex/auth/pin/{id}
Check if PIN has been authenticated. Returns {"authenticated": true, "auth_token": "..."} or {"authenticated": false, "auth_token": null}.
GET /api/plex/servers
Get list of user's Plex servers.
{
"servers": [
{
"name": "My Server",
"machine_id": "abc123",
"host": "192.168.1.100",
"port": 32400,
"ssl": false,
"uri": "http://192.168.1.100:32400",
"owned": true,
"local": true,
"connections": [{"uri": "http://192.168.1.100:32400", "address": "192.168.1.100", "port": 32400, "protocol": "http", "ssl": false, "local": true, "relay": false}]
}
]
}
GET /api/plex/libraries
Get libraries from a Plex server. Optional query parameters: url, token, verify_ssl. They default to the saved Plex values. 400 when no URL or token is available.
{
"libraries": [
{ "id": "1", "name": "Movies", "type": "movie" },
{ "id": "2", "name": "TV Shows", "type": "show" }
]
}
POST /api/plex/test
Test Plex connection. Request: {"url": "...", "token": "..."}. Returns {"success": true, "server_name": "...", "error": null}, or {"success": false, "error": "..."}.
Worker group endpoints
All endpoints use the normal session/API authentication and are also available during initial setup.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/worker-groups |
Saved groups, optimistic revision, app timezone, limits, detected hardware, nodes (each added node's id, name, state and reason, with its GPUs and CPU cores, for the device picker; never its address or key), current capacity, warnings and global pause state |
| PUT | /api/worker-groups |
Replace the complete group list with {"groups":[...],"revision":N}. 400 rejects invalid configuration; 409 means another change advanced the revision. Reload and reconcile before resubmitting. |
| POST | /api/worker-groups/{id}/scale |
Atomic saved adjustment: exactly {"delta":1}, {"delta":-1} or {"enabled":true/false}. delta works on single-member groups; on a group with several members it returns 409 (use the member route). 404 for an unknown group, 400 for an invalid action or capacity limit. |
| POST | /api/worker-groups/{id}/members/{member_id}/scale |
Add or remove one worker on one device: exactly {"delta":1} or {"delta":-1}. 404 for an unknown group or member, 409 when the group is disabled, 400 outside 1–64 workers or past a capacity limit. |
For example, after loading revision 3, replace the configuration with one group that runs previews on a GPU and loudness on the CPU overnight:
{
"revision": 3,
"groups": [{
"id": "off-hours",
"name": "Off-hours",
"enabled": true,
"availability": {
"mode": "scheduled",
"windows": [{"days": [0,1,2,3,4,5,6], "start": "01:00", "end": "07:00"}]
},
"members": [
{"id": "gpu", "resource": "gpu", "device": "cuda:0", "count": 3, "job_types": ["previews", "intro_credits"]},
{"id": "cpu", "resource": "cpu", "device": null, "count": 10, "job_types": ["loudness"]}
]
}]
}
A PUT replaces all groups, so include every group you want to keep. Use scale for a single immediate count or
Enabled change. A decrease to zero disables the group while retaining its saved positive count; an Enabled action
restores that count. Successful mutations return the updated GET snapshot plus success: true. An optional
warning means settings were saved but live reconciliation failed; inspect logs rather than assuming the save failed.
capacity.groups includes id, name, desired, target, available, busy, finishing,
state, next_available_at (ISO timestamp or null) and members, one row per device with id, resource, device and the same counts and state. Desired is the enabled saved count; target reflects current
hours and hardware. Busy excludes retiring work, which is counted as finishing. Available is zero during global
pause. States include active, disabled, off_hours, hardware_unavailable and draining. Removed groups may
remain in this snapshot while files finish. capacity.current sums current targets by CPU/GPU and capacity.peak
gives the configured weekly peaks; neither is a CPU utilization percentage. Warnings identify unavailable hardware
(code: "hardware_unavailable") or job types with no compatible hours outside the global pause schedule
(code: "no_eligible_workers"). processing_paused and pause_reasons describe
the separate master hold.
Persisted pending capacity waits and their saved checkpoints are restored independently of
auto_requeue_on_restart. Pending dependency followers also retain their waiting state. This does not clear manual,
schedule or global holds. Ordinary interrupted running jobs continue to follow the automatic crash-requeue setting.
Nodes Endpoints
API token or session, like every other route. A NODE_ROW is a node's id, name, url, enabled, state and
plain reason, version, roles, legacy (an older Plex database node that only answers /v1/ping), platform,
gpus, cpu, busy, last_seen, checking (a check is running or queued), path_mappings, used_by
({"groups": [...], "servers": [...]}), to_fix ({"must_fix": n, "recommended": n}, its Setup Health counts) and
edit_tag. It never holds the key.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/nodes |
Every node as a NODE_ROW, this app's app_version, and this_app: this app's own machine (not a node), as gpus, cpu (cores), busy and used_by ({"groups": [...]}, the groups with a member on this app). |
| POST | /api/nodes/probe |
{"url", "key"} (and id to reuse a saved node's key with ****): what answers there. Nothing is saved. |
| POST | /api/nodes |
Add a node (the nodes fields). 201 with its row; 400 for an invalid field; 409 when the machine answering there is already added. |
| PUT | /api/nodes/{id} |
Change name, url, key, enabled or path_mappings. A new url needs the key typed again. With the row's edit_tag, 409 when the node was saved from somewhere else since that row was read. |
| DELETE | /api/nodes/{id} |
Remove it. 409 with used_by while a worker group member or a Plex server uses it. |
| POST | /api/nodes/check |
Check every turned-on node in the background; 202 at once. Each row's checking is true until its check ends. |
| POST | /api/nodes/{id}/check |
Check it in the background; 202 at once. Its row's checking is true until the check ends. |
| POST | /api/nodes/{id}/rescan-gpus |
Ask it to detect its GPUs again; answers with the fresh row. |
| POST | /api/nodes/{id}/test-access |
Sample every enabled library and ask the node to find each file, with its saved path_mappings or posted ones. |
| GET | /api/nodes/{id}/suggested-mappings |
Rows to pre-fill for a node with none saved: one per library folder, the same path on the node. |
| GET | /api/nodes/{id}/health |
Its Setup Health. |
| GET | /api/nodes/{id}/log |
Its last lines (1–2000, default 200) log lines, oldest first, as {time, level, message}. |
An unknown id is 404.
Processing state (global pause)
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/processing/state |
Get global processing pause state |
| POST | /api/processing/pause |
Add the manual global hold; pause active processing and new starts |
| POST | /api/processing/resume |
Clear only the manual global hold; the pause schedule can still hold processing |
GET /api/processing/state — Response includes paused and reasons, for example
{"paused":true,"reasons":["manual","quiet_hours"]}. Manual holds survive restarts.
POST /api/processing/pause returns {"paused":true}. POST /api/processing/resume returns the effective
paused state and its reasons; a successful resume can return paused: true while the pause schedule is active.
Worker availability never clears these holds.
Jobs Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/jobs |
List jobs (filtered, paged) |
| POST | /api/jobs |
Create a library job |
| GET | /api/jobs/{id} |
Get job details |
| GET | /api/jobs/{id}/attempts |
Retry chain history for a chain-head job |
| GET | /api/jobs/{id}/file-list |
The job's first files for its row on the Jobs page (?limit=, default 10, max 50): {"files": [{"title", "name", "path"}], "total": N}, the files it was given first, then the files it has run. 404 for an unknown id. |
| POST | /api/jobs/{id}/cancel |
Cancel job |
| POST | /api/jobs/{id}/pause |
Add a manual hold to this pending or running job, for all job kinds. Returns the job; 409 for a terminal job, 404 for an unknown ID. Does not change global pause. |
| POST | /api/jobs/{id}/resume |
Remove this job's manual hold. Returns the job plus processing_paused; a schedule hold can keep paused true. 409 for a terminal job, 404 for an unknown ID. |
| POST | /api/jobs/{id}/priority |
Change a job's priority. Body {"priority": "high"\|"normal"\|"low"} or 1|2|3. Returns the job. An unrecognised value becomes Normal. |
| POST | /api/jobs/{id}/retry-now |
Skip the countdown to the next retry attempt of a retry-chain job |
| DELETE | /api/jobs/{id} |
Delete a finished job. 404 for an unknown or running job. |
Priority 1 is high, 2 normal, 3 low. Jobs wait in the queue by priority, then by creation time. A job's
priority field holds the number.
GET /api/jobs
Optional query parameters:
| Parameter | Meaning |
|---|---|
page |
Default 1. 0 returns all jobs. |
per_page |
Default 50, maximum 200. |
status |
active, running, pending, completed, failed, cancelled, paused or chapter_warnings. Omit or use all for every status. active is unfinished pending or running jobs, including paused jobs and retry heads (the dashboard starts here). paused is pending or running jobs with their own pause hold, whatever global pause says. chapter_warnings is visible finished preview jobs whose saved publisher summaries report failed, waiting or incomplete chapters. Anything else returns 400. |
q |
Case-insensitive search of job, library, source and server labels. |
kind |
previews, intro_credits or loudness. Anything else returns 400. |
server_id |
Jobs that started on, publish to, or touch libraries of that server. |
library_server_id and library_id |
An exact server and library pair. Send both or neither. |
include_retry_attempts |
1 includes the hidden per-attempt retry jobs. |
Filters apply before paging. Running jobs come first, then pending jobs by priority and creation time, then finished jobs, newest first. Retry attempts are hidden by default: a retry chain shows as one row.
The response has jobs, total, page, per_page and pages for the filtered list, filter_options (the servers
and libraries you can filter by) and chapter_warning_count. chapter_warning_count is the number of visible finished
preview jobs with saved chapter issues, regardless of the page, search, status, server, library and kind filters. It
describes job history, not the current state of every file. Each job in the list also carries library_names (the libraries
it covers, empty when it names none) and library_scope.
Every job object has these fields:
{
"id": "job-123",
"kind": "previews",
"status": "running",
"priority": 2,
"paused": false,
"created_at": "2026-01-15T10:30:00+00:00",
"started_at": "2026-01-15T10:30:05+00:00",
"completed_at": null,
"library_id": "1",
"library_name": "Movies",
"server_id": "plex-household",
"server_name": "Household Plex",
"server_type": "plex",
"parent_schedule_id": "",
"publishers": [],
"progress": {
"percent": 45.0,
"current_item": "Movie Title",
"total_items": 100,
"processed_items": 45,
"current_file": "/media/movies/Movie Title.mkv",
"current_files": ["/media/movies/Movie Title.mkv"],
"workers": [],
"outcome": {"generated": 40, "skipped_bif_exists": 5},
"retry_eta": null,
"retry_wait_total": null,
"marker_sources": null,
"cpu_fallback_files": 0
},
"error": null,
"config": {}
}
status is pending, running, completed, failed or cancelled. publishers has one row per server the job
published to (server_id, server_name, server_type, adapter_name, status, message, canonical_path).
progress.workers holds the per-worker status rows while the job runs.
POST /api/jobs
Request: {"library_id": "1", "library_name": "Movies"}
Other accepted fields: library_ids (list, for several libraries), library_names (list), libraries (list of
{"server_id", "library_id"} pairs, which keeps the same library id on two servers distinct), server_id, and priority
(high, normal, low or 1–3; default normal). Without server_id, the app infers the server when every chosen
library belongs to one server, and the job publishes only there. libraries pairs on several servers make one job:
each file is processed once and published to every server that owns it.
An optional config object may set force_generate, regenerate_thumbnails, sort_by, selected_libraries and
selected_library_ids, plus the media filters. Other keys are dropped with a WARNING and aren't
saved with the job.
Response: 201 with the created job object (same shape as GET /api/jobs/{id}). 503 when the config folder isn't writable.
Optional media filters go in config. For example,
process movies added in the last 30 days and released in 2020 or later:
{
"library_id": "1",
"library_name": "Movies",
"config": {
"sort_by": "newest",
"added_filter": "last_days",
"added_last_days": 30,
"movie_year_from": 2020
}
}
For TV libraries, "latest_seasons": 2 selects the most recent two available
seasons per show. In mixed selections, movie-year restrictions affect only
movies and season restrictions affect only episodes. Shared added-date
restrictions apply to both.
Invalid filter values return HTTP 400 before a job is created. Days and season counts must be positive JSON integers, not strings, booleans, or fractions. Date ranges need both valid endpoints in chronological order; a movie-year range may have one bound, but cannot have its lower bound exceed its upper bound. Fields belonging to an inactive added-date mode are cleared during normalization.
GET /api/jobs/{id}
Returns the job object shown above. 404 for an unknown id.
GET /api/jobs/{id}/attempts
For the head job of a retry chain. Returns
{"chain_id", "attempts": [...], "max_attempts"}. The first attempt is the original job (retry_attempt: 0,
is_originating: true), followed by each retry job in order. 404 when the job has no retry chain.
POST /api/jobs/{id}/retry-now
Skips the countdown to the next retry attempt. Returns {"fired": true, "job_id", "retry_job_id"}. 404 for an unknown
job, 400 when the job isn't a retry-chain head, 409 when no retry is waiting.
Schedules Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/schedules |
List schedules: {"schedules": [...], "load_status": {...}} |
| GET | /api/schedules/{id} |
One schedule. 404 for an unknown id. |
| POST | /api/schedules |
Create schedule (201) |
| PUT | /api/schedules/{id} |
Update schedule |
| DELETE | /api/schedules/{id} |
Delete schedule |
| POST | /api/schedules/{id}/run |
Run now. Returns {"success": true}. |
| POST | /api/schedules/{id}/enable · /api/schedules/{id}/disable |
Turn a schedule on or off without deleting it. Returns the schedule. |
| GET | /api/schedules/builtin |
The built-in daily jobs: {"jobs": [...]} with library_health (enabled, time, catch_up, catch_up_max_files (500), next_run) and check_servers (enabled, time, intro_credits_on, next_run). |
| PUT | /api/schedules/builtin/{id} |
Change a built-in job. Send only the fields to change: time (HH:MM) and enabled, and for library_health also catch_up. Returns the job. 400 for a bad value or a field the job doesn't have, 404 for an unknown id. |
| POST | /api/schedules/builtin/{id}/run |
Run a built-in job now. Returns {"ok": true}. 409 when a Library Health check is already running. |
| POST | /api/schedules/recover_from_backup |
Restore schedules.json from its newest backup after it failed to load. 200 with {"status": "ok"}, otherwise 409 with a status such as no_backup. |
POST /api/schedules
Fields: name (required), one of cron_expression or interval_minutes (required), library_id or library_ids,
library_name, server_id (pin the schedule to one server), enabled (default true), priority, stop_time (HH:MM,
container-local time; pauses the schedule's job at that time and resumes it at the next start) and config. 400 for an
invalid value. 503 with config_health when the config folder isn't writable.
Cron request — full library scan (default):
{
"name": "Nightly Movies",
"library_id": "1",
"cron_expression": "0 2 * * *"
}
Interval request — full library scan:
{
"name": "Every 4 Hours",
"library_id": "1",
"interval_minutes": 240
}
Recently Added scanner schedule:
{
"name": "Recently Added Scanner",
"library_id": null,
"interval_minutes": 15,
"enabled": true,
"config": {
"job_type": "recently_added",
"lookback_hours": 1
}
}
priority (optional, 1|2|3) pins the schedule. Omit it — or send
null on a PUT — to leave the schedule unpinned, which is the default: a
recently_added schedule then follows the global incoming_job_priority
setting (High out of the box), and a full_library schedule runs at Normal.
On PUT, an absent priority key leaves an existing pin untouched; an
explicit null clears it.
config.job_kinds is a non-empty list of "previews", "intro_credits" and "loudness" (returned in that order); a tick runs every kind listed. An unknown kind or an empty list returns 400. A config without it is read as before: job_type: "intro_credits" becomes ["intro_credits"] over the whole library, "recently_added" becomes all three kinds, anything else ["previews"]. A legacy Check servers schedule (job_type: "intro_credits" with reconcile) is deleted at startup with a WARNING, because Check servers is automatic now.
config.job_type says which files the kinds run on:
"full_library"(default — optional, omit to get the same behaviour) — the whole library. Each ticked kind is queued as its own job on the same tick, in the schedule's libraries (library_ids); a kind that fails to start doesn't stop the others. Previews processes the items missing previews. The config accepts the same media filters asPOST /api/jobs, which apply to every kind.priorityapplies to every kind; unpinned, Previews follows its usual default and Intro & Credits and loudness run at Low.server_idis set when every chosen library is on one server, and""on aPUTclears the pin.config.libraries(optional) is a list of{"server_id", "library_id"}objects, the exact libraries picked (Start new job sends the same pairs); an invalid list returns400, and an absent one means the flatlibrary_idsplusserver_idapply. Picks on several servers create one job per kind over all the pairs; a file the servers share is processed once. A kind is skipped while an earlier job of the same kind from the schedule is still pending or running."recently_added"— schedule runs one Recently Added scan instead; the ticked kinds are what happens to the files it finds. Previews are generated only when"previews"is ticked; the other ticked kinds are queued as follow-ups for those files, and with Previews unticked the scan only lists the files and queues the follow-ups. Nothing found, nothing queued. Requiresconfig.lookback_hours(float, clamped to 0.25–720). Scans only items added within the lookback window (PlexaddedAt, Emby/JellyfinDateCreated), counted back from when the job was created (a job that waited for a slot, or was revived after a restart, still covers that window; a tick while the schedule's last scan hasn't started queues nothing, widening that scan's window when this tick looks further back), as one preview job with a Files-panel row per file, the usual retry for files a server hasn't indexed yet, and revival after a restart. Whenlibrary_idisnull, the scan falls back to the globally selected libraries in Settings (or every supported library when no global filter is set); when set, only that section is scanned. Works for Plex, Emby, and Jellyfin — each vendor's processor implementsscan_recently_addedagainst its native API.
A start tick (or POST /api/schedules/{id}/run) first resumes every job of the schedule that its stop time paused and that its kinds still cover (a Recently Added schedule always covers its scan job), and then queues nothing that tick. Manual and schedule holds are independent in config.pause_reasons: a start tick removes only the schedule hold, and a manual resume removes only the manual hold. A stop tick adds its schedule hold even when the job is already manually paused. The compatibility paused_by_schedule flag reflects the schedule hold. Deleting a schedule leaves its paused jobs paused and logs a WARNING naming each; so does a PUT that changes the kinds or job_type so that its start ticks no longer resume a job its stop time paused.
Media filters are cleared when saving a Recently Added schedule. Filter validation also runs before creating or updating a whole-library schedule; invalid values return HTTP 400 without changing the saved schedule.
Scheduled ticks during global pause are skipped rather than saved for a catch-up run. Repeated starts for an unchanged scope can reuse an unstarted pending request; Recently Added can widen its pending lookback. Started passes and their parked remaining work are not expanded by later ticks. Worker availability does not create jobs and does not change a schedule's start/stop times.
System Endpoints
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/health |
No | Health check |
| GET | /api/system/status |
Yes | gpus, gpu_stats, running_job and pending_jobs |
| GET | /api/system/config |
Yes | Current configuration |
| GET | /api/libraries |
Yes | Libraries across every enabled server, each tagged with server_id, server_name and server_type. ?server_id= limits it to one server. ?url=&token= lists a Plex server that isn't saved yet (setup wizard). |
Multi-Media-Server Endpoints
For full design and per-vendor details see Multi-Media-Server.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/servers |
List configured servers (auth redacted) |
| POST | /api/servers |
Add a new server (auto-generates id) |
| GET | /api/servers/<id> |
Fetch one server (auth redacted) |
| PUT/PATCH | /api/servers/<id> |
Update; redacted auth values are kept. A body without markers keeps the stored Intro & Credits block; a partial markers merges over it, plex/emby sub-block included (a key sent as null wins; "markers": null resets the block to the defaults, Intro & Credits off) |
| DELETE | /api/servers/<id> |
Remove a server |
| POST | /api/servers/test-connection |
Test a candidate config without saving |
| POST | /api/servers/<id>/refresh-libraries |
Re-fetch the server's library list |
| GET | /api/servers/owners?path=... |
Diagnose which servers own a given path |
| GET | /api/servers/<id>/output-status?path=...&item_id=... |
Whether publisher output files exist for a path on this server. Plex computes its bundle destination from the readable local source file and requires neither item_id nor a live connection. Emby also derives its path locally. Jellyfin off-media output requires item_id. |
| POST | /api/servers/auth/emby/password |
Username+password → Emby token |
| POST | /api/servers/auth/jellyfin/password |
Username+password → Jellyfin token |
| POST | /api/servers/auth/jellyfin/quick-connect/initiate |
Begin Quick Connect ceremony |
| POST | /api/servers/auth/jellyfin/quick-connect/poll |
Poll for approval |
| POST | /api/servers/auth/jellyfin/quick-connect/exchange |
Exchange approved secret for token |
| GET | /api/servers/<id>/health-check |
Per-server settings audit. Returns {vendor, issues, issue_count, fixable_count}; issues[] carries {flag, label, severity, current, recommended, rationale, library_id, library_name, fixable}. Works for Plex (server-wide prefs via /:/prefs), Emby and Jellyfin (per-library LibraryOptions). |
| POST | /api/servers/<id>/health-check/apply |
Apply settings to one or more flags. Three body shapes (all backwards-compatible): {} = fix every issue at recommended value; {"flags": ["FlagName", ...]} = fix only named flags toward recommended; {"set": [{"flag": "X", "value": true\|false, "library_ids": ["id"]\|null}]} = set each flag to the EXPLICIT value (enables disable-direction toggles on the Setup Health card). Returns {ok, results} keyed <library_id>:<flag> (or :<flag> for server-wide prefs). |
| GET | /api/servers/<id>/previews-readiness |
Unified readiness payload for every vendor. Returns {vendor, overall_ok, sections: [{id, title, docs_anchor, ok, severity, checks: [{id, label, docs_anchor, tooltip, ok, severity, current, recommended, actions: {enable?, disable?}, reason, meta}]}]}. Drives the unified Setup Health card on the Edit Server modal. See the Setup Health guide. |
| POST | /api/servers/<id>/install-plugin |
Jellyfin and Emby (400 for Plex). Jellyfin: adds the Media Preview Bridge manifest URL to Jellyfin's plugin repos, queues the package install, and restarts Jellyfin. Returns {ok, steps: [{step, ok, detail}], error}. Emby: installs Media Preview Bridge for Emby from Emby's own plugin catalog and restarts Emby; when the catalog doesn't list it, answers ok: false, manual: true (install the DLL by hand). Returns {ok, steps, error, manual}. |
| POST | /api/servers/<id>/plex-library-markers |
Plex only. Setup Health's Turn on for a library whose own Intro markers / Credits markers setting is off (Plex then hides every skip marker of that type there, ours included). Body {"library_id": "2", "prefs": ["enableIntroMarkerGeneration", "enableCreditsMarkerGeneration"]} (one or both); sets them on with PUT /library/sections/{id}/prefs for that library only. 400 for any other pref, a library outside the server's Intro & Credits selection, or a non-Plex server. Returns {ok, library_id, prefs} or {ok: false, error}. |
| POST | /api/servers/<id>/plex-marker-detection |
Plex only. Setup Health's Set server-wide to Never. Body {"types": ["intro", "credits"]} (one or both); sends PUT /:/prefs?GenerateIntroMarkerBehavior=never&GenerateCreditsMarkerBehavior=never for the types given, which stops Plex's own detection without hiding any marker. 400 for any other type, a non-Plex server, or a server with Intro & Credits off. Returns {ok, types} or {ok: false, error}. |
| POST | /api/servers/<id>/plex-loudness-analysis |
Plex only. The recommended Set to Never fix, with a confirmation dialog in Setup Health. Body {}; sends PUT /:/prefs?LoudnessAnalysisBehavior=never. Requires app loudness opt-in, eligible selected video libraries, configured CPU loudness hours outside the global pause schedule and a fresh successful writer-readiness check. Returns 409 if the server is disabled, selection is empty, eligible CPU hours are absent or the writer is unavailable; 400 for a non-Plex server or loudness opt-in off; 404 for an unknown server. Response is {ok, error}; inspect ok even for HTTP 200 because Plex can refuse the preference update. Affects native analysis in every library, including music; existing measurements are retained. |
| POST | /api/servers/<id>/plex-loudness-library-analysis |
Plex only. Setup Health's Turn off in this app's libraries fix, offered when Plex has libraries this app doesn't measure. Body {library_ids: [...]}, each one of this app's selected loudness libraries; sends PUT /library/sections/<id>/prefs?enableLoudnessAnalysis=0 for each. Same requirements and status codes as the row above, plus 400 when library_ids is empty or names a library outside the selection. Response is {ok, error}. Music and other libraries keep Plex's own analysis. |
| POST | /api/servers/<id>/uninstall-plugin |
Jellyfin only (400 otherwise). Removes the Media Preview Bridge plugin (DELETE /Packages/{GUID}; 404 treated as success — already gone) and restarts Jellyfin. Repo URL stays in place for possible re-install. Same response shape as /install-plugin. |
| POST | /api/servers/<id>/previews-readiness/dismiss · /undismiss |
Hide or show again one recommended Setup Health check. Body {"check_id": "..."}. Stored in the server's health_dismissals. |
| GET | /api/bif/servers/<id>/search?q=<query> |
One server's preview search; returns preview_kind (bif or trickplay) per result. Limited to 10 per minute. The Inspector searches every server through GET /api/media/search instead |
| GET | /api/bif/trickplay/info?server_id=...&path=... |
Parse a Jellyfin trickplay manifest + report sheet metadata |
| GET | /api/bif/trickplay/frame?server_id=...&sheets_dir=...&index=N&tile_width=10&tile_height=10 |
Slice and serve a single thumbnail JPEG from a trickplay tile sheet |
The readiness response can include checks marked informational: true. These remain visible when their severity
is info, without becoming recommendations or making the section unhealthy. An optional_action names an action
in the existing actions map, with optional_label supplying its button text. Plex's native loudness schedule uses
this form; its optional action is excluded from bulk fixes and is absent when no eligible libraries are selected or
the writer is unavailable.
Inspector Endpoints
The Inspector page (/inspector, guide) searches with GET /api/media/search, reads a file's
Intro & Credits with GET /api/markers/item, and draws preview frames with GET /api/bif/frame and
GET /api/bif/trickplay/frame. These routes add what it needs beyond them. All take the usual API token or a signed-in
session (the POST routes also the page's CSRF token from a browser).
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/inspector/status |
Preview and Intro & Credits state for search rows |
| POST | /api/inspector/show |
A show's seasons and episodes, each with its Intro & Credits state |
| GET | /api/inspector/file?path=... |
Where each server keeps one file's preview, Plex loudness measurements, a job working on the file, other versions |
| GET | /api/inspector/frames?path=...&start_ms=...&count=...&width=... |
Exact frames one second apart, read from the video |
POST /api/inspector/status
Request: {"paths": ["/media/…/file.mkv", …]}, at most 30 local file paths (a search row's paths[0]).
Limited to 120 requests a minute.
Response: {"items": {"<path>": {…}}}. A path that isn't a file inside a server library is {"in_library": false}.
Every other one:
{
"in_library": true,
"exists": true,
"kind": "movie",
"quality": "2160p Dolby Vision",
"servers": ["Plex"],
"preview": {"state": "ready", "frames": 4089, "servers": ["Plex"]},
"markers": {"state": "not_checked", "label": "Not checked yet"}
}
quality comes from the file's name. preview.state is ready, missing, or unknown (a server couldn't be
asked); frames is the BIF's frame count (null for trickplay). markers.state is not_checked,
both ("Intro + credits"), credits ("Credits set" for a film, "Credits only" for an episode), intro or none
("Nothing found"), from markers.db only. A file that took longer than 12 seconds to check has error instead.
400 for a body that isn't {"paths": [...]} or has more than 30.
POST /api/inspector/show
Request: {"paths": ["/media/tv/Show (2024)", …]}: a show's folders (a search row's paths), at most 10.
Response: {"seasons": [{"season": 1, "label": "Season 1", "episodes": [{"path", "code": "E01", "episode": 1,
"markers": {"state", "label"}}]}]}, seasons in order with Specials last, read from the folders on disk. 400 when
no path is a folder inside a server library.
GET /api/inspector/file
Query: path, the file's local path.
Response: canonical_path; exists (false: gone from disk; null for a path that is neither in a library nor in
markers.db, which isn't looked at); in_library; known (markers.db has it); title (from its folders, e.g. "Blood
Legacy (2024) · S01E01"); kind; quality; and, for a file that is there and in a library:
duration_ms: markers.db's, else the preview's frames × interval.previews: one row per server holding the file —server_id,server_name,server_type,kind(bifortrickplay),path(Plex's bundle BIF, Emby's BIF next to the video, Jellyfin's sheet folder),exists,note(why there is no path, e.g. "Plex hasn't analysed this file yet"),error(e.g. "Couldn't reach Jellyfin"),versions, and when it existsframe_count,interval_ms,file_size,created_at(plustile_width,tile_height,sheets_dirfor trickplay). Each server is waited for at most 8 seconds.preview: the row the frames are drawn from (the first BIF that is there, else trickplay), withinterval_mschecked against the file's length: a BIF header that leaves the interval out, or disagrees with length ÷ frames by more than a quarter, gets length ÷ frames.versions:[{path, label, current}]when the server keeps several versions of the item on this disk.job: the queued or running job for this file (id,kind,status,name,percent), or null.loudness: per-server Plex audio measurements, read without starting analysis or changing metadata. Each row containsserver_id,server_name,server_type,enabled(this file's library opt-in, not database write readiness),source,source_note,state,noteandstreams. State isavailable,not_analysed,partial,unavailableorunsupported.availablemeans Plex reports recognized measurements and normalization capability; it does not prove that Plex has reanalysed a same-size replacement at the same path.
Each loudness stream identifies item_id, part_id, part_file (basename), stream_id, index, codec, language,
channels, title and default. Its result contains state, note, analysis_version, normalization_available,
integrated_lufs, true_peak_dbtp, lra_lu, threshold_lufs and gain_offset_db. normalization_available describes
Plex's capability, not the player's Normalize Loudness switch. Measurements are finite JSON numbers or null;
recognized native silence/short-track sentinel values use the strings "-inf" and "inf" where applicable. Invalid
or unknown reports remain distinguishable from measurements that are absent. Complete native values remain
visible when the app's loudness opt-in is off.
400 when path isn't an absolute path inside the media folder.
GET /api/inspector/frames
Query: path (required), start_ms (default 0), count (1-14, default 7), width (160, 240, 320 or 480;
default 320). Limited to 120 requests a minute.
Response: {"path", "start_ms", "step_ms": 1000, "frames": [{"t_ms": 5557000, "src": "data:image/jpeg;base64,…"}]}
— fewer frames when the video ends first. Works for a file with no preview.
Only a file the app already knows is read: it has to be a video inside a server library and the media folder
(normalised, no ..), and either markers.db has it or a server holding it lists it (remembered for 10 minutes).
ffmpeg seeks (-ss before the input) and reads one frame a second on the CPU at low priority (nice), HDR10 and
HLG tone-mapped with the previews' tone-map setting; at most two reads run at a time, and one still running after
30 seconds is killed. Answers are cached in a private 64 MB folder under the system temp folder; nothing is written
next to the media. 400 for a bad query or a path that isn't a video in a library; 404 for a file the app doesn't
know; 502 when ffmpeg can't read the frames; 503 when both reads were busy for 2 seconds (the page asks again).
Webhook Endpoints
Inbound webhook endpoints for Radarr, Sonarr, Plex, Emby, Jellyfin and custom integrations. Each call must carry the API token or the webhook_secret, sent as an X-Auth-Token header, an Authorization: Bearer header, the password of HTTP Basic auth, or a ?token= query parameter (for senders that can't set headers). They never need a CSRF token.
Tip
The universal webhook URL at POST /api/webhooks/incoming auto-detects the vendor (Plex / Emby / Jellyfin / Sonarr / Radarr / templated path) so you only need one URL across every server. Falls back to per-server URLs at POST /api/webhooks/server/<server_id> for ambiguous setups (rare). See Multi-Media-Server — Webhook configuration for details.
Webhook delay parameter
All ingestion routes — POST /api/webhooks/radarr, /sonarr, /sportarr, /custom, /plex, /incoming, and /server/<server_id> — accept optional delay=<seconds> in the URL query string. The value must be a whole number from 1 to 3600 (up to one hour); invalid values return 400. Omission uses the current webhook_delay setting.
Examples: /api/webhooks/radarr?delay=30, /api/webhooks/sonarr?delay=300, /api/webhooks/incoming?token=YOUR_TOKEN&delay=30, or /api/webhooks/server/plex-main?delay=30. Use &delay=30 when another query parameter is already present.
On /radarr, /sonarr, /sportarr, /custom, and legacy /plex, each accepted new file resets its source/server batch deadline using that request's effective delay. The latest request sets the timer if values differ within one batch, subject to a maximum batch age equal to the greater of 600 seconds and the longest delay accepted into that batch. The age is measured from its first file, so late arrivals can receive less than the full delay; arrivals after the limit open a new batch. Ignored duplicates do not reset the deadline. The batch age limit is persisted with the job.
On /incoming and /server/<server_id>, each resolved file version keeps its own job and initial deadline. The wait starts when that job is queued, after any vendor API lookup needed to resolve the payload. Jobs retain their vendor item IDs, publisher pin, and optional regenerate flag. Later arrivals do not reset other jobs' deadlines; existing duplicate suppression still applies. When delay is omitted they use the global webhook_delay. Use delay=1 for the shortest supported wait.
All pending webhook deadlines are persisted and restored by automatic restart recovery. Processing still honors pause and worker availability. Fire now skips the remaining initial wait; automatic retries use their separate retry backoff, and manual Reprocess starts without the original batching delay.
Initial media-server scan requests on source-specific import routes remain immediate; the delay controls job processing.
POST /api/webhooks/incoming
Universal webhook router. Inspect the request body, classify it as Plex / Emby / Jellyfin / Sonarr / Radarr / generic-{path: ...}, and dispatch to every server that owns the resolved canonical path. Works alongside the per-vendor URLs below — you can keep using those, or replace them all with this one.
Returns 202 with status: "queued", kind, canonical_path, and job_id for one resolved version; multiple versions return a jobs array and version_count. Jobs wait for the webhook delay before processing. Duplicate notifications return 202 with status: "ignored_duplicate"; noise events return 202 with status: "ignored". Returns 400 for unrecognised payloads or invalid delays, 401 for bad auth, and 413 for payloads above the 1 MiB cap.
POST /api/webhooks/server/{server_id}
Same as /api/webhooks/incoming but pins dispatch to one configured server. Useful when two configured servers (e.g. Plex + Jellyfin) own the same path and the source can't tell them apart — the URL itself carries the disambiguation. Returns 404 when the server id isn't configured.
POST /api/webhooks/radarr
Receive a Radarr webhook payload.
Download event request:
{
"eventType": "Download",
"movie": {
"title": "Inception",
"folderPath": "/movies/Inception (2010)"
}
}
Response (202): {"success": true, "message": "Processing queued for 'Inception'"}
Test event: {"eventType": "Test"} → Response (200): {"success": true, "message": "Radarr webhook configured successfully"}
POST /api/webhooks/sonarr
Same authentication and response patterns as Radarr.
Download event request:
{
"eventType": "Download",
"series": { "title": "Breaking Bad" },
"episodeFile": { "relativePath": "Season 01/S01E01.mkv" }
}
POST /api/webhooks/custom
Receive a custom webhook payload from any external tool (Tdarr, scripts, etc.). Accepts one or more file paths to process.
Single file request:
{
"file_path": "/media/movies/Movie (2024)/Movie.mkv"
}
Multiple files request:
{
"file_paths": [
"/media/tv/Show/Season 01/S01E01.mkv",
"/media/tv/Show/Season 01/S01E02.mkv"
],
"title": "Optional display label"
}
| Field | Type | Required | Description |
|---|---|---|---|
file_path |
string | One of file_path / file_paths |
Single absolute file path |
file_paths |
array of strings | One of file_path / file_paths |
Multiple absolute file paths |
title |
string | No | Display label for history/jobs |
eventType |
string | No | Set to "Test" to verify connectivity |
Response (202): {"success": true, "message": "Processing queued for 1 file"}
Test event: {"eventType": "Test"} → Response (200): {"success": true, "message": "Custom webhook configured successfully"}
Error (400): {"success": false, "error": "Payload must include 'file_path' (string) or 'file_paths' (array of strings)"}
POST /api/webhooks/plex
Receive a native Plex webhook (Plex Pass feature). Plex POSTs multipart/form-data with a payload part containing the JSON event body. Only library.new events trigger work; other events (media.play, media.rate, library.on.deck, etc.) are acknowledged with 200 and ignored.
The endpoint also accepts a synthetic test.ping event, which it records in the history and otherwise ignores.
library.new payload (excerpt):
{
"event": "library.new",
"owner": true,
"Metadata": {
"ratingKey": "153037",
"type": "movie",
"title": "Some Movie",
"Media": [{ "Part": [{ "file": "/data/movies/Some Movie/Some Movie.mkv" }] }]
}
}
When Media[].Part[].file is missing from the payload (Plex doesn't always include it), the app fetches the item by ratingKey via the Plex API to recover the file paths.
Authentication: same as the other webhook endpoints. Plex can only send a ?token= query parameter, so the registered URL carries the webhook secret (or the API token when no secret is set).
Important
Plex's library.new webhook is wired through the same code path as mobile push notifications. If push notifications are disabled on your Plex server, library events are silently dropped — enable them under Plex Web → Settings → General (toggle Enable mobile push notifications). See the Auto-trigger from Plex guide for full details.
POST /api/settings/plex_webhook/register
Register the Plex direct webhook with the plex.tv account of a Plex server, using that server's token. The registered URL
points at /api/webhooks/server/{server_id} and carries the webhook secret.
Request body:
{ "server_id": "plex-household", "public_url": "http://your-host:8080" }
Both fields are optional. server_id defaults to the first Plex server. public_url defaults to the URL saved on the
server (output.webhook_public_url), or else the address of this request. Only its scheme and host are kept. It is saved
on the server entry.
Response (200): {"success": true, "server_id": "...", "registered_in_plex": true, "public_url": "..."}
Errors:
400withreasonmissing_token,missing_urlormissing_auth_token403withreason: "plex_pass_required"404withreason: "server_not_found"502when the call to plex.tv failed
POST /api/settings/plex_webhook/unregister
Remove the Plex direct webhook from the server's plex.tv account. Optional server_id as above. Returns
{"success": true, "server_id": "...", "registered_in_plex": false}.
GET /api/settings/plex_webhook/status
Probe the live state. Optional ?server_id=. Returns the saved public URL, whether it is registered with Plex, and Plex Pass detection.
{
"server_id": "plex-household",
"server_name": "Household Plex",
"registered_in_plex": true,
"public_url": "http://your-host:8080/api/webhooks/server/plex-household",
"default_url": "http://your-host:8080/api/webhooks/server/plex-household",
"has_plex_pass": true,
"error": null,
"error_reason": null,
"warning": null
}
GET /api/settings/emby_webhook/info · GET /api/settings/jellyfin_webhook/info
The URL and auth details to put into Emby's webhook setup or Jellyfin's Webhook plugin. Requires ?server_id= (400 without it, 404 for an unknown server, 400 for a server of the other type).
To run a Recently Added scan immediately, call POST /api/schedules/<id>/run on the scanner schedule.
GET /api/webhooks/history
Get recent webhook events (newest first, max 100). For events with status: "triggered" (a debounced batch that was processed), the response may include job_id, path_count, and files_preview (up to 20 basenames) so the UI can show which files were in the batch. File lists are also available on the Dashboard job queue (expand with the chevron next to "Sonarr: N files" / "Radarr: N files" / "Custom: N files") and on the Automation page (Triggers tab) Activity Log (expand triggered rows).
{
"events": [
{
"timestamp": "2026-02-12T10:30:00+00:00",
"source": "sonarr",
"event_type": "Download",
"title": "sonarr",
"status": "triggered",
"job_id": "abc-123",
"path_count": 3,
"files_preview": ["S01E01.mkv", "S01E02.mkv", "S01E03.mkv"]
}
]
}
DELETE /api/webhooks/history
Clear all webhook history. Returns {"success": true}.
Error Responses
Errors return a JSON object with an error message. Some add fields such as reason, hint or success: false.
{"error": "Error message"}
| HTTP status | Meaning |
|---|---|
400 |
Invalid request data, or a missing or stale CSRF token on a browser request |
401 |
Missing or invalid token: {"error": "Authentication required"} |
404 |
Unknown job, schedule, server or other resource |
409 |
The request conflicts with the current state (for example a stale worker-group revision) |
429 |
Rate limit hit (see Rate Limiting) |
500 |
Internal error. The app log has the details. |
503 |
The config folder isn't writable |
WebSocket Events
The dashboard uses Flask-SocketIO for live updates. The client connects to the /jobs namespace and must be signed in.
const socket = io('/jobs', {
transports: ['websocket', 'polling'],
reconnection: true
});
Events on /jobs (the payload is the object itself):
| Event | Payload |
|---|---|
job_created, job_started, job_updated, job_completed, job_failed, job_cancelled |
The job object |
job_progress |
{job_id, kind, progress, publishers} |
job_paused, job_resumed |
{job_id, paused} |
job_deleted |
{job_id} |
jobs_cleared |
{count} |
worker_update |
{workers: [...]}, the worker status rows |
Send subscribe or unsubscribe with {"job_id": "..."} to join or leave a job's room.
Example job_progress payload:
{
"job_id": "job-123",
"kind": "loudness",
"progress": {
"percent": 50.0,
"processed_items": 50,
"total_items": 100,
"current_item": "Checking Plex loudness…",
"current_files": ["/media/movies/Film.mkv"]
},
"publishers": []
}
Loudness jobs expose progress.current_files in events and full job
snapshots: canonical local paths currently being checked or processed. This list is bounded by active execution
slots, not library size; a path can occur twice while two slots are using it. The Inspector uses these paths to track
library jobs and webhook jobs whose sender paths differ from local paths, then refreshes results when the job ends.
The /logs namespace streams log lines: log_message events, filtered by level. Send set_level with {"level": "INFO"}
(DEBUG, INFO, WARNING, ERROR or CRITICAL) to change the minimum level. New connections start at the configured log_level.
Complete Endpoint Index
The sections above cover the endpoints most integrations need. This index
catalogues the remaining routes — mostly internal APIs the web UI calls, but
documented here so you can drive them from scripts if you want. All require
the same X-Auth-Token / Authorization: Bearer auth as the rest of the API
unless noted.
Auth & token management
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/auth/status |
Session auth state — used by the UI on page load |
| POST | /api/auth/login · /api/auth/logout |
Session login/logout (cookie-based, for the browser: needs the page's CSRF token; scripts send the API token instead) |
| POST | /logout |
Sign this browser out (the nav's Logout, with the page's CSRF token). GET /logout only shows a "Sign out?" page |
| POST | /api/token/regenerate |
Rotate the stored API token (disabled when WEB_AUTH_TOKEN is set) |
| POST | /api/token/set |
Set a custom token — min 8 chars; returns {success: false, error: ...} on validation failure |
Jobs (beyond the basics in Jobs Endpoints)
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/jobs/manual |
Process a file or folder. {"file_paths": ["/a.mkv", "/tv/Show"], "force_regenerate": false, "priority": 2, "server_id": "..."}. Directories are expanded to the video files inside; bypasses library scan. Paths must be inside MEDIA_ROOT. 201 with the job. |
| GET | /api/media/search |
Backs the Manual Generation typeahead. ?q= (min 2 chars), optional ?server_id= to scope to one server. Fans across enabled servers and returns {results: [{kind: "show"\|"movie"\|"episode", title, year, paths: [local container paths], child_count, servers: [{id, name, type}]}]}. Shows resolve to their folder(s); the same item reported by several servers is merged into one row (union of paths + servers). |
| POST | /api/jobs/{id}/reprocess |
Re-run a finished job with the same config: 201 with the new job (an Intro & Credits job keeps its schedule), 409 while it's pending or running. A Check servers job is queued like POST /api/markers/reconcile and answers the same way (202 {"job_id", "already_queued"}, reusing one already queued or running). A Re-run clears the manual global hold; the pause schedule still applies. |
| POST | /api/jobs/{id}/fire-webhook-now |
Skip the initial wait on a pending webhook job, including source-specific batches and universal/per-server jobs. Returns 202 when dispatched, or 404 when no pending webhook timer exists for the job. Automatic restart recovery restores pending timers from saved job deadlines. |
| GET | /api/jobs/{id}/logs |
Paginated log stream: ?offset=&limit= (limit capped at 5000), or ?last=N for the tail |
| GET | /api/jobs/{id}/files |
Per-file outcomes — paginated ?page=&per_page= (per_page capped at 500), plus optional ?outcome= and ?search= filters. Use ?view=requested to list the complete selected paths, with search and pagination, instead of recorded outcomes; requested paths do not imply completed processing. The underlying outcome JSONL is soft-capped at 5000 rows; past that, a truncated marker row appears and aggregate counts remain in progress.outcome. |
| POST | /api/jobs/cancel-bulk |
Cancel several waiting, paused or running jobs. Body {"job_ids": ["..."]} (1 to 500 ids, otherwise 400). Finished and unknown jobs are skipped. Returns {"cancelled": [ids], "skipped": [{"id": "...", "reason": "not_found" \| "not_active (<status>)"}]}. |
| POST | /api/jobs/clear |
Delete finished jobs. Optional body {"statuses": ["completed", "failed", "cancelled"]} (default: all three), or {"job_ids": ["..."]} to delete only those jobs (not both). With job_ids, only finished jobs are removed; others come back in skipped. Returns {"success": true, "cleared": N}; the job_ids form also returns removed (ids deleted, including finished retry rows of a cleared chain head) and skipped ([{"id": "...", "reason": "not_found" \| "not_finished (<status>)" \| "retry_in_progress"}]). |
| GET | /api/jobs/stats |
Counts of visible jobs: total, pending, running, completed, failed, cancelled (retry attempts are not counted separately) |
| GET | /api/jobs/workers |
Current worker-pool snapshot (type, state, current item, group_id, group_name, group_resource, retiring, and node ({"id", "name"}), node_state and node_cause (why an offline node is offline: restarting, rate_limited...) for a node's worker, null for this app's). Busy workers include their actual job_id and current_file; job_kind and paused reflect that job's current state. Idle workers have no current job or file. Global processing pause is separate. |
| POST | /api/workers/add · /api/workers/remove |
Scaling by type: {"worker_type":"CPU"\|"GPU","count":N,"group_id":"..."}. Changes a saved group; omit group_id only when exactly one group matches the resource. Ambiguous selection returns 409. Busy removed workers finish their current file. |
| POST | /api/jobs/{id}/workers/add · /api/jobs/{id}/workers/remove |
Aliases of the above for a running job; select the group as above. Changes the shared saved group, not a temporary allocation exclusive to that job. |
Pause schedule
| Method | Endpoint | Description |
|---|---|---|
| GET · POST | /api/quiet-hours |
Read or write the Global pause schedule (Settings → Pause schedule). Body: {"enabled": bool, "windows": [{"start": "HH:MM", "end": "HH:MM", "days": ["mon","tue",...]}]}. Days are the day a window starts (mon…sun; omit for every day), so Monday 23:00–07:00 ends Tuesday morning. The response adds day_basis: "start" and currently_in_quiet_window. |
Settings & setup
| Method | Endpoint | Description |
|---|---|---|
| PUT | /api/settings/log-level |
Change runtime log verbosity ({"level": "DEBUG"\|"INFO"\|...}) |
| POST | /api/settings/validate-local-path |
Pre-flight a mount/volume path before saving (exists + readable) |
| POST | /api/settings/validate-plex-config-folder |
Pre-flight a Plex config folder (looks for Cache/Media/Metadata). Body {"path": "..."} |
| POST | /api/settings/validate-jellyfin-config-folder |
Pre-flight a Jellyfin config folder. Body {"path": "..."} |
| GET | /api/settings/backups |
List the backups of each config file (settings.json, schedules.json, webhook_history.json, setup_state.json) |
| POST | /api/settings/backups/restore |
Restore a backup of one config file. Body {"file": "settings.json", "backup": "<filename from the list>"}; without backup the newest is restored. The current file is backed up first. A backup that can't be read, or one a newer version saved, is refused with 409 and nothing changes. |
| POST | /api/setup/skip |
Skip the setup wizard (advanced — saves setup_complete=true with minimal state) |
| POST | /api/setup/validate-paths |
Pre-flight wizard path fields in bulk |
| POST | /api/setup/preview-file-path |
Resolve one real file against draft mappings and report container file/read-access status; does not save or start processing |
Servers (beyond the basics in Multi-Media-Server Endpoints)
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/servers/{id}/test-connection |
Re-test a saved server's live connection |
| PATCH | /api/servers/{id}/enabled |
Enable or disable a server without deleting it. Body {"enabled": true\|false}. Returns {"server_id", "enabled"}. |
| POST | /api/servers/{id}/vendor-extraction |
Turn the server's own scan-time preview generation off or on. Body {"scan_extraction": true\|false}. |
| POST | /api/servers/{id}/scheduled-trickplay |
Emby and Jellyfin. Turn the scheduled trickplay task off or on. Body {"enabled": true\|false}. |
| GET | /api/servers/{id}/vendor-extraction/status |
Current aggregate state (e.g. "stopped on 3/5 libraries") |
| POST | /api/servers/{id}/trickplay-fix-all |
Jellyfin only. Apply all recommended trickplay settings. Body {"install_plugin": true\|false} (default true). Returns {ok, steps, error}. |
Preview files (BIF viewer)
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/bif/search?q= |
Search Plex for media and report which items have a BIF. Min 2 characters. Limited to 10 per minute. |
| GET | /api/bif/info?path= |
Header metadata of a .bif file (frame count, interval) |
| GET | /api/bif/frame?path=&index= |
One JPEG frame (zero-based index) from a .bif file |
The path must end in .bif and sit in the Plex config folder or a server's library folders. Anything else returns 400.
Webhooks (beyond the basics in Webhook Endpoints)
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/webhooks/sportarr |
Sonarr-compatible feed for Sportarr (falls back to flat filePath) |
| GET | /api/webhooks/pending |
Pending webhook waits — batch or job key, countdown, and queued paths |
| POST | /api/webhooks/pending/{debounce_key}/fire-now |
Skip the initial timer and dispatch the pending webhook job immediately |
System & diagnostics
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/system/timezone |
Container TZ + source (env var vs /etc/localtime) |
| GET | /api/system/media-servers |
Multi-server aggregate health (per-vendor connection + library counts) |
| GET | /api/system/vulkan |
Vulkan ICD probe result (device, driver version, ICD files loaded) |
| GET | /api/system/vulkan/debug |
Plain-text diagnostic bundle for attaching to GitHub issues (DV Profile 5 troubleshooting) |
| GET | /api/system/config-health |
Whether the config folder is writable, plus advisories and unhealthy media mounts (used by the dashboard banner) |
| POST | /api/system/config-health/dismiss |
Permanently dismiss one non-fatal config-health advisory |
| POST | /api/system/rescan-gpus |
Re-probe all GPUs (refreshes gpu_config candidate list) |
| GET | /api/system/version |
App version + commit SHA + build date |
| GET | /api/system/browse |
Folder picker: lists sub-directories of ?path= (default /). ?include_files=1 also returns video files (each entry has is_dir); ?show_hidden=1 includes dot-entries. System dirs (/proc, /sys, …) are denied. |
| GET | /api/system/notifications |
In-app notification list (health checks, deprecations, warnings) |
| POST | /api/system/notifications/{id}/dismiss |
Dismiss until the next restart. Needs login or an API token; 400 for an id the app doesn't show |
| POST | /api/system/notifications/{id}/dismiss-permanent |
Dismiss for good (stored in settings). Needs login or an API token. 400 for an unknown id, and for media_mount_unhealthy (a media folder that looks empty or unmounted) and gpu_keeps_failing_<device> (a GPU whose last 5 files ran on the CPU), which can only be dismissed until the next restart. A dismissed Settings migrated notice comes back after the next upgrade that changes the settings format |
| POST | /api/system/notifications/reset-dismissed |
Clear all permanent dismissals |
| GET | /api/system/whats-new |
Release-notes viewer payload (version + changes since last-seen) |
| POST | /api/system/whats-new/dismiss |
Mark the current version's notes as seen |
| GET | /api/logs/history |
Persisted log history — ?limit= (default 500, max 2000), ?level= (minimum level filter), ?before= (ISO-8601 timestamp cursor for older-than paging) |
Rate Limiting
Only these endpoints are limited, per client address:
| Endpoint | Limit |
|---|---|
POST /login |
5 per minute |
POST /api/auth/login |
10 per minute |
POST /api/inspector/status, GET /api/inspector/frames |
120 per minute |
POST /api/inspector/show |
60 per minute |
GET /api/media/search |
20 per minute |
GET /api/bif/search, GET /api/bif/servers/{id}/search |
10 per minute |
Dashboard polling endpoints have no limit. A request over the limit gets 429.
Next Steps
- Complete install and setup in Getting Started
- Use operational workflows in Guides & Troubleshooting
Edit this page on GitHub