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