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 toconfig.ymlvia 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 negativeplex_match_every_minswritten straight intoconfig.ymlpanics 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_gbandquality(defaultbest). An emptyindexersborrowsstremio.indexers. The flatplex_watchlist_enabled,plex_watchlist_check_every_secsandwatchlist_qualitykeys still count as the legacy spellings of the first, second and last of those. See config.md. mount_pathis 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/authcreates a PIN session (pkg/plex/auth.go,CreatePlexAuthURL), stores it inpkg/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/callbackserves a progress page that, once authorized, calls/plex/serversto list servers and/plex/serverto 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 withlast_errorset (enrichServersWithIdentity,auth.go:111-150). - Selecting a server writes
plex_server_url/plex_tokento 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/logoutclears the stored values and the status cache.
Plex Client Capabilities (pkg/plex/client.go)
- Connection lifecycle: deferred start option (
WithDeferredStart),ValidateConnectionguard against/identity, gracefulClose. There is no restart API — the dashboard'sRestartAllowed/RestartReasonare 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);ProcessJobissuesGET /library/sections/{key}/refreshwith Plex headers and at most onepathquery 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;GetJobQueueSizeexposes backlog. - Load/stuck detection:
CheckServerLoadinspects/activities, trackslibrary.update.sectionprogress per UUID (a changed progress or subtitle counts as progress), andIsJobStuckflags stale jobs bylibrarySectionIDwith a Title fallback.GetServerActivitiesDetailscreates and retires the same trackers, becauseCheckServerLoadonly 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 forlibrary.update.sectiononly; 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.sectionsubtitle 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 ininternal/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 withincludeGuidsto collect paths, GUIDs, IMDB IDs), andGetShowLevelIMDBIndex(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/MatchSelectiontypes). - Server info:
GetServerIdentityOnlyandGetServerDetails(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), andGetWatchHistoryWithMetadata(pkg/plex/history.go), feeding the stats inpkg/plex/stats.go.
Media Scanner Integration (pkg/scan/scanner.go)
- Initializes Plex client when
mount_path,plex_server_url, andplex_tokenare set; retries every 5s until connected, firingOnPlexReconnectedcallbacks. TriggerScangroups paths per Plex section (viaFindSectionNormalized) 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.ReinitializePlexClientallows dashboard updates to swap servers/tokens live.GetQueueSizesexposes 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 livescanner.PlexClient; otherwise returns explicit errors. - The only Plex state stored on a torrent is
PlexRatingKey(show-level for episodic content) plusIMDBID(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 aGetShowLevelIMDBIndexpass so episodes inherit the show's IMDB/TVDB/TMDB ID instead of an episode-level one. Items are keyed byGrandparentRatingKeywhen 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:
buildPlexDirIndexindexes every lowercased path component of every Plex file, and the torrent'sGetKeyis 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_keyis set (sendHashImdbPairs→TorrentManager.SendHashImdbPairs,internal/torrent/mediainfo.go:254-258).
MatchSingleTorrentruns 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.txtsummarizing counts, unmatched torrents, skipped torrents, Plex items without torrents, and matches missing IMDB. - Scheduling (
internal/plex/schedule.go): each completed run stampsdata/plex_match_timestamp. On start,StartMatchingJobruns immediately only if that stamp is missing or older thanplex_match_every_mins; otherwise it waits out the remainder first. It listens for manual triggers onTorrentManager.PlexMatchTrigger, for live interval changes onResetPlexMatchTicker, and retries background panics up to 3 times. - Manual trigger endpoint
/plex/match-torrentspushes to the trigger channel and refuses while a run is in flight;/plex/match-torrents/statusreports 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.resolvePlexRatingKeyvalidates that key withGetMetadataSummary, backfills a missing IMDB ID from it, and on a stale key re-resolves viaLookupRatingKeyByGUIDusing the torrent's storedtt…/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) callsApplyManualMatch, 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.
- Requires the torrent to already carry a
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 underacquisition.sources. -
The client uses
discover.provider.plex.tvand 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
PlexStatusFetcheron 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-statsrenders a 9-slide year-in-review from the last 12 months of Plex watch history;/plex-stats/datareturns the same stats as JSON,/plex-stats/imagerenders a shareable PNG via headless chromedp (square1080x1080 default,instagram1080x1920,facebook1200x630).- Stats are fetched at most once per 24h (
?refresh=trueforces), capped at 10000 history items, and cached both in memory and on disk atdata/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 & Refresh Links
- Library side effects fire on directory assignment changes, renames, deletions and file repairs:
TriggerLibrarySideEffectsinvalidates the rclone VFS dircache for the affected paths (so the media servers do not scan a stale listing), callsMediaScanner.TriggerScan(hitting Plex refresh), and runs the user'son_library_updatehook (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.shis the reference partial-scan script foron_library_update: sh plex_update.sh "$@";docker-compose.ymlcarries a commented-out bind mount for it, to be uncommented when you use it.integration/plex_integration.shruns undermake integration-testandmake 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.shis a standalone playback check that readsplex_server_url/plex_tokenout ofconfig.yml.
Recommended Plex Settings
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.
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.
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.
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.
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"