Full Plex migration: preserve everything, no rebuild

Reverses the forward-only decision. That call predated measuring the library;
at 27 TB / 25,148 video items / 337 GB of generated preview cache, rebuilding
costs 1-3 weeks of saturated gigabit and permanently loses every manual match
fix across 23,493 episodes. Migration preserves watch history, resume points,
collections, playlists, manual matches, artwork choices, added-at dates, the
337 GB cache, and server identity - so shared users stay invited, clients do
not re-add, and there is no claim step at all.

migration/remap.sql
  Four path columns remapped: media_parts.file (80,126), section_locations
  .root_path (11 -> 9), media_streams.url (14,837) and metadata_items.guid (9).
  Two formats, not one: backslash/UNC for the first two, file:// with %20
  encoding for the last two - decoding those %20s would break every subtitle
  reference containing a space, which given share names like Radio Shows is
  most of them.

  A scan of all 80 text columns across 82 tables found SIX columns matching
  korval. Only four are paths. taggings.text is one row reading Dr. Korval,
  and metadata_items.summary is four Liaden Universe blurbs about Clan Korval.
  A bare REPLACE on the word would have corrupted a cast credit and four book
  summaries, so every statement is anchored to a path prefix. Proven against a
  synthetic database built from the real path shapes: 12/12, including both
  false positives surviving byte-identical.

  Also drops the Audio Books and Music Organized roots - configured as library
  roots but holding 0 files / 0 bytes. The consolidation had already happened;
  only the dead roots remained.

migration/migrate-db.sh
  Runs the remap through Plex own SQLite build borrowed from the container
  image, keeps a .pre-remap rollback, asserts the counts moved 1:1 and the
  false positives did not, then stats 200 random remapped paths against the
  real filesystem. That last check is the one that matters - SQL running
  without error proves nothing.

migration/gen-preferences.ps1
  Plex live settings store on Windows is the REGISTRY, not Preferences.xml,
  and the two disagree here. Folds ~50 values into one Linux file, dropping
  Windows-only keys including the per-GPU limit keyed by 10de:1b81, the GTX
  1070 PCI ID. Preserves MachineIdentifier and the online token, which is what
  keeps the server identity. The existing Preferences.xml is malformed anyway
  (duplicate allowedNetworks) and will not parse strictly.

scripts/20-cifs.sh
  Eight shares down to six. Mount points now mirror the share names verbatim -
  /mnt/nas/Home Movies, space and all - because that reduces the remap to one
  uniform prefix substitution instead of eleven special cases. New requirement
  this creates: \040 escaping in BOTH fstab fields, not just the share name.
  Verified, plus a round-trip check that a remapped DB path lands under the
  generated mount point.

plex/docker-compose.yml
  Six read-only NAS binds whose source path equals target path, so database,
  host and container agree with no translation. No PLEX_CLAIM. Phase 2 Media
  relocation present but commented.

scripts/60-media-relocate.sh
  Post-soak, optional: moves the 337 GB cache to the 3TB ext4 disk so future
  growth (~28 MB per content-hour) stops eating the SSD. Plex does not support
  this, so the script forces generation on one title afterwards to prove writes
  survive the EXDEV boundary, and rollback is deleting a compose override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-28 21:41:26 -04:00
co-authored by Claude Opus 5
parent 7eadcdf9e8
commit 351eab41ac
7 changed files with 921 additions and 88 deletions
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
# =============================================================================
# 60-media-relocate — move Plex's 337 GB preview cache off the SSD.
#
# RUN THIS ONLY AFTER THE SOAK. It is deliberately not part of bootstrap.
#
# WHY
# Preview thumbnails accumulate at roughly 28 MB per hour of video added. On
# the SSD that eats the ~75 GB of headroom in one to three years. On the 3 TB
# ext4 disk — which otherwise has no job — there is room for about 85,000 more
# content-hours, roughly seven times the current library.
#
# THE RISK, STATED PLAINLY
# Plex does not support relocating Media/. It exposes exactly two relocation
# controls: the data directory ROOT, and TranscoderTempDirectory. Putting
# Media/ on another filesystem is a bind mount Plex is unaware of.
#
# The specific hazard is EXDEV. Plex writes temp files and rename()s them into
# place — the orphaned Preferences.xml.tmp.<guid> files on the old Windows
# install are that pattern leaving fingerprints. A rename() across a
# filesystem boundary fails unless the caller catches EXDEV and falls back to
# copy-then-delete. Whether Plex's does cannot be reasoned about from the
# outside. It can only be tested — which is what this script does, on exactly
# one title, reversibly.
#
# ROLLBACK is a single command, printed at the end.
# =============================================================================
set -uo pipefail
PLEXDIR="/srv/plex"
CFG="$PLEXDIR/config/Library/Application Support/Plex Media Server"
SRC="$CFG/Media"
DEST="/srv/data/plex-media"
OVERRIDE="$PLEXDIR/docker-compose.override.yml"
say(){ printf '\n\033[1;36m=== %s\033[0m\n' "$*"; }
ok(){ printf ' \033[1;32m[ok]\033[0m %s\n' "$*"; }
bad(){ printf ' \033[1;31m[FAIL]\033[0m %s\n' "$*"; }
note(){ printf ' %s\n' "$*"; }
[ "$(id -u)" -eq 0 ] || { bad "must run as root"; exit 1; }
# ---------------------------------------------------------------------------
say "preflight"
# ---------------------------------------------------------------------------
if ! mountpoint -q /srv/data; then
bad "/srv/data is not mounted. Convert the 3TB disk to ext4 first (runbook Phase 11)."
exit 1
fi
ok "/srv/data mounted: $(findmnt -no SOURCE,FSTYPE /srv/data)"
[ -d "$SRC" ] || { bad "no Media directory at $SRC"; exit 1; }
SRC_FS=$(stat -c '%m' "$SRC"); DEST_FS=$(stat -c '%m' /srv/data)
SZ=$(du -sh "$SRC" 2>/dev/null | cut -f1)
note "Media/ is $SZ on $SRC_FS, moving to $DEST_FS"
AVAIL=$(df --output=avail -BG /srv/data | tail -1 | tr -dc '0-9')
NEED=$(du -sBG "$SRC" 2>/dev/null | cut -f1 | tr -dc '0-9')
if [ "${AVAIL:-0}" -lt "$((NEED + 20))" ]; then
bad "need ~${NEED}G + headroom, /srv/data has ${AVAIL}G"
exit 1
fi
ok "space available: ${AVAIL}G"
# ---------------------------------------------------------------------------
say "stopping Plex"
# ---------------------------------------------------------------------------
docker compose -f "$PLEXDIR/docker-compose.yml" stop plex || true
sleep 3
# ---------------------------------------------------------------------------
say "moving Media/ (this takes a while — ~337 GB)"
# ---------------------------------------------------------------------------
install -d -o 3000 -g 3000 "$DEST"
if ! rsync -a --remove-source-files --info=progress2 "$SRC"/ "$DEST"/; then
bad "rsync failed — Media/ left in place, nothing changed structurally"
docker compose -f "$PLEXDIR/docker-compose.yml" start plex
exit 1
fi
find "$SRC" -type d -empty -delete 2>/dev/null
install -d -o 3000 -g 3000 "$SRC" # empty mountpoint for the bind
chown -R 3000:3000 "$DEST"
ok "moved, $(du -sh "$DEST" | cut -f1) now on /srv/data"
# ---------------------------------------------------------------------------
say "enabling the bind mount"
# ---------------------------------------------------------------------------
# An override file rather than editing the compose: rollback is deleting it.
cat > "$OVERRIDE" <<'YAML'
# Added by 60-media-relocate.sh. Delete this file to roll back to the
# supported single-directory layout (and move Media/ back).
services:
plex:
volumes:
- type: bind
source: /srv/data/plex-media
target: /config/Library/Application Support/Plex Media Server/Media
YAML
ok "wrote $OVERRIDE"
docker compose -f "$PLEXDIR/docker-compose.yml" up -d
sleep 10
for i in $(seq 1 40); do
curl -fsS -m 3 http://127.0.0.1:32400/identity >/dev/null 2>&1 && break
sleep 3
done
if docker exec plex sh -c 'mountpoint -q "/config/Library/Application Support/Plex Media Server/Media"'; then
ok "container sees Media/ as a mountpoint"
else
bad "bind mount not active inside the container"
exit 1
fi
# ---------------------------------------------------------------------------
say "THE TEST — can Plex write across the boundary?"
# ---------------------------------------------------------------------------
# Take one movie, set its existing preview index aside, force regeneration,
# and see whether a new one appears on the far side of the mount. Fully
# reversible: the saved copy is restored if generation fails.
TOKEN=$(grep -o 'PlexOnlineToken="[^"]*"' "$CFG/Preferences.xml" | cut -d'"' -f2)
[ -n "$TOKEN" ] || { bad "could not read token from Preferences.xml"; exit 1; }
KEY=$(curl -fsS "http://127.0.0.1:32400/library/sections/2/all?X-Plex-Container-Size=1&X-Plex-Token=$TOKEN" \
| grep -o 'ratingKey="[0-9]*"' | head -1 | cut -d'"' -f2)
[ -n "$KEY" ] || { bad "could not pick a title to test with"; exit 1; }
note "test title ratingKey=$KEY"
BIF=$(find "$DEST" -name 'index-sd.bif' 2>/dev/null | head -1)
if [ -n "$BIF" ]; then
cp -a "$BIF" /tmp/bif-safety.bak
rm -f "$BIF"
note "removed one index-sd.bif (backed up to /tmp/bif-safety.bak)"
else
note "no existing .bif found to displace; watching for any new write instead"
fi
BEFORE=$(find "$DEST" -name '*.bif' -newermt '-1 minute' 2>/dev/null | wc -l)
curl -fsS -X PUT "http://127.0.0.1:32400/library/metadata/$KEY/analyze?X-Plex-Token=$TOKEN" >/dev/null 2>&1
curl -fsS -X PUT "http://127.0.0.1:32400/library/sections/2/refresh?force=1&X-Plex-Token=$TOKEN" >/dev/null 2>&1
note "triggered analyze + forced refresh; waiting up to 5 minutes"
RESULT=fail
for i in $(seq 1 60); do
sleep 5
NOW=$(find "$DEST" -name '*.bif' -newermt '-6 minutes' 2>/dev/null | wc -l)
if [ "$NOW" -gt "$BEFORE" ] || { [ -n "${BIF:-}" ] && [ -f "$BIF" ]; }; then
RESULT=pass; break
fi
done
# Cross-device errors are unambiguous in the log if they happened.
if docker logs plex 2>&1 | tail -500 | grep -qiE 'cross-device|EXDEV|Invalid cross'; then
bad "cross-device link errors in the Plex log — the split is NOT safe here"
RESULT=fail
fi
echo
if [ "$RESULT" = pass ]; then
ok "Plex wrote preview data across the filesystem boundary — layout is good"
rm -f /tmp/bif-safety.bak
echo
note "SSD now holds only the database and artwork; future thumbnail growth"
note "lands on /srv/data, which has room for ~7x the current library."
else
bad "Plex did NOT write across the boundary. Roll back:"
echo
note " rm $OVERRIDE"
note " docker compose -f $PLEXDIR/docker-compose.yml up -d --force-recreate"
note " rsync -a --remove-source-files $DEST/ \"$SRC\"/"
[ -f /tmp/bif-safety.bak ] && note " (restore the test file from /tmp/bif-safety.bak)"
exit 1
fi
exit 0