Files
2026-07-12 10:17:17 -04:00

50 KiB
Raw Permalink Blame History

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:

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:

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:

# 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

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:

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:

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:

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:

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:

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"):

# For each music library section:
curl -s -X PUT "http://IP:32400/library/sections/{id}/prefs?musicAnalysis=1&X-Plex-Token=TOKEN"

Verify:

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:

    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:

    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:

# 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):

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

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

# Verify scanner is running and on which files
ps aux | grep "Plex Media Scanner" | grep -v grep

Database (check fingerprint state):

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

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:

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.

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

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:

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

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:

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)

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

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:

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:

# 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

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

    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:

    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)

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)

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:

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

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:

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

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:

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:

# 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):

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:

# 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 100s1000s 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.

# 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 × 515s per track = 516 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.

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:

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_datapv: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

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

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

Step 3: Set butler hours to current hour + toggle musicAnalysis

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:

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

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