Configuration & API Reference

Every Media Preview Generator setting, environment variable, path mapping and REST API endpoint, with defaults, request and response examples.

Back to Docs

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

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-readiness payload. 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.

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:

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:


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:

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:

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):

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

  1. Plex path: Plex Web → Settings → Libraries → Edit → Folders.
  2. Emby path: Emby Dashboard → Libraries → Edit → Folders.
  3. Jellyfin path: Jellyfin Dashboard → Libraries → Edit → Folders.
  4. Container path: Check your -v volume 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).

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."}

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:

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_prefix is the canonical key (works for Plex, Emby and Jellyfin). The old plex_prefix is 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:

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:

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:

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


Edit this page on GitHub
Type to search the documentation.