# Plex API & Sonic Analysis Management

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
import urllib.request
import 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')

url = f"http://localhost:32400/library/sections?X-Plex-Token={token}"
with urllib.request.urlopen(url) as resp:
    data = resp.read().decode()
```

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}/prefs` | GET/PUT | Library-level preferences |
| `/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 |
| `/:/prefs` | GET/PUT | Server-level preferences |

### Refresh vs Force Refresh

- `GET /library/sections/{id}/refresh` — Basic scan: checks for new/changed files, runs metadata matching. Fast.
- `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:

```bash
# Read all preferences
curl -s "http://IP:32400/library/sections/1/prefs?X-Plex-Token=TOKEN"

# Enable sonic analysis
curl -s -X PUT "http://IP:32400/library/sections/1/prefs?musicAnalysis=1&X-Plex-Token=TOKEN"
```

## Activities / Task Monitoring

```bash
curl -s "http://IP:32400/activities?X-Plex-Token=TOKEN" -H "Accept: application/json"
```

Returns active tasks with `title`, `subtitle`, `progress` (0-100%). Key task types:
- `media.generate.music.analysis` — Sonic Analysis
- `butler` — Scheduled maintenance umbrella
- `library.update.section` — Library scan
- `media.download` — Track downloads

## Sonic Analysis

### What it powers

- Plexamp DJ modes (auto DJ, guest DJ)
- "Sonically Similar" tracks/albums/artists
- Sonic Sage AI playlists (requires ChatGPT API key in Plex settings)
- Smart transitions in Plexamp

### Requirements

- **Plex Pass** — check via `GET /` → `myPlexSubscription: True`
- **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
import urllib.request, xml.etree.ElementTree as ET, re

tree = ET.parse('/home/ray/docker/plex/config/Library/Application Support/Plex Media Server/Preferences.xml')
token = tree.getroot().get('PlexOnlineToken')

# Get all music libraries (type=8)
sections_xml = urllib.request.urlopen(f"http://localhost:32400/library/sections?X-Plex-Token={token}").read().decode()
for sid in re.findall(r'Directory key="(\d+)" title="([^"]*)" type="(\w+)"', sections_xml):
    if sid[2] == 'artist':  # music libraries show as type="artist" in XML
        prefs = urllib.request.urlopen(f"http://localhost:32400/library/sections/{sid[0]}/prefs?X-Plex-Token={token}").read().decode()
        ma = re.search(r'id="musicAnalysis".*?value="([^"]*)"', prefs)
        print(f"Section {sid[0]} ({sid[1]}): musicAnalysis={ma.group(1) if ma else 'NOT FOUND'}")
```

If `musicAnalysis=false`, analysis has NEVER run on that library — enable it:
```python
url = f"http://localhost:32400/library/sections/{sid}/prefs?musicAnalysis=1&X-Plex-Token={token}"
req = urllib.request.Request(url, method='PUT')
urllib.request.urlopen(req)
```

**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
import sqlite3
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
    FROM activities
    WHERE title LIKE '%Sonic%'
    ORDER BY started_at DESC
    LIMIT 10
""")
for r in cur.fetchall():
    print(f"  {r[0]}: {r[1]} | {r[4]} ({r[2]} → {r[3]})")
conn.close()
```

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

**3. Verify server-level `MusicAnalysisBehavior`:**

```python
prefs = urllib.request.urlopen(f"http://localhost:32400/:/prefs?X-Plex-Token={token}").read().decode()
m = re.search(r'id="MusicAnalysisBehavior".*?value="([^"]*)"', prefs)
print(f"MusicAnalysisBehavior = {m.group(1) if m else 'NOT FOUND'}")
```

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`:
```python
url = f"http://localhost:32400/:/prefs?MusicAnalysisBehavior=scheduled&X-Plex-Token={token}"
req = urllib.request.Request(url, method='PUT')
urllib.request.urlopen(req)
```

### Two-Phase Process

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"
```

Verify:
```bash
curl -s "http://IP:32400/library/sections/{id}/prefs?X-Plex-Token=TOKEN" \
  | grep -oP 'id="musicAnalysis".*?value="[^"]*"'
# Should show: value="true"
```

### Triggering (forced — skip the 2-5 AM wait)

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.

### Monitoring Progress

**API (live overview):**
```bash
curl -s "http://IP:32400/activities?X-Plex-Token=TOKEN" -H "Accept: application/json" \
  | python3 -c "import sys,json; d=json.load(sys.stdin)
for a in d['MediaContainer'].get('Activity',[]):
    if 'Sonic' in a.get('title',''):
        print(f\"Sonic: {a['subtitle']} ({a.get('progress',0)}%)\")"
```

**Deep Analysis log (per-track detail):**
```bash
# Shows individual track loudness/fingerprint processing
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"
```

Key tables:
- `library_sections` — Library definitions (section_type: 8=music, 1=movie, 2=TV)
- `section_locations` — Library folder paths (id, library_section_id, root_path)
- `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
    WHERE mi.library_section_id = ?
    AND md_album.metadata_type = 9
    AND md_album.user_thumb_url IS NOT NULL 
    AND md_album.user_thumb_url != ''
""", (section_id,))
```

## Server Preferences (Butler & Analysis)

Key prefs visible at `GET /:/prefs`:

| Pref | Default | Purpose |
|------|---------|---------|
| `ButlerStartHour` | 2 | Maintenance window start |
| `ButlerEndHour` | 5 | Maintenance window end |
| `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.

```bash
# Check current value
curl -s "http://IP:32400/:/prefs?X-Plex-Token=TOKEN" | grep -oP 'id="MusicAnalysisBehavior".*?value="[^"]*"'

# Fix if stuck on manual
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.

## File Permission Troubleshooting (Plex Container Access)

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:

```bash
# Diagnosis — check if xattrs exist
getfattr -d -m - "/path/to/file.mp3"
# Output: user.DOSATTRIB=0sAAAFAAUAAAARAAAAIAAAAOTJdJmXAt0B

# Fix — strip recursively
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')
token = tree.getroot().get('PlexOnlineToken')

# Check thumb serving — pick any album ID
url = f'http://localhost:32400/library/metadata/{album_id}/thumb/1781090321?X-Plex-Token={token}'
with urllib.request.urlopen(url) as resp:
    print(f"Thumb: {resp.status} {resp.headers.get('Content-Type')} {resp.headers.get('Content-Length')}B")
```

### Art Coverage Check (DB)

```python
conn = sqlite3.connect(db_path)
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
    WHERE mi.library_section_id IN (1,4,5) 
    AND md_album.metadata_type = 9
    AND md_album.user_thumb_url IS NOT NULL 
    AND md_album.user_thumb_url != ''
""")
```

Note: metadata_type=9 is album, type=10 is track. Albums have `user_thumb_url`; tracks inherit art from their parent album.

### Check Files for Embedded Art

```bash
ffprobe -v quiet -show_streams -select_streams v '/path/to/file.mp3' 2>&1 | grep -i 'attached_pic\|DISPOSITION'
```

### PhotoTranscoder Cache

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:

```bash
# For each music library section:
curl -s "http://IP:32400/library/sections/{id}/refresh?force=1&X-Plex-Token=TOKEN"
```

### Client-Side Fixes

If server-side art is confirmed working (HTTP 200 with valid JPEG):

1. **Force-quit and reopen Plexamp** — it caches art aggressively
2. **Settings → Source** — verify connected to the correct server
3. **Settings → Advanced → Clear Cache** — clears Plexamp's local image cache

## Album Grouping & "Various Artists" Issues

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.

### Diagnosis

```bash
# Check current respectTags setting
curl -s "http://IP:32400/library/sections/{id}/prefs?X-Plex-Token=TOKEN" \
  | grep -oP 'id="respectTags".*?value="[^"]*"'

# Quick tag probe — spot-check first 3 files in a folder
for f in /path/to/folder/*.mp3; do
    ffprobe -v quiet -show_entries format_tags=title,artist,album,album_artist \
        -of default=noprint_wrappers=1:nokey=1 "$f" 2>&1 | paste - - - -
done | head -3
```

### ⚠️ 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:
   ```bash
   find /path/to/music -type f \( -name "*.mp3" -o -name "*.flac" -o -name "*.m4a" \) -exec touch {} +
   ```
   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)
   subprocess.run(['docker', 'compose', 'restart', 'plex'],
                  cwd='/home/ray/docker/plex', capture_output=True, timeout=30)
   time.sleep(12)
   
   # Step 3: Empty trash (clears orphaned database entries)
   url = f'http://localhost:32400/library/sections/{section_id}/emptyTrash?X-Plex-Token={token}'
   req = urllib.request.Request(url, method='PUT')
   urllib.request.urlopen(req)
   
   # Step 4: Re-add location(s) to DB
   conn = sqlite3.connect(db)
   for loc_path in ['/path/to/music/folder1', '/path/to/music/folder2']:
       conn.execute("INSERT INTO section_locations (library_section_id, root_path) VALUES (?, ?)",
                    (section_id, loc_path))
   conn.commit()
   conn.close()
   
   # Step 5: Trigger fresh scan
   url = f'http://localhost:32400/library/sections/{section_id}/refresh?force=1&X-Plex-Token={token}'
   urllib.request.urlopen(url)
   ```

   **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.

### Fixes

**Option A: Enable `respectTags` (use file metadata)**

```bash
curl -s -X PUT "http://IP:32400/library/sections/{id}/prefs?respectTags=1&X-Plex-Token=TOKEN"
```

Best when files have proper ID3 album/artist tags. Compilation folders will show each track under its original album as "Various Artists."

**Option B: Disable `respectTags` (use folder names)**

```bash
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
    url = f'http://localhost:32400/library/sections/{section_id}/refresh?force=1&X-Plex-Token={token}'
    with urllib.request.urlopen(url) as resp:
        print(f"Section {section_id}: HTTP {resp.status}")
```

**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.

**Complete workflow (database → restart → trigger scan → verify):**

**1. Add subfolder locations to the database:**

```python
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)

folders = [
    "Best of HipHop (2000-2026)",
    "Techno Remixes 2026 (Top 100)",
    # ... all subfolders to add as individual locations
]
base = "/mnt/seagate8tb/Music/English"
section_id = 1  # Library section ID from /library/sections

for folder in folders:
    path = f"{base}/{folder}"
    conn.execute(
        "INSERT INTO section_locations (library_section_id, root_path) VALUES (?, ?)",
        (section_id, path)
    )

# Remove the parent location after all subfolders are added
conn.execute("DELETE FROM section_locations WHERE id = 1")  # parent location id
conn.commit()
conn.close()
```

**2. Restart Plex:**

```bash
cd ~/docker/plex && docker compose restart plex
```

**3. Wait ~15 seconds for Plex to initialize**, then trigger a scan. Use Python to avoid token mangling (see Plex Token section above):

```python
import urllib.request
import 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')

# Trigger scan — GET returns 200 when scan is queued
url = f"http://localhost:32400/library/sections/1/refresh?X-Plex-Token={token}"
with urllib.request.urlopen(url) as resp:
    print(f"Status: {resp.status}")  # 200 = scan queued
```

**4. Verify scan is running** by checking the `refreshing` attribute on the library:

```python
import re
url = f"http://localhost:32400/library/sections?X-Plex-Token={token}"
with urllib.request.urlopen(url) as resp:
    data = resp.read().decode()
    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."

**Diagnosis:**
```bash
# Check a few files for album_artist
for f in /path/to/music/*.mp3; do
    ffprobe -v quiet -show_entries format_tags=album_artist \
        -of default=noprint_wrappers=1:nokey=1 "$f" 2>/dev/null
done | sort | uniq -c | sort -rn | head -5
```

**DB verification** (check how many tracks are under "Various Artists" after a scan):
```python
cur = conn.execute("""
    SELECT COUNT(DISTINCT md_track.id)
    FROM metadata_items md_artist
    JOIN metadata_items md_album ON md_album.parent_id = md_artist.id
    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
    WHERE mi.library_section_id = ? AND md_artist.metadata_type = 8
    AND md_artist.title = 'Various Artists'
""", (section_id,))
```

**Fix:** Strip `album_artist=Various Artists` and empty `album_artist` from files:

```bash
# Use the bundled script
python3 scripts/strip-various-artists.py /mnt/seagate8tb/Music/Albanian/

# Or dry-run first to see what would change:
python3 scripts/strip-various-artists.py --dry-run /path/to/music/

# Strip a different artist name:
python3 scripts/strip-various-artists.py --artist="Various" /path/to/music/
```

**⚠️ 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.

```bash
# Default: adds compilation=1 + albumartist=Various Artists
python3 fix-compilation-tags.py /mnt/seagate8tb/Music/Albanian/

# Custom albumartist
python3 fix-compilation-tags.py --albumartist="Ray's Picks" /path/to/music/

# Compilation flag only (leave albumartist alone)
python3 fix-compilation-tags.py --compilation-only /path/to/music/
```

Requires `mutagen`: `pip install mutagen`.

**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'

# 1. Check server-level behaviors
prefs = urllib.request.urlopen(f"http://localhost:32400/:/prefs?X-Plex-Token={token}").read().decode()
for key in ['MusicAnalysisBehavior', 'LoudnessAnalysisBehavior']:
    m = re.search(rf'id="{key}".*?value="([^"]*)"', prefs)
    status = m.group(1) if m else 'MISSING'
    print(f"{key}: {status} {'❌ FIX ME' if status != 'scheduled' else '✅'}")

# 2. Check per-library musicAnalysis
sections = urllib.request.urlopen(f"http://localhost:32400/library/sections?X-Plex-Token={token}").read().decode()
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%'
    ORDER BY started_at DESC LIMIT 5
""")
print("\nRecent loudness activity:")
for r in cur.fetchall():
    print(f"  [{r[2]}] {r[0]}: {r[1]}")
conn.close()
```

### If `LoudnessAnalysisBehavior=asap`

Fix:
```python
url = f"http://localhost:32400/:/prefs?LoudnessAnalysisBehavior=scheduled&X-Plex-Token={token}"
req = urllib.request.Request(url, method='PUT')
urllib.request.urlopen(req)
```

### 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**

```python
conn = sqlite3.connect(db)
cur = conn.execute("""
    SELECT mp.id, mp.extra_data FROM media_parts mp
    JOIN media_items mi ON mi.id = mp.media_item_id
    WHERE mi.library_section_id = 4
""")
for pid, ed in cur.fetchall():
    new_ed = re.sub(r',"pv:deepAnalysisDate":"[^"]*"', '', ed)
    new_ed = re.sub(r'"pv:deepAnalysisDate":"[^"]*",?', '', new_ed)
    if new_ed != ed:
        conn.execute("UPDATE media_parts SET extra_data = ? WHERE id = ?", (new_ed, pid))
conn.commit()
conn.close()
```

**Step 2: Restart Plex to reset butler state**
```bash
cd ~/docker/plex && docker compose restart plex
```

**Step 3: Set butler hours to current hour + toggle musicAnalysis**
```python
from datetime import datetime
now = datetime.now()
start, end = now.hour, (now.hour + 2) % 24

# Set butler window
urllib.request.urlopen(urllib.request.Request(
    f"http://localhost:32400/:/prefs?ButlerStartHour={start}&ButlerEndHour={end}&X-Plex-Token={token}",
    method='PUT'))

# Toggle musicAnalysis off → on to wake butler
for sid in [4]:
    urllib.request.urlopen(urllib.request.Request(
        f"http://localhost:32400/library/sections/{sid}/prefs?musicAnalysis=0&X-Plex-Token={token}",
        method='PUT'))
time.sleep(3)
urllib.request.urlopen(urllib.request.Request(
    f"http://localhost:32400/library/sections/{sid}/prefs?musicAnalysis=1&X-Plex-Token={token}",
    method='PUT'))
```

**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.**

**Step 6: Restore settings**
```python
# Restore butler hours
urllib.request.urlopen(urllib.request.Request(
    "http://localhost:32400/:/prefs?ButlerStartHour=2&ButlerEndHour=5&X-Plex-Token={token}",
    method='PUT'))
# 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). |
| `ma:peak` | `media_parts.extra_data` | True peak (dBTP). |
| `ma:lra` | `media_parts.extra_data` | Loudness range (LU). |

**Index files** (real persistence for sonic fingerprints):
| File | Path | Contains |
|------|------|----------|
| `Track.tree` | `Databases/Music Analysis {sid}/` | Per-track fingerprint vectors |
| `Album.tree` | `Databases/Music Analysis {sid}/` | Per-album aggregate fingerprints |
| `Artist.tree` | `Databases/Music Analysis {sid}/` | Per-artist aggregate fingerprints |
| `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"`
- ✅ `Databases/Music Analysis {sid}/` directory contains populated `Track.tree`, `Album.tree`, `Artist.tree`, `Mapping.db` files
- ❌ `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.
