Plex Features & Capabilities

Configuration & Storage

  • Config keys documented in config.md/config.example.yml: plex_server_url, plex_token, plex_match_every_mins (default 1440), plex_watchlist_enabled, plex_watchlist_check_every_secs (default 5s), mount_path (required for matching/scan path building). Dashboard-authored settings are saved directly to config.yml via the config writer (updateConfigFileAtomic).
  • Both intervals fall back to their default only when the value is exactly 0 (intOr, internal/config/config.go:9-14); neither can be disabled. The dashboard rejects anything below 1 (internal/handlers/dashboard/dashboard.go:1168-1190), and a negative plex_match_every_mins written straight into config.yml panics the scheduler's ticker rather than turning matching off.
  • Watchlist acquisition is configured by the watchlist: block — enabled, check_every_secs, indexers, max_size_gb, max_season_size_gb and quality (default best). An empty indexers borrows stremio.indexers. The flat plex_watchlist_enabled, plex_watchlist_check_every_secs and watchlist_quality keys still count as the legacy spellings of the first, second and last of those. See config.md.
  • mount_path is also the prefix for scan paths sent to Plex and the managed rclone mount (config.md, pkg/scan/scanner.go).
  • On-library-update hook (on_library_update) is fed every changed path as arguments, debounced 500 ms and deduped, with one invocation at a time, enabling Plex autoscan scripts (internal/torrent/hooks.go).

Authentication & Server Discovery

  • Web dashboard flow: /plex/auth creates a PIN session (pkg/plex/auth.go, CreatePlexAuthURL), stores it in pkg/plex/auth_session.go (auto-cleanup after 10 min, swept every 5), and polls for completion via /plex/auth/status (PollForAuthToken, 60 attempts 5s apart before timing out).
  • /plex/callback serves a progress page that, once authorized, calls /plex/servers to list servers and /plex/server to select one (internal/handlers/dashboard/dashboard.go).
  • Server discovery (pkg/plex/auth.go): fetches Plex resources with both HTTP/HTTPS endpoints, filters relay if direct exists, handles IPv6 addresses, and probes each entry's /identity (TLS verification disabled). Only servers owned by the account are considered (device.Owned, auth.go:66) — shared servers never appear — and entries that fail the probe are dropped from the response rather than returned with last_error set (enrichServersWithIdentity, auth.go:111-150).
  • Selecting a server writes plex_server_url/plex_token to config, reinitializes the Plex client, (re)starts the matcher scheduler, fires a match trigger, and refreshes the cached dashboard status (HandlePlexServerSelect, internal/handlers/dashboard/dashboard.go:582-684). /plex/logout clears the stored values and the status cache.

Plex Client Capabilities (pkg/plex/client.go)

  • Connection lifecycle: deferred start option (WithDeferredStart), ValidateConnection guard against /identity, graceful Close. There is no restart API — the dashboard's RestartAllowed/RestartReason are inferred from the server URL (local/LAN vs. remote) and are display-only (internal/handlers/dashboard/plex_status.go:getPlexConnectionDetails).
  • Scan queue: dynamic job queue/worker (common.DynamicJobQueue); ProcessJob issues GET /library/sections/{key}/refresh with Plex headers and at most one path query param per job — more than one is rejected outright (client.go:313-315). A job with no path is a full-section refresh. 503 becomes a server-busy error, 404 reports the library as deleted from Plex; GetJobQueueSize exposes backlog.
  • Load/stuck detection: CheckServerLoad inspects /activities, tracks library.update.section progress per UUID (a changed progress or subtitle counts as progress), and IsJobStuck flags stale jobs by librarySectionID with a Title fallback. GetServerActivitiesDetails creates and retires the same trackers, because CheckServerLoad only runs while the worker has a job queued — a scan Plex started by itself used to get no tracker at all, so it could never be reported stuck and its elapsed time read as zero on every refresh. Both paths keep history for library.update.section only; tracking anything else here would flap against the worker's prune.
  • The activity subtitle is not the file a parked scan is blocked on. Measured on the plex503 rig, 2026-09-15: the library.update.section subtitle is the scanner's enumeration pointer and runs ahead of the reads. With the mount refusing three files it named a fourth that had never been requested, and it named the blocked file for 290 ms out of a 56 s cycle. The dashboard shows it as "Plex reports" and does not link it to a release; what the scan is actually waiting on comes from zurg's own refusals (internal/universal/refused_reads.go), which is the only side that knows. The corpus is in internal/handlers/dashboard/testdata/plex503-*.
  • Library queries: GetSections (movie/show sections with locations), GetSectionItemCount, FindSectionNormalized (matches a path against a section location and corrects the prefix to the library's casing), GetAllMediaItemsConcurrent (batched, concurrent fetch with includeGuids to collect paths, GUIDs, IMDB IDs), and GetShowLevelIMDBIndex (show-level IDs for TV sections so episodes can inherit them).
  • Metadata & matching helpers (pkg/plex/match.go): GetMetadataSummary (GET /library/metadata/{ratingKey}), GetMatchCandidates (GET /library/metadata/{ratingKey}/matches?manual=1, with title/year/language overrides), LookupMetadataByGUID/LookupRatingKeyByGUID (GET /library/all?guid=…), ApplyManualMatch (PUT /library/metadata/{ratingKey}/match), and GUID parsing (ExtractIMDBIDFromGUID, ExtractTMDBIDFromGUID, ExtractTVDBIDFromGUID, ExtractAnyIDFromGUIDs; MatchResult/MatchSelection types).
  • Server info: GetServerIdentityOnly and GetServerDetails (machine ID/version; the latter also totals sessions/transcodes/bandwidth), GetStreamingSessions, GetTranscodeSessions, GetServerActivitiesDetails (annotated with stuck status).
  • Watch history: GetWatchHistory (GET /status/sessions/history/all), GetMetadataBatch (comma-joined rating keys against /library/metadata), and GetWatchHistoryWithMetadata (pkg/plex/history.go), feeding the stats in pkg/plex/stats.go.

Media Scanner Integration (pkg/scan/scanner.go)

  • Initializes Plex client when mount_path, plex_server_url, and plex_token are set; retries every 5s until connected, firing OnPlexReconnected callbacks.
  • TriggerScan groups paths per Plex section (via FindSectionNormalized) and queues one refresh job per mount_path-prefixed path, logging progress every 50. Also supports Jellyfin/Emby, which take the non-grouped path. A path that lands in no section is only warned about when none of the batch's paths matched — otherwise it is a debug line, so a torrent in both a library dir and a zurg-only dir (recent, music, …) does not warn.
  • ReinitializePlexClient allows dashboard updates to swap servers/tokens live.
  • GetQueueSizes exposes Plex queue depth for UI/monitoring.

Matching & Metadata Sync (internal/plex/matcher.go, internal/plex/schedule.go)

  • Preconditions: requires configured Plex URL/token, mount_path, and a live scanner.PlexClient; otherwise returns explicit errors.
  • The only Plex state stored on a torrent is PlexRatingKey (show-level for episodic content) plus IMDBID (internal/torrent/torrent_types.go:99,106). Plex GUIDs are used in-flight for ID extraction and reporting but are never persisted.
  • Data gathering: pulls all torrents from the directory map and all Plex items via GetAllMediaItemsConcurrent, prefixed by a GetShowLevelIMDBIndex pass so episodes inherit the show's IMDB/TVDB/TMDB ID instead of an episode-level one. Items are keyed by GrandparentRatingKey when present.
  • Sync steps:
    • Validate every stored rating key against the freshly fetched Plex items; a key Plex no longer has is cleared along with the torrent's IMDB ID, and the torrent is requeued for matching.
    • Path-based matching for torrents without a rating key: buildPlexDirIndex indexes every lowercased path component of every Plex file, and the torrent's GetKey is looked up in it (O(1), first entry wins). Torrents whose assigned directories match no Plex section location are skipped first.
    • IMDB backfill: first from the already-fetched batch (no API calls), then per torrent via GetMetadataSummary (max 20 concurrent).
    • Detects Plex items with no torrents.
    • Sends every hash+IMDB pair to the DMM API — only when dmm_api_key is set (sendHashImdbPairs → TorrentManager.SendHashImdbPairs, internal/torrent/mediainfo.go:254-258).
  • MatchSingleTorrent runs the same path lookup for one torrent, for use right after a targeted scan; it no-ops if the torrent already has a rating key.
  • Reporting: writes logs/match_report.txt summarizing counts, unmatched torrents, skipped torrents, Plex items without torrents, and matches missing IMDB.
  • Scheduling (internal/plex/schedule.go): each completed run stamps data/plex_match_timestamp. On start, StartMatchingJob runs immediately only if that stamp is missing or older than plex_match_every_mins; otherwise it waits out the remainder first. It listens for manual triggers on TorrentManager.PlexMatchTrigger, for live interval changes on ResetPlexMatchTicker, and retries background panics up to 3 times.
  • Manual trigger endpoint /plex/match-torrents pushes to the trigger channel and refuses while a run is in flight; /plex/match-torrents/status reports whether one is running (internal/handlers/utilities.go).

Manual Matching & Manage UI (internal/handlers/manage)

  • Torrent list filters expose Plex states (plex_matched, plex_in_library_not_matched, plex_no_imdb, plex_missing_metadata) for quick views (torrent_list_state.go).
  • Per-torrent manual match page /manage/{hash}/plex-match:
    • Requires the torrent to already carry a PlexRatingKey (set by the matcher); the page reports "torrent is not linked to Plex" otherwise. resolvePlexRatingKey validates that key with GetMetadataSummary, backfills a missing IMDB ID from it, and on a stale key re-resolves via LookupRatingKeyByGUID using the torrent's stored tt…/tvdb:///tmdb:// ID (internal/handlers/manage/handler.go:93-151).
    • Fetches Plex manual match candidates with optional title/year/language overrides (44 language options, default en-US); renders matches with the current selection highlighted (torrent_matches.go).
    • Applying a match (HandleApplyPlexMatch) calls ApplyManualMatch, then re-looks-up the rating key by the chosen GUID because Plex may reassign it, updates the torrent's rating key and IMDB (including an optional IMDB supplied by the user), persists changes, and redirects with success/error states.

Watchlist acquisition

  • Plex watchlist is an adapter in internal/acquisition/plex, running on the shared acquisition service alongside Seerr. Existing watchlist settings work; adapters can also be configured under acquisition.sources.

  • The client uses discover.provider.plex.tv and pages 100 items at a time. The Plex token is read fresh for each request, so re-authentication applies without restarting the adapter.

  • The shared executor acquires movies or each planned season from Newznab and Torznab indexers, preferring season packs. TV searches try TVDB before IMDb because indexer mappings differ.

  • Progress, retry deadlines and per-episode receipts survive restarts in data/acquisition.json. The earlier Plex ledger is imported automatically.

  • Removal follows acquisition of every planned target. Failed requests stay on the watchlist and retry with persistent backoff. A removal-only retry does not acquire again. See Plex watchlist acquisition for limits and acquisition sources for the shared configuration.

Dashboard & API Endpoints (internal/handlers/router.go, internal/handlers/dashboard)

  • Routes: /plex/auth, /plex/callback, /plex/auth/status, /plex/servers, /plex/server, /plex/logout, /plex/match-torrents, /plex/match-torrents/status, /plex/scan/{sectionKey}, /plex/scan-all, /plex-stats, /plex-stats/data, /plex-stats/image, /manage/{hash}/plex-match.
  • Dashboard shows Plex server info and status via PlexStatusFetcher on two tickers: every 30s it refreshes streaming sessions, transcodes, bandwidth and server activities (GetStreamingSessions/GetTranscodeSessions/GetServerActivitiesDetails); every 5 minutes it does a full fetch that also re-reads identity (GetServerIdentityOnly), library sections and per-section item counts. Scan activities are cross-referenced against the torrent list by filename so the dashboard can link an activity to a torrent hash.
  • Section/all scan endpoints queue a full section refresh (RefreshSection(key, nil), no path) for one or all sections; they do not touch STRM files or fire library side effects (HandlePlexScan/HandlePlexScanAll).

Plex Wrapped (internal/handlers/dashboard/plex_wrapped.go)

  • /plex-stats renders a 9-slide year-in-review from the last 12 months of Plex watch history; /plex-stats/data returns the same stats as JSON, /plex-stats/image renders a shareable PNG via headless chromedp (square 1080x1080 default, instagram 1080x1920, facebook 1200x630).
  • Stats are fetched at most once per 24h (?refresh=true forces), capped at 10000 history items, and cached both in memory and on disk at data/plex_wrapped_cache.json, which is reloaded at startup. While a fetch is in flight the page reports a loading state rather than blocking.
  • Library side effects fire on directory assignment changes, renames, deletions and file repairs: TriggerLibrarySideEffects invalidates the rclone VFS dircache for the affected paths (so the media servers do not scan a stale listing), calls MediaScanner.TriggerScan (hitting Plex refresh), and runs the user's on_library_update hook (internal/torrent/hooks.go).
  • Refresh pipeline only runs STRM generation and side effects after initialization completes to avoid premature Plex scans (internal/torrent/refresh.go:426-432).

Infrastructure & Scripts

  • Sample Nginx vhost for proxying Plex traffic (nginx/plex.conf).
  • plex_update.sh is the reference partial-scan script for on_library_update: sh plex_update.sh "$@"; docker-compose.yml carries a commented-out bind mount for it, to be uncommented when you use it.
  • integration/plex_integration.sh runs under make integration-test and make integration-test-media; it skips loudly on a host with no Plex server, so read the output rather than the exit code. scripts/plex-e2e-test.sh is a standalone playback check that reads plex_server_url/plex_token out of config.yml.

Plex's defaults assume local disks that are always present and free to read. A zurg mount is neither: its bytes come from a provider over HTTP, and it can be briefly unreadable while rclone reconnects. Several Plex defaults are actively harmful under those conditions.

zurg reconciles these on every Plex status refresh. plex_settings_policy decides how far it goes — off, warn, guard (default) or enforce. The list lives in pkg/plex/settings.go (RecommendedSettings); the policy in pkg/plex/reconcile.go.

A setting the server does not report is skipped, so a Plex version that lacks one is not reported as misconfigured.

Nine of the sixteen values below are against Plex's own default; the other seven match it and are guards rather than corrections, firing only for an operator who turned one on. Those rows show the same value in both columns, which is not a mistake — Plex ships GenerateBIFBehavior at never and the server these were measured against was sitting at scheduled. Plex reports its own default per setting in /:/prefs, so the "Plex ships" column below is read from the server rather than assumed (measured 2026-09-05 against Plex 1.42.2).

Opting out of one setting

plex_settings_policy is tiered, and the tier is not always the question being asked. An operator whose files move between library folders — tag-driven sorting, say — depends on Plex emptying its own trash to clear the entry left behind, and guard turns that off. Dropping to warn to get it back also gives up the filesystem-event guards, which are the same tier and not what they asked for.

plex_settings_ignore names settings individually:

plex_settings_policy: guard
plex_settings_ignore:
  - autoEmptyTrash

The dashboard writes this one for you. The config page has a Let Plex Empty Its Own Trash switch above the Library Trash Sweep settings. It adds and removes autoEmptyTrash here and leaves the rest of the list untouched. Turning it on takes effect on the next restart.

An ignored setting is still read and still shown on the dashboard and in zurg plex-settings (marked skip), so nothing is hidden. It is simply never written, never counted as a problem, and never behind the dashboard's trash banner. Ids are matched case-insensitively; one that names no setting in the tables below is reported at startup and leaves the setting guarded, since a typo must not read as an opt-out.

Ignoring a safety setting is at your own risk, and zurg says so once when it starts rather than on every refresh. autoEmptyTrash in particular is the setting that empties a library when a scan meets a mount that has blipped. Ignoring it also hands the cleanup job back to Plex — see below.

Who empties the trash

Turning autoEmptyTrash off leaves a job undone. Plex has been told not to clear entries whose files are gone, and for a long time zurg's own removal was opt-in, so out of the box a library accumulated dead entries and nothing collected them. Neither side was wrong and the gap was real: it is what someone hits when a file moves between library folders and the old entry stays forever.

zurg now takes the job it took away. With plex_trash_sweep_every_mins unset, the trash sweep removes entries every 60 minutes — but only when nothing else is doing it. If Plex is still emptying its own trash, because plex_settings_policy is off or warn or because autoEmptyTrash is in plex_settings_ignore, zurg stands down and leaves it to Plex. Two collectors on one library is how a disagreement between them becomes a deletion.

The sweep is much narrower than Plex's own emptying, which is the point of preferring it: it removes one entry at a time, never one that still has a file, only from a mount that reads at that moment, capped at 50 removals and 10% of the trash per pass, and entries it has no verdict on stay visible as broken for 14 days first. It also needs plex_database_path set, so it only runs where Plex is on this host.

To keep removal off and empty the trash yourself, write plex_trash_sweep_every_mins: 0. That is the explicit no, and it is distinct from leaving the key out.

That key only turns off zurg's own removal. It does not hand Plex's setting back. Under guard zurg still writes autoEmptyTrash off, so Plex still will not empty its trash after a scan and the entries pile up with nothing collecting them. Use the dashboard switch or plex_settings_ignore to give Plex the job back.

Safety — corrected under guard and enforce

These lose data rather than time, which is why the default policy writes them instead of only warning. Only autoEmptyTrash ships at the destructive value; the other three already default the way zurg wants them and are here to catch an operator who turned one on.

Preference Plex default zurg wants Why
autoEmptyTrash 1 0 Plex empties its trash after every scan. If the mount is briefly unreadable while a scan is walking it, every file reads as deleted and Plex removes them permanently instead of leaving them in the trash to come back with the mount.
FSEventLibraryUpdatesEnabled 0 0 Event-driven updates miss what actually changes here. zurg's tree changes on its own side, which never passes through the local kernel, so no event is raised and the library quietly goes stale. The events that do arrive are writes made through the mount, which land while a directory is still being assembled.
FSEventLibraryPartialScanEnabled 0 0 A partial scan runs off those same events, so it fires on the writes coming through the mount — exactly when a directory is half-written — and never on the remote-side changes that would actually need a scan.
ButlerTaskBackupDatabase 1 1 The nightly database backup is the only thing that recovers a library Plex has already deleted.

The two FSEvent reasons were rewritten after measuring them, on every platform zurg mounts on. The earlier wording said a FUSE mount "does not emit them reliably, so this produces scans at moments nothing asked for one". Both halves of that are wrong.

Measured 2026-09-05, each platform watched with the API its media server actually uses, against an rclone mount whose backing directory could be changed directly. A is a write made through the mount; B is the same file appearing on the far side, the way it does when zurg's own tree changes.

Platform Mount Watched with A: through the mount B: changed on the far side
Linux fuse.rclone inotify CREATE OPEN ATTRIB CLOSE_WRITE no events
Docker same mount bind-mounted into a container inotify, inside the container same four, seen in the container no events
macOS macFUSE FSEvents Created IsFile no events
Windows WinFsp ReadDirectoryChangesW Created Changed no events

Every platform delivered A, and a same-run control on a native filesystem (APFS, NTFS) produced the same events, so the watchers were working. None delivered B, though the file was visible through the mount once the directory cache expired.

So the failure mode is the opposite of the one claimed: an event-driven update fires on writes coming through the mount — an *arr import landing in a directory still being assembled — and never fires for the remote-side changes that are the reason to scan at all. It is not that events are unreliable; it is that they report the wrong half of what happens on a debrid mount.

Bandwidth — corrected under enforce only

Each of these makes Plex decode entire files for a result nobody looks at. Harmless on local storage; over a debrid mount every byte is a provider request, and these run across the whole library. Analysis whose output is visible — scrubbing previews, chapter pictures, volume levelling, sonic analysis — sits in the taste tier below instead, where enforce will not touch it.

Preference Plex default zurg wants
ButlerTaskDeepMediaAnalysis 1 0
ButlerTaskUpgradeMediaAnalysis 1 0
GenerateAdMarkerBehavior scheduled never
GenerateVADBehavior never never
GenerateIndexFilesDuringAnalysis 0 0
ButlerTaskGenerateMediaIndexFiles 0 0

Taste — always reported, never corrected

There is a feature behind each of these, so zurg says what the value costs and leaves the choice alone. Turning one off unasked would take away something the operator can see, which is not zurg's call however much bandwidth it would save. For the first two the cheaper option keeps the feature and moves the work into the maintenance window; for the rest the choice is the feature or the bandwidth.

Preference Plex default zurg suggests Why
GenerateIntroMarkerBehavior asap scheduled or never asap decodes each file the moment it lands, outside any maintenance window. scheduled keeps the Skip Intro button and moves the work into the window.
GenerateCreditsMarkerBehavior asap scheduled or never The same, for Skip Credits.
GenerateBIFBehavior never never Decodes the whole video to build the image strip shown while scrubbing. Playback does not need it.
GenerateChapterThumbBehavior scheduled never Decodes the file to pull one frame per chapter, for the picture beside each entry in the chapter list.
LoudnessAnalysisBehavior scheduled never Fully decodes every file's audio to measure it, for volume levelling between tracks and titles.
MusicAnalysisBehavior scheduled never Decodes every track — many small files, so this is the request count that gets an account rate-limited. Buys sonic analysis: similar-track mixes and the visualiser.

The maintenance window

zurg does not touch ButlerStartHour/ButlerEndHour, because there is no hour that is right for everyone. It is worth setting by hand: Plex defaults to 02:00–05:00, and a window overlapping the hours anyone watches turns every maintenance task into a stall. Whatever survives in the bandwidth tier runs inside it.

Stopping a pass already in flight

Changing a preference stops future runs, not the one already going. Plex Media Server respawns its scanner children, so killing them does nothing:

curl -X DELETE "http://localhost:32400/butler?X-Plex-Token=$TOKEN"