description: Deploy and configure Plex Media Server on a self-hosted Linux server — Docker, GPU hardware transcoding, network configuration, media library management, and troubleshooting.
Deploy and manage Plex Media Server via Docker on a self-hosted Linux server. Covers first-run setup, GPU hardware transcoding, network configuration, media library management, sonic analysis, Plex API interaction, and troubleshooting.
## When to Use
- User asks to set up Plex on their homelab/server
- User wants hardware-accelerated transcoding (NVIDIA, Intel QuickSync)
- User wants to add media drives to an existing Plex container
- User asks about sonic analysis, Plexamp DJ, or "sonically similar" features
- User needs to query or manage Plex programmatically via API
- User is migrating Plex to a new server
## Prerequisites
- Docker + docker-compose-v2 installed
- NVIDIA driver installed (for NVIDIA GPU transcoding) OR Intel GPU for QuickSync
- Plex account and an active Plex Pass (for hardware transcoding)
## Setup Steps
### 1. Create directory structure
```bash
mkdir -p ~/docker/plex/config
```
-`config/` — Persistent Plex data (database, metadata, preferences)
### 2. Create docker-compose.yml
```yaml
services:
plex:
image:plexinc/pms-docker:latest
container_name:plex
network_mode:host
environment:
- TZ=America/New_York
- PLEX_CLAIM=claim-<your-claim-token>
- ADVERTISE_IP=http://<server-lan-ip>:32400/
volumes:
- /home/ray/docker/plex/config:/config
- /dev/shm:/transcode
devices:
- /dev/dri:/dev/dri
restart:unless-stopped
```
**Key configuration details:**
- **`network_mode: host`** — Required for DLNA/GDM discovery, remote access, and automatic local network detection. Without host mode, Plex can't broadcast its presence on the LAN.
- **`PLEX_CLAIM`** — One-time token from https://plex.tv/claim. Expires ~5 minutes after generation. Set it, start the container once, then remove it (optional but recommended after first run).
- **`ADVERTISE_IP`** — Must be the server's actual LAN IP (check with `ip -4 addr show | grep -oP '(?<=inet\s)\d+\.\d+\.\d+\.\d+' | grep -v 127.0.0.1`). Required for proper remote access configuration. DO NOT guess this — verify the IP first.
- **`/dev/dri`** — Device passthrough for hardware transcoding. Works for both NVIDIA and Intel GPUs. NVIDIA also needs the `nvidia` Docker runtime in some configurations, but `/dev/dri` alone covers VA-API/NVDEC for Plex.
- **`/dev/shm:/transcode`** — RAM-backed transcode directory. Prevents SSD wear from heavy transcoding and improves performance. Ensure the server has enough RAM (minimum 2GB free for 4K transcodes).
### 3. Get a claim token and start
1. Visit https://plex.tv/claim and copy the token (expires in ~5 minutes)
2. Add `PLEX_CLAIM=claim-<token>` to the compose file
3. Start the container:
```bash
cd ~/docker/plex && docker compose up -d
```
4. Verify it's healthy:
```bash
docker ps --filter name=plex
docker logs plex | tail -10
```
5. Look for "Token obtained successfully" in logs — confirms the server is linked to your Plex account
### 4. Access and configure
Open `http://<server-ip>:32400/web` in a browser. The server should appear as claimed and ready. If it doesn't automatically detect:
- Check `ADVERTISE_IP` is correct
- Restart the container with updated config
### 5. Add media libraries (after first run)
Once the server is running and claimed, add media mounts as volumes. **Do this after the first run** — get the server healthy and accessible first, then add media:
```yaml
volumes:
- /home/ray/docker/plex/config:/config
- /dev/shm:/transcode
- /mnt/media/Videos:/data/media/Movies:ro
- /mnt/media/Music:/data/media/Music:ro
- /mnt/wd-passport/Videos:/data/media/WDVideos:ro
```
**Pitfall:** Binding mount paths into `/data/media/` is convention, not requirement. Plex lets you point at any path inside the container. Use whatever layout makes sense, then add libraries via the Plex web UI pointing at the container paths.
Recreate the container to apply the new volume mounts (restart won't pick them up):
```bash
docker compose up -d plex
```
**Why:** `docker compose restart` reuses the existing container config — new volume mounts won't appear. `up -d` recreates with the updated compose file, which is required for volume, device, or environment changes.
### 6. Verify hardware transcoding
1. Play a media file that requires transcoding (high-bitrate 4K, or audio format the client doesn't support)
3. On a successful HW transcode, you should see lines indicating NVDEC/NVENC or VA-API usage
## Pitfalls
- **`PLEX_CLAIM` expired** — The token is only valid for ~5 minutes. If you didn't start the container in time, remove `PLEX_CLAIM` from the compose, restart, and the server remains unclaimed. To re-claim: generate a new token, re-add it, run `docker compose up -d` (NOT restart — `restart` doesn't re-read env vars, only `up -d` does), then remove the token after success.
- **`ADVERTISE_IP` wrong** — If you guess the IP, remote access and local discovery will fail. Always check `ip -4 addr show` and set it to the correct LAN IP before first start.
- **`libusb_init failed` in logs** — Harmless. Means no USB TV tuner is attached. Ignore it.
- **`chown: missing operand after 'plex:plex'` in logs** — Cosmetic during first init. Harmless.
- **Hardware transcoding not working** — Check that `/dev/dri` is accessible inside the container: `docker exec plex ls -la /dev/dri`. If the device doesn't exist, verify NVIDIA driver is installed and the container was created with `--device /dev/dri`. For NVIDIA specifically, you may also need `runtime: nvidia` in the compose or `--gpus all` on `docker run`.
- **Plex not showing on local network** — Host network mode usually fixes this. If still not visible, check firewall rules (ufw/iptables blocking UDP 1900 for SSDP).
- **Media mount paths** — Use `:ro` (read-only) for all media mounts. Plex only reads from these directories; write access is unnecessary and risks accidental file modification.
- **`docker compose restart` vs `up -d`** — `compose restart` reuses the existing container config and only restarts the process. `compose up -d` recreates the container with any config changes. Use `up -d` when you change `environment`, `devices`, or `volumes`; use `restart` for a simple process restart.
- **Plexamp DJ blocked after sonic analysis** — Even with completed analysis, Plexamp DJ features may still show *"Requires Sonic Analysis"*. Top causes: (1) `MusicAnalysisBehavior` server pref is `manual` instead of `scheduled` — Plexamp reads this server-level flag and blocks DJ when it's not `scheduled`. Fix: `PUT /:/prefs?MusicAnalysisBehavior=scheduled`. (2) Plexamp cached old server state — force-quit and reopen. (3) Sonic analysis indexes need reload — restart the Plex container. See `references/plex-management.md` for full troubleshooting.
- **Plex API token mangled by bash shell** — Plex tokens like `fjCWcQF_trzJHgYKhRXV` contain underscores and other chars that bash may mangle in shell string expansion. If `curl -s "http://...?X-Plex-Token=$TOKEN"` returns 401 but the token is correct, the shell is the culprit. **Use Python urllib instead**: read the token with `xml.etree.ElementTree`, build the URL with `urllib.request.Request`, and the token passes through unmolested. The `curl` command works fine when the token is copied literally — the problem is bash variable interpolation, not the API itself.
- **Compilation folders showing as "Various Artists"** — When Plex scans a parent folder containing multiple compilation subfolders, it can cross-contaminate album grouping even with `respectTags=true`. **Fix: add each subfolder as an individual library location instead of the parent.** Process: (1) Insert rows into `section_locations` table in the Plex SQLite DB (`com.plexapp.plugins.library.db`) with `library_section_id` and `root_path` for each subfolder. (2) Delete the old parent location row. (3) Restart Plex container and trigger a library refresh. This isolates each compilation folder so they appear as distinct entities in Plex. Use Python + sqlite3 module (not the `sqlite3` CLI, which may not be installed).
- **`respectTags` change has no effect on existing items** — Changing `respectTags` from false→true (or vice versa) does NOT cause Plex to re-read embedded tags on already-matched albums. "Refresh Metadata" in the web UI and `GET /refresh?force=1` will appear to do nothing. You MUST either touch all files to update their mtime (so Plex sees them as "changed") then run a forced scan, or perform the Plex Dance (remove location→scan→empty trash→re-add location→scan) to reprocess from scratch. See `references/plex-management.md` for both workflows.
## Verification Checklist
- [ ] Container is `Up` and `healthy` in `docker ps`
- [ ] `http://<server-ip>:32400/web` loads the Plex web interface
- [ ] Server appears as claimed (linked to Plex account)
- [ ] `/dev/dri` is accessible inside the container
- [ ] Media libraries are scannable after adding mounts
- [ ] Hardware transcoding is active (check Plex Dashboard during a transcode)
- [ ] Remote access is configured (if desired)
## Managing Plex After Setup
For programmatic management — API endpoints, sonic analysis, database queries, butler task scheduling, tag-based artist/album grouping, file permission troubleshooting (DOSATTRIB xattrs), and the \"Various Artists\" fix — see **`references/plex-management.md`**. For Plex 1.43.x `--analyze-loudness` regression / DJ Friendgänger blockage, see **`references/plex-1.43-loudness-debug.md`** (full investigation + diagnostic workflow).\n\nBundled scripts:\n- `scripts/fix-compilation-tags.py` — add `compilation=1` + `albumartist=Various Artists` to untagged tracks (for singles/compilations that should appear as standalone entries)\n- `scripts/strip-various-artists.py` — REMOVE `album_artist=Various Artists` and empty `album_artist` from files so Plex uses the track-level `artist` tag (for tracks wrongly grouped under \"Various Artists\")
Covers:
- Plex HTTP API (sections, preferences, activities, tokens)
- Sonic analysis: enabling, triggering immediately, monitoring progress
# Plex 1.43.2 Loudness Analysis Debug — Full Investigation
Session: June 27, 2026. Server: Ubuntu 26.04, Plex 1.43.2.10687-563d026ea, Docker.
## Investigation Chain
### 1. Initial complaint: DJ Friendgänger greyed out
User's Albanian Traditional Music library (section 4, 170 tracks) had sonic analysis enabled and completed per Plex UI, but DJ Friendgänger was greyed out.
Set butler to current hour. Toggled musicAnalysis off/on. Butler ran but only processed "Processing 1 albums" — the one album was from section 5 (Club Hits), not section 4.
This proved sonic fingerprinting had completed successfully. The "persistence bug" in earlier docs was wrong — indexes ARE persisted, just in binary tree files, not as `sa:` keys.
### 10. Found gate: `pv:deepAnalysisDate`
All 170 tracks in section 4 had `pv:deepAnalysisDate: 1781974855` (June 20, 2026) in `media_parts.extra_data`. This timestamp tells Plex "already analyzed."
### 11. Cleared `pv:deepAnalysisDate` from all 170 tracks
```python
# Removed pv:deepAnalysisDate from extra_data via regex replacement
# 170 tracks updated
```
### 12. Restart + butler trigger
```bash
docker compose restart plex
# Set butler hours to current
# Toggled musicAnalysis off → on for section 4
```
### 13. Butler picked up items
`/activities` showed: "Sonic Analysis: Processing 5 albums" at 0%
`ps aux` showed: `Plex Media Scanner --analyze-deeply --item 25040`
### 14. Scanner confirmed running on section 4 item
Item 25040: "Po e mirë oj nane / Nisja tash me def / Hajde me shuplakë - Live" (section 4)
Deep Analysis log showed: "Read 10000 packets at 261.198367", "Read 20000 packets at 522.422857"
### 15. But NO loudness data produced
After scanner completed:
-`pv:deepAnalysisDate` updated to new timestamp (1782165677)
-`ma:loudness`, `ma:gain`, `ma:peak`, `ma:lra` still MISSING
- Loudness count: 0/170
## Root Cause Confirmed
On Plex 1.43.2:
-`--analyze-deeply` runs, reads files, updates `deepAnalysisDate` — but does NOT compute EBU R128 loudness
-`--analyze-loudness` is completely non-functional (exit 0, zero effect)
- The butler only triggers `--analyze-deeply`, never `--analyze-loudness`
- There is no mechanism to trigger loudness analysis on 1.43.2
## Key Database State Signals
| Condition | SQL Check | Meaning |
|-----------|-----------|---------|
| Loudness missing | `extra_data NOT LIKE '%ma:loudness%'` | Phase 1 never ran |
| `deepAnalysisDate` present | `extra_data LIKE '%pv:deepAnalysisDate%'` | Butler skips this item |
| Sonic indexes exist | Check `Music Analysis {sid}/` dir | Phase 2 completed |
| ZERO-WORK activities | `started_at = finished_at` | Butler tried but found nothing to do |
## Summary
DJ Friendgänger can never work on Plex 1.43.2 because loudness analysis (EBU R128) cannot be triggered through any mechanism. The only fix is downgrading Plex.
Programmatic Plex configuration via HTTP API and database — library management, sonic analysis, task monitoring.
## Plex Token
Extract from Preferences.xml:
```bash
grep -oP 'PlexOnlineToken="\K[^"]+'\
"/home/ray/docker/plex/config/Library/Application Support/Plex Media Server/Preferences.xml"
```
Append `?X-Plex-Token=<token>` to all API calls. Accept JSON: `-H "Accept: application/json"`.
**⚠️ Pitfall: Shell token mangling.** Plex tokens often contain underscores (`_`) and other characters that bash interprets when used in double-quoted shell variables or `eval`. A `curl` command that works one day may silently return 401 the next — the token is being mangled in transit, not invalidated. **Use Python + `urllib` for any Plex API call that involves the token in a variable:**
```python
importurllib.request
importxml.etree.ElementTreeasET
tree=ET.parse('/home/ray/docker/plex/config/Library/Application Support/Plex Media Server/Preferences.xml')
This avoids shell escaping entirely. For simple one-liners where the token is pasted directly (not in a variable), curl is fine — the issue is variable interpolation, not curl itself.
## Key Endpoints
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/` | GET | Server info (version, Plex Pass status) |
| `/library/sections` | GET | List all libraries |
| `/library/sections/{id}` | GET | Library details + directories |
| `/library/sections/{id}/analyze` | PUT | Trigger media analysis scan |
| `/library/sections/{id}/refresh` | GET | Trigger metadata refresh/scan |
| `/library/sections/{id}/refresh?force=1` | GET | Deep metadata refresh — re-reads embedded tags and cover art from files (slower but more thorough) |
| `/activities` | GET | Active background tasks + progress |
-`GET /library/sections/{id}/refresh?force=1` — Deep refresh: re-reads every file's embedded ID3 tags, re-extracts cover art. Plex Media Scanner processes are visible in logs as `--match --type 8` and `--analyze-deeply` jobs. Significantly slower but fixes stale metadata and missing covers.
**⚠️ Pitfall: PUT method returns 404.** The Plex web UI sends `PUT /refresh` for the "Refresh Metadata" button, but programmatic calls with `PUT` return HTTP 404. Always use `GET` for the refresh endpoint: `GET /library/sections/{id}/refresh?force=1`. This is the documented API method — `PUT` is an internal web UI convention.
## Library Preferences (per-section)
Plex stores library settings at `/library/sections/{id}/prefs`. Each library type has different settings. Key music library prefs:
- **Plex Music agent** — library must use `tv.plex.agents.music` (not legacy)
- **Version 1.32+** — fpcalc NOT needed; fingerprinting is built-in from 1.32+
- **CPU-only** — Sonic analysis uses CPU (FFMPEG ebur128 for loudness, Plex's own fingerprinting with `Plex Music Analyzer` + `Music.tflite` TensorFlow Lite model). GPU is only used for video transcoding, never for audio analysis.
### Initial Diagnosis: Is Analysis Even Enabled?
When investigating whether sonic analysis has run (or why it hasn't), start with these checks in order. The most common cause of "analysis never happened" is simply that `musicAnalysis` was never enabled on the library.
**1. Check per-library `musicAnalysis` pref:**
```python
importurllib.request,xml.etree.ElementTreeasET,re
tree=ET.parse('/home/ray/docker/plex/config/Library/Application Support/Plex Media Server/Preferences.xml')
**2. Check the activities table for zero-duration sonic runs:**
When `musicAnalysis` was disabled, the butler may have run sonic analysis tasks that completed instantly with no work — these appear as activities with `started_at == finished_at`:
```python
importsqlite3
conn=sqlite3.connect('/home/ray/docker/plex/config/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db')
cur=conn.execute("""
SELECT title, subtitle, started_at, finished_at,
CASE WHEN started_at = finished_at THEN 'ZERO-WORK' ELSE 'real' END as type
Multiple "Sonic Analysis: Finding albums" entries with `ZERO-WORK` means the butler kept trying but had nothing to do. After enabling `musicAnalysis`, you may need to kick the butler (see Triggering below).
Must be `scheduled` (not `manual`, not `asap`). The value `asap` has been observed in the wild and behaves unpredictably — Plexamp may treat it like `manual` and block DJ features. Always set to `scheduled`:
Sonic analysis runs in two phases, both visible as activities:
1.**Loudness analysis** — Per-track EBU R128 loudness, gain, peak, dynamic range. Produces `ma:loudness`, `ma:gain`, `ma:peak`, `ma:lra` keys in `media_parts.extra_data`. Controlled by server-level `LoudnessAnalysisBehavior` pref. Logs to `Plex Media Scanner Deep Analysis.log` with `Loudness:` entries.
2.**Sonic fingerprinting** — Audio fingerprint computation for similarity matching. Runs after loudness phase completes. Controlled by `MusicAnalysisBehavior`. Stores results as `.tree` files and `Mapping.db` under `Databases/Music Analysis {section_id}/`. The umbrella "Sonic Analysis: Processing N albums" stays at 0% during phase 1 and jumps when phase 2 begins.
**⚠️ Plex 1.43.2 regression: `--analyze-loudness` CLI flag is BROKEN.** The scanner CLI accepts `--analyze-loudness` but it exits immediately with code 0 — no output, no log entries, no database writes. Per-item and per-section invocations both fail silently. The butler's `DeepMediaAnalysis` task spawns `Plex Media Scanner --analyze-deeply --item <id>` which runs successfully but only performs deep analysis (reading file packets, updating `pv:deepAnalysisDate`) — it does NOT produce loudness data. This means loudness analysis CANNOT be triggered via CLI on 1.43.2. The only known fix is downgrading Plex to a version where `--analyze-loudness` works (pre-1.43).
**Phase 2 internals:** Plex runs up to 3 parallel `Plex Music Analyzer` processes (one per album batch), each at 99.9% CPU. Each batch: (a) decodes album tracks to mono 16kHz PCM WAV via `Plex Transcoder` into `/tmp/music-analysis-input-<uuid>/track-NNNN.wav`, (b) runs the Music.tflite TensorFlow Lite model: `Plex Music Analyzer /tmp/music-analysis-<uuid> /usr/lib/plexmediaserver/Resources/Music.tflite labels index.txt`, (c) builds track/album/artist similarity trees. 4,428 tracks across 44 albums took ~4 hours on a GTX 1050 Ti server (CPU-bound; GPU idle).
### Enable
Per-library via API (or Plex UI → Library → Edit → Advanced → "Analyze audio tracks for sonic features"):
```bash
# For each music library section:
curl -s -X PUT "http://IP:32400/library/sections/{id}/prefs?musicAnalysis=1&X-Plex-Token=TOKEN"
Sonic analysis runs during the butler maintenance window (2-5 AM by default). To trigger immediately:
1.**Set butler hours to now:**
```bash
curl -s -X PUT "http://IP:32400/:/prefs?ButlerStartHour=CURRENT_HOUR&ButlerEndHour=CURRENT_HOUR+2&X-Plex-Token=TOKEN"
```
2. **Wait ~5 seconds** for butler to wake up. Sonic Analysis activity appears at `/activities`.
3. **Restore normal hours ONLY after all phases complete:**
```bash
curl -s -X PUT "http://IP:32400/:/prefs?ButlerStartHour=2&ButlerEndHour=5&X-Plex-Token=TOKEN"
```
**⚠️ Butler may not start analysis on first enable.** After setting `musicAnalysis=1` on a library, a metadata scan may run (visible as "Scanning LibraryName" at 99%) but the butler does NOT always start sonic analysis when the scan finishes. If no "Sonic Analysis" activity appears within 10 seconds of the scan completing, force the butler to notice by toggling `musicAnalysis` off and back on:
```bash
# Toggle off, wait 3s, toggle on
curl -s -X PUT "http://IP:32400/library/sections/{id}/prefs?musicAnalysis=0&X-Plex-Token=TOKEN"
sleep 3
curl -s -X PUT "http://IP:32400/library/sections/{id}/prefs?musicAnalysis=1&X-Plex-Token=TOKEN"
```
The butler should pick up the change within 10 seconds and begin processing.
**⚠️ Don't restore hours too early.** If the current time falls outside the restored window (e.g., you restore to 2-5 AM while it's 11 AM), the butler immediately stops scheduling new tasks. Already-running individual tasks finish, but the butler won't pick up the next album or phase. Wait until all sonic analysis is done (activity disappears from `/activities`) before restoring.
tail -f "/home/ray/docker/plex/config/Library/Application Support/Plex Media Server/Logs/Plex Media Scanner Deep Analysis.log"
# Count tracks processed so far
grep -c 'Loudness:' "/home/ray/docker/plex/config/Library/Application Support/Plex Media Server/Logs/Plex Media Scanner Deep Analysis.log"
```
**Process check:**
```bash
# Verify scanner is running and on which files
ps aux | grep "Plex Media Scanner" | grep -v grep
```
**Database (check fingerprint state):**
```bash
python3 -c "
import sqlite3
db = '/home/ray/docker/plex/config/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db'
conn = sqlite3.connect(db)
cur = conn.cursor()
cur.execute(\"SELECT COUNT(*) FROM media_parts WHERE extra_data LIKE '%sonic%'\")
print('Fingerprinted tracks:', cur.fetchone()[0])
conn.close()
"
```
### Pitfalls
- **Butler ran before `musicAnalysis` enabled** — Tasks complete instantly with no work (started_at == finished_at in the activities table). Diagnosis: query `SELECT title, subtitle, started_at, finished_at FROM activities WHERE title LIKE '%Sonic%' ORDER BY started_at DESC` — if all recent entries show identical start/finish timestamps, the butler has been running zero-work cycles. **Fix: toggle `musicAnalysis` off, wait 3 seconds, then back on** — this wakes the butler to the new setting. Simply enabling it once may not be enough; the toggle forces the butler to re-evaluate. Combined with setting butler hours to the current hour, this reliably triggers fresh analysis:
- **"Processing N albums" at 0% for a while** — Normal. Plex first catalogs albums, then runs loudness analysis on each (butler progress ticks up), then does fingerprinting. The Sonic Analysis umbrella stays at 0% through the loudness phase and only jumps when fingerprinting begins. Check the Deep Analysis log for per-track progress during the 0% period.
- **Low CPU during analysis** — Also normal. Loudness analysis is I/O-bound (reading audio files, FFMPEG ebur128). CPU spikes more during fingerprinting phase.
- **`watchMusicSections` false** — Server-level pref. If false and a library has `musicAnalysis=1`, analysis still runs during butler window. This pref controls watching for NEW additions only.
- **GPU not used** — Sonic analysis is CPU-only. NVIDIA GPU utilization stays at 0% throughout. Plex only uses GPU for video transcoding.
- **`LoudnessAnalysisBehavior=asap` blocks DJ Friendgänger** — DJ Friendgänger requires per-track EBU R128 loudness data (Phase 1). If `LoudnessAnalysisBehavior` is `asap` or `manual`, loudness analysis never runs even though sonic fingerprinting (Phase 2) completes. The sonar indexes build fine, but Plexamp blocks DJ modes because `ma:loudness` data is missing from `media_parts.extra_data`. Fix: `PUT /:/prefs?LoudnessAnalysisBehavior=scheduled`, then force loudness analysis (see Forcing Loudness Analysis below).
- **`--analyze-loudness` CLI broken on 1.43.x** — The scanner CLI accepts `--analyze-loudness` (both per-item and per-section) but exits immediately with code 0, producing no output, no log file, and no database writes. This is distinct from `--analyze-deeply` which runs but doesn't include loudness. The butler only triggers `--analyze-deeply`, not `--analyze-loudness`. As of 1.43.2, there is no way to trigger loudness analysis through any mechanism.
- **`--analyze-deeply` on music does NOT include loudness** — Verified on 1.43.2: the scanner reads file packets, updates `pv:deepAnalysisDate` in `media_parts.extra_data`, but does not compute or persist `ma:loudness`/`ma:gain`/`ma:peak`/`ma:lra`. Deep analysis for music items is fingerprint-only.
- **Sonic indexes ARE persisted (contrary to earlier belief)** — On Plex 1.32+, sonic fingerprints live in `Databases/Music Analysis {section_id}/` as `.tree` files + `Mapping.db`. The old check `WHERE extra_data LIKE '%sa:%'` returns 0 because modern Plex doesn't store fingerprints there. The real persistence check: look for `Track.tree`, `Album.tree`, `Artist.tree`, and `Mapping.db` files with non-trivial sizes. The `/library/metadata/{id}/similar` REST endpoint may return 404 even when data exists — this is a Plex 1.43.x API behavior, not a data-loss bug.
## Database
```bash
DB="/home/ray/docker/plex/config/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db"
- `metadata_items` — All metadata entries: tracks (type=10), albums (type=9), artists (type=8). Albums reference tracks via `parent_id`. Albums have `user_thumb_url` (cover art reference); tracks inherit art from their parent album.
- `media_items` — Physical file records (id, library_section_id, metadata_item_id, duration, bitrate, container, audio_codec). Links to metadata_items via `metadata_item_id`.
- `media_parts` — File paths and extra_data (JSON with analysis results). Links to media_items via `media_item_id`.
- `activities` — Task history with `type`, `title`, `started_at`, `finished_at`
- `metadata_item_settings` — Per-item metadata
- `preferences` — Server-level settings
**Common query pattern — albums with art in a music library:**
```python
cur = conn.execute("""
SELECT COUNT(DISTINCT md_album.id)
FROM metadata_items md_album
JOIN metadata_items md_track ON md_track.parent_id = md_album.id
JOIN media_items mi ON mi.metadata_item_id = md_track.id
| `ButlerTaskDeepMediaAnalysis` | true | Enables deep analysis |
| `ButlerTaskUpgradeMediaAnalysis` | true | Re-analyzes when format changes |
| `MusicAnalysisBehavior` | scheduled | `scheduled` or `manual` |
| `LoudnessAnalysisBehavior` | scheduled | `scheduled` or `manual` |
**⚠️ `asap` is NOT a recognized value.** Some Plex installations end up with `MusicAnalysisBehavior=asap` and `LoudnessAnalysisBehavior=asap`. This value is not documented by Plex and behaves like `manual`.
- **`MusicAnalysisBehavior=asap`** blocks sonic fingerprinting (phase 2) — indexes won't build, "Processing N albums" stays ZERO-WORK. Fix: set to `scheduled`.
- **`LoudnessAnalysisBehavior=asap`** blocks loudness analysis (phase 1) — `ma:loudness`/`ma:gain`/`ma:peak`/`ma:lra` are never written to `media_parts.extra_data`. This specifically blocks DJ Friendgänger which requires per-track loudness data. Sonic fingerprinting can still complete successfully while loudness is blocked — the two phases are independently gated. Fix: set to `scheduled`.
Both should always be `scheduled`:
**⚠️ `MusicAnalysisBehavior` is CRITICAL for Plexamp DJ.** If set to `manual` (e.g., after temporarily toggling to force analysis), Plexamp will block all DJ features with *"Requires Sonic Analysis to be enabled on your library."* Always restore this to `scheduled` after any manual trigger. Plexamp reads this server-level pref — it's not enough to have `musicAnalysis=1` per-library.
curl -s -X PUT "http://IP:32400/:/prefs?MusicAnalysisBehavior=scheduled&X-Plex-Token=TOKEN"
```
## Verifying Sonic Analysis Completion
The analysis pipeline produces several signals — use ALL of them, not just the database:
1. **Log confirmation** (most reliable): `grep 'Sonic analysis group complete.*processed:'` in the server log should show all album batches done.
2. **Activity completion**: Sonic Analysis disappears from `/activities`.
3. **Index building**: Logs should show `"Building track index with N tracks"` and `"Indexes completed"`.
4. **Database** (least reliable on modern Plex): On Plex 1.32+, sonic fingerprints are stored in internal binary index structures, NOT as `sa:` keys in `media_parts.extra_data`. The old SQL query `WHERE extra_data LIKE '%sonic%'` will return 0 even after successful analysis. The `/library/metadata/{id}/similar` API endpoint may also return empty/404 despite data existing — this appears to be a Plex 1.43.x behavior.
**Reliable completion check (log-based) — search ALL rotated logs:**
```bash
grep 'Sonic analysis group complete\|Indexes completed' \
"/home/ray/docker/plex/config/Library/Application Support/Plex Media Server/Logs/"Plex\ Media\ Server*.log
```
Plex rotates the server log (`.log` → `.1.log` → `.2.log` etc.) after restarts or size thresholds. A multi-hour sonic analysis will often span multiple log files. Always grep across ALL rotations with a wildcard.
**Misleading log line:** `"Butler: Performed sonic analysis on 0 batches of albums"` appears at the END of every butler run regardless of whether analysis actually ran. It's a summary counter (batches NEWLY analyzed in THIS run minus already-completed batches), not a reflection of total work done. The real signal is `"Sonic analysis group complete (processed: 28/28)"` appearing earlier in the same butler cycle.
When Plex can't read or analyze media files, permissions are often the culprit. Two common issues:
### Windows DOSATTRIB xattr on ext4
Files that originated on Windows and were copied to an ext4 filesystem may have `user.DOSATTRIB` extended attributes. These cause `ls -la` to show a `+` in the permission block (e.g., `-rw-r--r--+`) — easily mistaken for ACLs. `getfacl` will show NO ACLs, but `getfattr -d` reveals the real culprit:
find "/path/to/music" -type f -exec setfattr -x user.DOSATTRIB {} +
```
Install `attr` package if `getfattr`/`setfattr` are missing: `sudo apt-get install -y attr`.
**Don't chase ACLs when you see `+` in ls.** Check xattrs first — `getfacl` showing a clean result with `ls` still showing `+` is the telltale sign.
### Standard Permission Baseline for Plex
When Plex runs in Docker with `network_mode: host`, the container accesses files with whatever UID/GID the Plex process uses inside the container (typically `plex:plex`, UID 1000 or 999). Simple baseline:
```bash
sudo chown -R ray:ray "/path/to/music"
find "/path/to/music" -type d -exec chmod 755 {} +
find "/path/to/music" -type f -exec chmod 644 {} +
```
If the Docker container uses a different UID, adjust ownership accordingly or use `--user` in the compose file to match.
## Plexamp DJ Troubleshooting
If Plexamp DJ features are blocked despite completed sonic analysis:
1. **Check `MusicAnalysisBehavior`** — must be `scheduled` (see above). This is the most common cause.
2. **Force-quit and reopen Plexamp** — it caches server state.
3. **Restart Plex container** — may help reload sonic indexes into memory.
4. **Check Plex version** — Plexamp DJ feature compatibility can lag behind server updates.
5. **Network check** — Plexamp must reach the server directly. VPNs or double-NAT can interfere.
## Plexamp Cover Art Troubleshooting
When album covers show in Plex Web UI but not in Plexamp:
### Server-Side Verification
Before troubleshooting the client, verify the server is serving art:
```python
import urllib.request, xml.etree.ElementTree as ET, sqlite3
# Get token
tree = ET.parse('/home/ray/docker/plex/config/Library/Application Support/Plex Media Server/Preferences.xml')
Plex caches resized album art in `Cache/PhotoTranscoder/`. If art is present in the database but not rendering, clear this cache to force regeneration:
```bash
docker exec plex rm -rf "/config/Library/Application Support/Plex Media Server/Cache/PhotoTranscoder/"*
```
Then trigger a forced metadata refresh to re-extract covers from source files:
When Plex incorrectly groups tracks into albums — e.g., genre folders ("Rap", "Pop") become album names, or unrelated tracks appear under "Various Artists" as if on the same album.
### Root Causes
1. **`respectTags` is false** — Plex ignores embedded ID3 tags and derives album/artist from folder structure. A folder like `Music/English/Rap/` becomes album "Rap" with artist "Various Artists" because the parent folder name doesn't match any artist.
**⚠️ Single-location trap:** When a music library has `respectTags=false` and only ONE location containing tracks from multiple artists, EVERY track becomes part of one giant album named after the location folder. This is the most common cause of "Plex sees the whole folder as one album" complaints. Fix: enable `respectTags=true` + force refresh. Verify files have proper tags first with `ffprobe`.
2. **Compilation folders** — Folders like "Best of HipHop (2000-2026)" or "Techno Remixes 2026" contain tracks from different original albums. If `respectTags=true`, each track's original album tag creates a separate "Various Artists / [Album Name]" entry. If `respectTags=false`, the folder name becomes the album (which may be desired for compilations).
3. **Missing `album_artist` tags** — Files with `artist` tags but no `album_artist` tag cause Plex to treat multi-artist albums as compilations.
### ⚠️ Pitfall: `respectTags` change does NOT apply retroactively
Changing `respectTags` from `false` to `true` (or vice versa) only affects **newly added or changed** items. Already-matched items keep their existing metadata — the embedded tags are not re-read on a standard "Refresh Metadata" or "Scan Library Files." The user will click "Refresh Metadata" in the web UI and nothing will happen. The Plex scanner logs will show `"Skipping over directory '', as nothing has changed"` for directories where files appear unchanged.
**This is the single most common failure mode** when fixing album grouping. Three techniques to force re-reading, ordered by reliability:
1. **Touch files + force rescan** — Update mtimes so Plex sees files as "changed," then scan. Only works when Plex has NOT already run matching on the files with outdated tags:
Then trigger `GET /library/sections/{id}/refresh?force=1`. Plex logs will show `"File '...' changed write time, can't skip."` when detection works. Check with: `grep 'changed write time' "/path/to/Plex Media Server.log"`
**Limitation:** If Plex already ran `--match --match-tag-mode=all` on these files and stored MusicBrainz-derived metadata (even with `guid=local://...`), it may still not re-read the embedded tags. If the album/artist names don't change after this, escalate to the Plex Dance.
2. **The Plex Dance** (most reliable) — Remove location, restart, scan, empty trash, re-add location, restart, scan. Forces Plex to process every file as if brand new. This is the ONLY reliable fix when tags have been modified or when `respectTags` was changed after initial matching:
```python
import sqlite3, urllib.request, time, subprocess
import xml.etree.ElementTree as ET
# Get token
tree = ET.parse('.../Preferences.xml')
token = tree.getroot().get('PlexOnlineToken')
db = '.../com.plexapp.plugins.library.db'
conn = sqlite3.connect(db)
# Step 1: Remove ALL locations for the section
conn.execute("DELETE FROM section_locations WHERE library_section_id = ?", (section_id,))
conn.commit()
conn.close()
# Step 2: Restart Plex (required for location changes to take effect)
**Downside:** If `musicAnalysis=1`, Plex will auto-trigger loudness analysis on freshly added items. For 2883 tracks this took ~30 minutes on an i7-10700K. Monitor with `GET /activities` and `ps aux | grep "Plex Media Scanner"`.
**Note on `touch` after Plex Dance:** After removing locations/emptying trash, Plex may skip files because the empty DB has no "previous state" to compare against — some Plex versions require a fresh mtime to re-add files. If fewer tracks are added back than expected, touch all files and trigger another `force=1` scan.
3. **`preferLocalMetadata` attempt** — Setting this as a section-level pref via `PUT /library/sections/{id}/prefs?preferLocalMetadata=1` returns HTTP 400. This is a server-level setting only (accessible at `/: /prefs`). Avoid trying to set it per-section.
**Deprecated scanner flags (do not use):** The `Plex Media Scanner --refresh` flag prints *"The '--refresh' operation is deprecated and will be removed"* and does nothing. Use the API endpoints above instead.
curl -s -X PUT "http://IP:32400/library/sections/{id}/prefs?respectTags=0&X-Plex-Token=TOKEN"
```
Best when files lack tags or when you WANT compilation folder names to become album names. Each subfolder becomes one album. Properly-tagged albums in their own folders still show correctly since folder = album name.
After changing, trigger a **forced** metadata refresh so Plex re-reads every file's embedded tags:
```python
# Use force=1 + Python (avoids shell token mangling)
import urllib.request, xml.etree.ElementTree as ET
tree = ET.parse('/home/ray/docker/plex/config/Library/Application Support/Plex Media Server/Preferences.xml')
token = tree.getroot().get('PlexOnlineToken')
for section_id in [1, 4, 5]: # your music library IDs
**Why `force=1`:** Without `force`, Plex only re-checks files it thinks have changed. With `force=1`, it re-reads every file's ID3 tags — essential after changing `respectTags`.
### Subfolder-as-Location Workaround
A technique to prevent cross-folder album grouping: add each subfolder as a SEPARATE library location instead of one parent folder. This makes Plex treat each subfolder as an independent source, preventing tracks in different compilation folders from merging under one "Various Artists" album.
**When to use:** You have `respectTags=true` (for properly-tagged albums) but compilation/playlist folders (e.g., "Best of HipHop 2000-2026", "Techno Remixes 2026") are getting merged because tracks share no common album tag. Adding subfolders as individual locations isolates each folder. **Combined with `respectTags=true`**, this is the optimal config: tagged albums show real names, compilation folders appear as distinct entities.
for title, ref in re.findall(r'title="([^"]+)".*?refreshing="([^"]+)"', data):
print(f"{title}: {'SCANNING' if ref == '1' else 'idle'}")
```
Monitor scan progress via Plex scanner processes: `ps aux | grep "Plex Media Scanner"`
### ⚠️ `album_artist=Various Artists` Causes All Tracks to Show as "Various Artists"
Even with `respectTags=true`, if files have `album_artist=Various Artists` in their ID3 tags, Plex groups ALL those tracks under a single "Various Artists" artist entry — regardless of the `artist` tag. This is distinct from the album-grouping problem: the albums may be correctly split, but every track's artist shows as "Various Artists."
**⚠️ After stripping tags, Plex WON'T re-read them on a normal refresh** — the old artist assignment is cached in the database. The touch+rescan approach is often insufficient because Plex's matcher already stored the "Various Artists" grouping. You MUST use the **Plex Dance** (see "respectTags change does NOT apply retroactively" pitfall above) to force Plex to re-process the files from scratch. The full sequence: remove locations from DB → restart Plex → empty trash → re-add locations to DB → restart Plex → `GET /refresh?force=1`. Without this, 224/264 tracks remained under "Various Artists" even after stripping tags and touching files.
**Script:** `scripts/strip-various-artists.py` — walks a directory recursively, removes `album_artist` when it's "Various Artists" (case-insensitive) or when it's an empty/missing TPE2 frame. All other tags (title, artist, album, year, genre) are untouched. Uses mutagen's EasyID3 for named tags and raw ID3 frame deletion for orphaned TPE2 frames. ~1,000 tracks/minute on spinning disk.
When tracks are genuinely standalone singles or compilations (not albums), the cleanest fix is to tag them as such at the file level. Adding `compilation=1` and `albumartist=Various Artists` tells Plex each track is an independent compilation entry — no more phantom album grouping, regardless of `respectTags` setting.
**When to use:** You have 100s–1000s of tracks in genre/language folders (e.g., `Music/Albanian/`, `Music/English/Rap/`) that are singles, not albums. `respectTags=true` doesn't help because the tracks have album tags pointing at different phantom albums. The SQLite subfolder approach is overkill — you'd need hundreds of location rows. Tag the files directly.
**Script:** `scripts/fix-compilation-tags.py` — walk a directory recursively, add `compilation=1` and `albumartist=Various Artists` to any .mp3 missing them. Existing tags (title, artist, album, year, genre) are untouched.
**Performance:** ~1,000 tracks/minute on spinning disk. 3,865 tracks took ~3 minutes on an 8TB HDD. Scales linearly — no per-file API calls like MusicBrainz lookup.
**After tagging:** Plex → Settings → Libraries → Music → Scan Library Files. Plex re-reads the ID3 tags and stops merging the tagged tracks into albums.
**Why not beets?** beets does MusicBrainz acoustic fingerprint matching per track, which is excellent for accurate album/track identification but far too slow for large singles libraries (3,865 tracks × 5–15s per track = 5–16 hours). When the goal is simply to stop Plex from grouping tracks, mutagen direct-tagging is 100× faster and sufficient.
## Forcing Loudness Analysis (When It's Stuck)
When loudness analysis refuses to run — all tracks show 0 `ma:loudness` entries, butler doing ZERO-WORK — here's the diagnostic and forcing workflow.
### Diagnostic Checklist
Run these checks in order. The most common cause is `LoudnessAnalysisBehavior=asap`.
```python
import urllib.request, xml.etree.ElementTree as ET, re, sqlite3
tree = ET.parse('/home/ray/docker/plex/config/Library/Application Support/Plex Media Server/Preferences.xml')
token = tree.getroot().get('PlexOnlineToken')
db = '/home/ray/docker/plex/config/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db'
for sid, title in re.findall(r'key="(\d+)".*?title="([^"]*)".*?type="artist"', sections):
p = urllib.request.urlopen(f"http://localhost:32400/library/sections/{sid}/prefs?X-Plex-Token={token}").read().decode()
ma = re.search(r'id="musicAnalysis".*?value="([^"]*)"', p)
print(f"Section {sid} ({title}): musicAnalysis={ma.group(1) if ma else 'MISSING'}")
# 3. Count loudness coverage
conn = sqlite3.connect(db)
for sid in [4, 5, 1]: # your music section IDs
cur = conn.execute("""
SELECT COUNT(DISTINCT mp.id) FROM media_parts mp
JOIN media_items mi ON mi.id = mp.media_item_id
WHERE mi.library_section_id = ? AND mp.extra_data LIKE '%"ma:loudness"%'
""", (sid,))
has = cur.fetchone()[0]
cur = conn.execute("SELECT COUNT(DISTINCT mp.id) FROM media_parts mp JOIN media_items mi ON mi.id = mp.media_item_id WHERE mi.library_section_id = ?", (sid,))
total = cur.fetchone()[0]
print(f"Section {sid}: {has}/{total} tracks with loudness {'❌' if has == 0 else '✅'}")
# 4. Check butler activity history for ZERO-WORK patterns
cur = conn.execute("""
SELECT title, subtitle,
CASE WHEN started_at = finished_at THEN 'ZERO' ELSE 'REAL' END
FROM activities WHERE title LIKE '%loudness%' OR title LIKE '%Loudness%'
### Forcing Re-Analysis (when tracks have `pv:deepAnalysisDate` but no loudness)
Plex tracks whether items have been analyzed via `media_parts.extra_data` → `pv:deepAnalysisDate`. If this timestamp exists, the butler considers the item fully analyzed and won't re-run deep analysis. To force re-evaluation:
**Step 1: Clear `pv:deepAnalysisDate` from section 4 tracks**
**Step 4: Verify butler picks up items.** Within 15-30 seconds, `/activities` should show `"Sonic Analysis: Processing N albums"`. Check scanner: `ps aux | grep "Plex Media Scanner"`. The scanner runs `--analyze-deeply --item <id>` per item.
**Step 5: Monitor.** The scanner processes one item at a time. Check `media_parts.extra_data` for `ma:loudness` entries appearing:
```python
cur = conn.execute("""
SELECT COUNT(DISTINCT mp.id) FROM media_parts mp
JOIN media_items mi ON mi.id = mp.media_item_id
WHERE mi.library_section_id = 4 AND mp.extra_data LIKE '%"ma:loudness"%'
""")
print(f"Loudness: {cur.fetchone()[0]}/170")
```
**⚠️ Key limitation on Plex 1.43.2:** Even after clearing `deepAnalysisDate` and successfully triggering `--analyze-deeply` per-item (confirmed by scanner CPU usage and Deep Analysis log showing packet reads), `ma:loudness` data is NOT produced. `--analyze-deeply` reads the file and updates `pv:deepAnalysisDate` but does not compute EBU R128 loudness. The `--analyze-loudness` CLI flag is completely non-functional. **The only reliable fix is downgrading Plex.**
# Re-enable musicAnalysis on any disabled libraries
```
### Related Database Fields
| Field | Table | Purpose |
|-------|-------|---------|
| `pv:deepAnalysisDate` | `media_parts.extra_data` | Timestamp of last deep analysis run. When present, butler skips item. Clear to force re-analysis. |
| `media_analysis_version` | `media_items` | Version number for analysis. Higher values = newer analysis format. If item's version < server max, butler MAY queue it for re-analysis. |
| `ma:loudness` | `media_parts.extra_data` | EBU R128 integrated loudness (LUFS). Missing = loudness analysis never ran. |
| `ma:gain` | `media_parts.extra_data` | ReplayGain-style track gain (dB). |
| `Mapping.db` | `Databases/Music Analysis {sid}/` | maps table linking DB IDs to tree nodes |
## When Sonic Analysis Won't Cooperate
If DJ features remain blocked despite verified completion (known issue on some Plex versions), these features work WITHOUT sonic analysis:
- **Track/Album Radio** — long-press any track → "Go to Radio". Uses metadata (genre, mood, year) for mixing.
- **Related Artists** — browse any artist → "Related" tab. Traditional metadata-based recommendations.
- **Sonic Sage** — AI playlist generation using ChatGPT. Requires adding an OpenAI API key in Plex Web → Settings → Account → Sonic Sage. This uses Plex's sonic data plus AI reasoning.
### Plex 1.43.x: `--analyze-loudness` Broken (NOT a persistence bug)
**Corrected finding (June 2026):** The "persistence bug" previously described here was a misinterpretation. On Plex 1.43.2, sonic fingerprinting indexes ARE correctly persisted — they live in `Databases/Music Analysis {section_id}/` as `.tree` files + `Mapping.db`. The old check `WHERE extra_data LIKE '%sa:%'` returns 0 because modern Plex (1.32+) stores fingerprints in these binary index structures, not as `sa:` keys in `media_parts`.
**The real bug on 1.43.2 is `--analyze-loudness`:** The scanner CLI accepts the flag but it is completely non-functional — exits immediately with code 0, zero effect. The butler only triggers `--analyze-deeply`, which reads file packets and updates `pv:deepAnalysisDate` but does NOT compute EBU R128 loudness. Result: sonic fingerprinting works fine, but loudness analysis cannot run through any mechanism.
**Symptoms of this specific bug (vs. true persistence failure):**
- ✅ `Plex Music Analyzer` processes run at 99.9% CPU, processing tracks with `Music.tflite`
- ✅ Logs confirm: `"Sonic analysis group complete (processed: 28/28)"`, `"Building track index with 170 tracks"`, `"Indexes completed"`
- ❌ `media_parts.extra_data` contains NO `ma:loudness`/`ma:gain`/`ma:peak`/`ma:lra` keys
- ❌ `media_parts.extra_data` contains no `sa:` keys (expected — modern Plex doesn't store them there)
- ❌ `/library/metadata/{id}/similar` API endpoint returns 404 (Plex 1.43.x API behavior; data exists in tree files)
- ❌ Butler DeepMediaAnalysis tasks show as ZERO-WORK because all items have `pv:deepAnalysisDate` set
- ❌ DJ Friendgänger blocked — requires per-track loudness data which never ran
**The only fix is downgrading Plex** to a version where `--analyze-loudness` works (pre-1.43). Clearing `pv:deepAnalysisDate` + restart + toggle triggers `--analyze-deeply` per-item (confirmed by scanner CPU + log output), but this only updates the deepAnalysisDate timestamp — never produces loudness data.
print(f"\n{action}: {fixed}, Errors: {errors}, Skipped (no match): {skipped}")
if__name__=="__main__":
main()
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.