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>
176 lines
7.2 KiB
Bash
Executable File
176 lines
7.2 KiB
Bash
Executable File
#!/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
|