One preview job for Plex, Emby and Jellyfin

Offload preview generation to another machine and reuse one decode across Plex, Emby and Jellyfin. Check media mounts, output paths and server results.

The tool can drive any number of Plex, Emby, and Jellyfin servers from a single instance. A new file is processed exactly once (one FFmpeg pass on the GPU) and the resulting frames are published to every configured server that owns it, in the format that server expects.

Connect and check your servers

  1. Install the container where you want the processing to run. Its media paths and output mounts must reach the same files your servers use.
  2. Open Servers → Add Server, choose Plex, Emby or Jellyfin, and complete the connection flow. Click Test connection, then Save server. Repeat for each server. The Connection tab of Edit holds authentication and the Plex database choice.
  3. Click Edit on a server card, then open Libraries to choose which libraries get previews. Use the Processing tab for that server's chapter thumbnails, Intro & Credits and, on a supported Plex server, loudness.
  4. Check the Path mappings tab when the server and container use different paths. Open Setup Health for each server and resolve the relevant output and library checks.
  5. Process one shared movie or episode with Process a file or folder on the Dashboard. Expand All jobs → Job details to check results for each server, then inspect the output in its player. Open logs and files preserves the per-file result when one server is waiting or failed.

Connection, library choice and output health are separate checks: a reachable server may still need a writable output mount or a plugin before it can use the result.

Offload preview generation to another machine

The simplest way is a worker node: the app stays where it is and hands the heavy work to the other machine's GPU, which only needs to read your media. The app keeps every write.

The container itself can also run on a separate GPU machine while Plex, Emby or Jellyfin stays on the media server. Both machines must reach the same source files, and the container needs network access to each server's API. Map the server's media paths to the paths inside this container.

Start with one file and watch both the worker's activity and each server's saved result. A network share, slow disk or server indexing delay can be the limit even when the GPU has spare capacity.

Servers page with one card each for Plex, Jellyfin and Emby. Each card shows a status dot, an on/off switch, the server URL, how many libraries are enabled, and Edit, Refresh libraries and Delete buttons. An Add server tile sits below.

Note

The universal webhook URL is configured on the Automation page (top nav, between Servers and Settings) under Webhooks — not on the Servers page. The per-server Plex Direct webhook lives under Servers → Edit → Webhook & Scanner for the Plex server.

Output details and advanced integration:


Why this exists

Two reasons:

  1. Built-in generation has gaps.
    • Plex documents no GPU option for preview thumbnails.
    • Emby's Video Preview Thumbnail task is software-only (forum) — no GPU option.
    • Jellyfin can use hardware decoding for trickplay, but it's off by default, and by default the job runs at below-normal priority with one thread, inside the server process. (A historical issue with the legacy Intel i965 VAAPI driver on older Intel CPUs caused slowness for some users — that's been resolved upstream.)
  2. Multi-server users do redundant work. If you run more than one server (a surprisingly common setup), each one generates its own previews from the same source files. This tool processes each file once and publishes the result everywhere — Plex BIF bundle, Emby sidecar BIF, Jellyfin trickplay tiles — from a single FFmpeg pass.

Where this tool helps most: offloading preview generation onto a separate machine (a NAS, a dedicated GPU box, anything) so your media server's CPU and GPU stay free for playback.


How a webhook fires through the system

A single inbound URL, POST /api/webhooks/incoming, handles every source.

                  ┌────────────────────────────────────────────────────┐
  Plex multipart ─►                                                    │
  Emby JSON     ──►  classify_payload  ──►  match server by Server.uuid│
  Jellyfin JSON ──►   (vendor sniff)         / Server.Id / ServerId    │
  Sonarr/Radarr ──►                                                    │
  {"path": ...} ──►                                                    │
                  └────────────────────────────────────────────────────┘
                                               │
                                               ▼
                       resolve_item_to_remote_path  →  apply path_mappings
                                               │              ▼
                                               ▼      canonical local path
                                  process_canonical_path
                                               │
                                               ▼
                                  registry.find_owning_servers
                                               │
                                               ▼
                                 ONE FFmpeg pass → frame dir
                                               │
                       ┌───────────────────────┼───────────────────────┐
                       ▼                       ▼                       ▼
            Plex bundle BIF         Emby sidecar BIF        Jellyfin trickplay
            +background notification +Library/Media/Updated +Items/{id}/Refresh

Failures on one server don't take down the others — if Jellyfin's write fails the Emby sidecar still lands. The job log shows a per-server status row so you can see exactly what happened.

Note

Two terms used throughout the rest of this doc:

  • Dispatcher — the routing engine inside the app that decides which servers a file goes to and in what order.
  • Publisher — the per-vendor writer that produces the on-disk output (Plex BIF, Emby BIF sidecar, Jellyfin trickplay tiles).

Adding a server

Use the connection steps above for the web UI. The API examples below are for scripted integrations. All three terminate at POST /api/servers with an auth token already in hand.

Plex

In Servers → Add Server, choose Plex and click Sign in with Plex. Pick a server from Discovered Plex servers, set its config folder, then click Test connection and Save server. You can also enter a token by hand.

Emby

1. POST /api/servers/auth/emby/password
   { "url": "http://emby:8096", "username": "admin", "password": "..." }
   → returns { "access_token": "...", "user_id": "...", "server_id": "..." }

2. POST /api/servers
   { "type": "emby",
     "name": "Office Emby",
     "url": "http://emby:8096",
     "auth": {
       "method": "password",
       "access_token": "...",
       "user_id": "..."
     }
   }
   → returns the persisted entry with auth redacted; id is generated

API-key paste is also supported — skip step 1 and put {"method": "api_key", "api_key": "..."} straight into the auth block.

Quick Connect lets the user authorise this tool from inside their Jellyfin web UI without ever giving us a password. Quick Connect must be enabled by the Jellyfin admin under Server → Quick Connect.

1. POST /api/servers/auth/jellyfin/quick-connect/initiate
   { "url": "http://jellyfin:8096" }
   → returns { "code": "ABC123", "secret": "..." }

2. Display "code" to the user. They open Jellyfin → click their profile →
   Quick Connect → enter the code.

3. Poll until approved:
   POST /api/servers/auth/jellyfin/quick-connect/poll
   { "url": "http://jellyfin:8096", "secret": "..." }
   → { "authenticated": false }   (still pending)
   → { "authenticated": true }    (approved!)

4. Exchange the secret for a token:
   POST /api/servers/auth/jellyfin/quick-connect/exchange
   { "url": "http://jellyfin:8096", "secret": "..." }
   → { "access_token": "...", "user_id": "...", ... }

5. POST /api/servers with auth.method = "quick_connect" and the token.

Jellyfin (username + password fallback)

Same shape as Emby — POST /api/servers/auth/jellyfin/password.


Per-vendor output formats

Each server type expects a different on-disk layout. The app picks the right format automatically based on the server's type — you don't need to configure this.

Vendor Adapter Output path
Plex plex_bundle Inside Plex's config folder: Media/localhost/{hash}/.../index-sd.bif
Emby emby_sidecar Next to the media file: {basename}-{width}-{interval}.bif
Jellyfin jellyfin_trickplay Next to the media file: {basename}.trickplay/{width} - 10x10/{0,1,…}.jpg (folders of 10×10 tile sheets). Optionally off the media drive — see below.

Because Emby and default-layout Jellyfin write next to the media file, this container needs the media mounted read-write for them. A :ro mount makes each write fail with "Read-only file system". Plex only writes into its config folder, so :ro media is fine there. See Volume Mounts.

Why Jellyfin's format is different. Jellyfin (10.9 onwards) reads its own native JPG tile-grid format — not BIF. BIF would require users to install a third-party plugin (Jellyscrub) on their Jellyfin server. This app writes the native format, so no extra plugin is needed.

Optional: store Jellyfin trickplay off the media drive. By default the app writes tiles next to each video. If you'd rather keep the media drive clean (like Plex), turn on Store trickplay off the media drive on the Jellyfin server card. The app then writes into Jellyfin's data folder (<config>/data/trickplay/<id[:2]>/<id>/{width} - 10x10/) instead. This needs, all together: the Media Preview Bridge plugin installed, Jellyfin's config dir bind-mounted read-write into this container (set the path in the server's Jellyfin config folder field), and SaveTrickplayWithMedia off for the libraries. The Setup Health tab guides every step and flags anything missing. Flipping this on doesn't move tiles already written next to the media — they stay until cleaned up by the usual orphan sweep or removed manually. See Setup Health → off-media.

Required Jellyfin library settings. Three per-library settings need to be set so Jellyfin reads the trickplay folders this app writes — most importantly Save trickplay images to media folders = on. The Setup Health tab has a one-click Disable on this server button that flips all three correctly. See the in-app help for what each setting does.

Optional Jellyfin plugin (recommended). Installing the Media Preview Bridge plugin (one-click install from the Setup Health tab) makes trickplay register instantly the moment this app finishes writing the tiles. Without the plugin, keep Jellyfin's Extract trickplay images during library scan on: Jellyfin then picks the tiles up on its next library scan. With that flag off and no plugin, new tiles wait for Jellyfin's daily trickplay task (3 AM by default). See Jellyfin Plugin for details.

To install the plugin by hand instead, add this repository URL in Jellyfin → Dashboard → Plugins → Repositories, then install Media Preview Bridge from the Catalogue and restart Jellyfin. Updates then arrive through Jellyfin's normal plugin updates.

https://mediapreviewgenerator.dev/jellyfin-plugin/manifest.json

Plex generation without waiting for Plex

Plex destinations are calculated from the current media file by default. No item ID or live Plex lookup is needed to write previews. Use Manual Generation to browse a file or folder, or send a webhook containing a media path. The server, enabled library roots, path mappings, and writable Plex config folder must already be configured. Title searches, library enumeration, and events that contain only a server item ID still need the server to resolve their input files.

The hash calculation reads at most the first and last 64 KiB. It uses the current source instead of a hash from Plex's last scan, which may describe a file replaced at the same path. Files smaller than 65,536 bytes use decimal size followed by the file's hexadecimal SHA-1 digest. At 65,536 bytes and above, Plex uses SHA-1 of decimal size followed by both hexadecimal block digests, even when the two blocks overlap. These cases were checked against a live Plex server.

After writing, a background queue requests analysis for a known item ID; without an ID, it first requests a scan and tries to resolve the file. Generation does not wait for that network work. The queue holds up to 128 pending files and coalesces duplicate notifications. Nothing about a notification is saved: when a later job finds a preview already present, it reads Plex's own flag for that item and asks for analysis only when Plex isn't showing the previews yet (an earlier request failed, the queue was full, or the preview was written before an upgrade). Such catch-up requests use at most half the queue, and a file Plex has already analyzed since the app started isn't asked about again. There is no separate automatic retry timer.

A written preview and Plex advertising it as available are separate steps: when Plex is offline or has not indexed the file, a later scan/analysis can be needed. Notification failures do not discard a successfully written preview.

Clean Bundles can delete previews for files Plex has not indexed yet. Live testing confirmed deletion before indexing and preservation after indexing. When multiple Plex servers share the same Media directory, consider each server's library membership before running cleanup. Generating locally does not coordinate Plex's cleanup tasks.


Webhook configuration per vendor

Set the same URL — https://<this-tool>/api/webhooks/incoming?token=<webhook_secret> — in every vendor's webhook UI. The token query parameter is required for Plex (Plex's webhook UI offers no header support); other vendors can use the X-Auth-Token header instead.

The router auto-detects the vendor by payload shape and matches the source server by the identifier embedded in every vendor's payload (Plex's Server.uuid, Emby's Server.Id, Jellyfin's ServerId).

Every webhook ingestion URL accepts delay=1 through delay=3600 (seconds). For example, append &delay=30 to the token-bearing URL above, or use /api/webhooks/server/<server_id>?delay=30. Omitting it uses the global Delay before processing setting (60 seconds by default).

Universal and per-server URLs apply this initial wait to each resolved file version, without merging separate jobs. Use delay=1 for the shortest wait. Source-specific URLs such as /api/webhooks/radarr?delay=30 and /api/webhooks/sonarr?delay=300 instead batch new files from the same source/server and reset the batch timer. See Set a delay per webhook URL.

Vendor Webhook source
Plex Server settings → Webhooks → Add webhook (Plex Pass required for outbound webhooks)
Emby Server settings → Notifications → Webhooks (Emby Premiere required)
Jellyfin Install jellyfin-plugin-webhook, configure under Plugins → Webhook
Sonarr / Radarr Settings → Connect → +Webhook

For Jellyfin, the plugin's stock ItemAdded template carries ItemId / ItemType / ServerId but not the file path. The router calls back to Jellyfin's API once to translate the id to a path. If you want to skip that callback, configure the plugin's template body to be:

{
  "path": "{{Item.Path}}",
  "trigger": "file_added"
}

That bypasses the auto-detection altogether and treats the payload as a path-first webhook.

Per-server fallback URL

If two servers share a server identifier (rare; usually only happens with cloned VMs) auto-detection can't disambiguate. Use the explicit per-server URL: POST /api/webhooks/server/<server_id>.


Library ownership and retry semantics

The dispatcher distinguishes three cases when a webhook fires:

Case Response
1. Path is under no enabled library on this server Skip permanently (status no_owners)
2. Path is under an enabled library, but the server hasn't scanned the file yet Plex and Emby write from the local file; outputs needing a server item ID use the slow-backoff retry queue (skipped_not_indexed)
3. Server is unreachable Plex and Emby path-based generation can continue; server-dependent discovery or output registration can fail or wait for a later scan

Ownership is decided from the cached libraries[] snapshot in each server config — no per-file index. Toggle a library off via the per-library enabled flag and the dispatcher will skip files in that library cleanly with no retry storm.


Smart dedup: skipping work that's already done

Two layers prevent the same file being processed twice:

  1. Short-term frame cache — when a second webhook arrives for the same file shortly after the first (e.g. Sonarr and Plex both notify within minutes), the second one reuses the already-extracted JPGs instead of running FFmpeg again. The cache checks file identity (device and inode), size, and nanosecond modification time, so a replacement with preserved timestamps does not reuse the old frames. It holds the most recent files for a configurable window (default 1 hour). Concurrent webhooks for the same file (a "webhook storm") collapse into a single FFmpeg pass.

  2. Long-term tracking — Plex needs no companion file: it stores each preview under a name made from the video's content, so a preview that exists there was made from that content. If a file is replaced by different content, the new content gets its own preview and the old one is left behind. On a later run the app skips extraction when the preview exists and isn't empty; if Plex doesn't show previews for that file yet (a preview written after Plex scanned it, or an Analyze that never ran), the app asks Plex to analyze it.

    Emby and Jellyfin outputs get a small companion file (<file>.bif.meta) that records the source file's size and last-modified time. It deliberately ignores the file's device and inode numbers: a remount (rclone, an Unraid user share) or Unraid's mover changes those without touching the video, and checking them rebuilt every preview. On any later webhook, the app checks this companion file first — if every output already exists, isn't empty, and the source hasn't changed, extraction is skipped.

    When the source file does change (a Sonarr quality upgrade swaps the file in place), the comparison fails and FFmpeg re-runs automatically. To force regeneration manually (e.g. you changed the thumbnail quality), tick Regenerate when starting a job — that bypasses both layers.

    Plex remembers where each preview image sits in a preview file the first time someone views it, and never re-reads a file that changes afterwards. So after Regenerate replaces a Plex preview that someone has viewed since Plex last started, Plex shows broken preview images for that video until Plex is restarted. The job log warns when this can happen.

Emby and Jellyfin outputs without a tracking file are treated as fresh on the next webhook, then stamped on the next publish. Older tracking files are not used to copy frames into another server's missing output unless their source identity can be verified. BIF files are staged beside the destination and atomically replaced only after packing finishes; a failed write leaves the previous preview intact.


Slow-backoff retry queue

When a publisher returns SKIPPED_NOT_INDEXED because its output still needs server metadata, the dispatcher schedules a retry instead of waiting for a manual re-run. Plex bundle publishing calculates its hash locally and does not enter this queue merely because Plex has not indexed a file.

The same retry also covers files no server knows yet, stale paths, and Emby/Jellyfin publishes still waiting for the server to register the item.

Backoff schedule: the wait before each retry is 1 m → 2 m → 5 m → 15 m → 60 m. Two settings under Settings → Retry policy shape it:

One job per attempt: each retry runs as its own "Retry: …" job that covers only the files still pending, not the whole original batch. On the Dashboard's Jobs queue, a waiting retry shows a Retry n/N badge and a countdown such as "Retry starting in 2 min".

Reuses the dedup machinery: retries call back into the same dispatch code path, so the journal short-circuit, frame cache, and per-publisher skip-if-exists all apply on retry. Retries are cheap when the publish has already succeeded through some other path (e.g. Plex's own webhook firing after its scan completes).

The Jobs queue with four rows: Tears of Steel running at 62%, Sintel pending with Retry 2/5 and Retry starting in 4 min, Big Buck Bunny completed, and Elephants Dream pending


Setup Health — the unified "is everything set up right?" panel

Each server has its own settings that can quietly break previews — a Jellyfin flag off that silently deletes published tiles, a Plex FSEvent toggle that stops the library noticing new files, a plugin the Media Preview Bridge needs to register results instantly, a Plex config mount that's accidentally read-only. Setup Health is the single place the UI surfaces — and fixes — all of them.

How to open it

  1. Go to Servers in the top nav.
  2. Each server card shows a coloured glyph next to its name when the audit finds issues (❗ = critical, ⚠ = recommended). Click the glyph.
  3. The Edit Server modal opens directly on the Setup Health tab — every check grouped by section (Server status → Library settings → Advanced), each with an ⓘ explanation, a direct link to the detailed doc, and Enable/Disable toggles where they apply.

Destructive toggles. A handful of flips are data-destructive — the most important one is Jellyfin's EnableTrickplayImageExtraction. Turning it off makes Jellyfin delete every tile this app has written on the next library refresh. The UI requires you to type disable trickplay to confirm.

Per-check reference: full list of every audit with "what it checks / why it matters / how to fix" lives in the Setup Health guide.

Scripting it. For the underlying REST surface (GET /api/servers/{id}/previews-readiness, POST /api/servers/{id}/health-check/apply, POST /api/servers/{id}/install-plugin), see Reference — Multi-Media-Server Endpoints.


Inspector (multi-server)

The Inspector at /inspector (Tools → Inspector) shows a published preview frame by frame, and the file's intro and credits on each server. It works for all three vendors:

One search covers every server (or the one picked in its dropdown) and merges a film several servers have into one row. GET /api/inspector/file then says where each server keeps that file's preview, and the page draws its frames from the first one that is there. See Guides → Inspector and Reference → Inspector endpoints.

The older per-server search is still served for scripts:

GET /api/bif/servers/<server_id>/search?q=<query>

Returns up to 15 results: {title, type, year, media_file, preview_path, preview_kind, preview_exists}. preview_kind is "bif" (Plex/Emby) or "trickplay" (Jellyfin).


Plex multi-server auto-discovery

After you click Sign in with Plex (one PIN sign-in via plex.tv), the Discovered Plex servers list shows every server the account can access. Pick one to fill in its URL and name, then click Test connection and Save server.

To add another server from the same account, sign in again after saving the first. Each server gets its own entry with a distinct server_identity (Plex's clientIdentifier), so the webhook router can tell them apart.


REST API summary

Method Endpoint Description
GET /api/servers List all configured servers (auth redacted)
POST /api/servers Add a new server
GET /api/servers/<id> Get one server (auth redacted)
PUT/PATCH /api/servers/<id> Update fields (redacted auth values are kept)
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
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 Start 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 (all vendors)
POST /api/servers/<id>/health-check/apply One-click fix of mis-set settings (all vendors)
GET /api/bif/servers/<id>/search?q=... One server's preview search (the Inspector searches every server through /api/media/search); returns preview_kind per result
GET /api/bif/trickplay/info?server_id=...&path=... Parse a Jellyfin trickplay manifest + 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 from a tile sheet
POST /api/webhooks/incoming Universal webhook with vendor auto-detection
POST /api/webhooks/server/<id> Per-server fallback webhook URL

All endpoints (except /api/webhooks/* which use the webhook secret) accept the same X-Auth-Token / Authorization: Bearer headers as the rest of the REST API.

Edit this page on GitHub
Type to search the documentation.