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:
+67
-53
@@ -1,38 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 20-cifs — NAS mounts for the 8 shares Plex actually uses.
|
||||
# 20-cifs — NAS mounts, named to match the Plex database exactly.
|
||||
#
|
||||
# Shares confirmed live from the Windows box 2026-07-27 (exact names/casing):
|
||||
# Audio Books · Education Videos · Health · Home Movies · media
|
||||
# Music Organized · Pictures · Radio Shows (+ "Share" — NOT mounted)
|
||||
# SIX shares. Confirmed live from the Windows box and cross-checked against
|
||||
# the Plex database 2026-07-27:
|
||||
#
|
||||
# "Share" is mounted on Windows today but is not a Plex library root, so it is
|
||||
# deliberately left out. Every entry below maps to a real library section.
|
||||
# media 1,360 movies + 23,493 episodes + all audio (~26.5 TB)
|
||||
# Radio Shows 236 files 1.6 GB
|
||||
# Education Videos 215 files 50.0 GB
|
||||
# Health 30 files 14.4 GB
|
||||
# Home Movies 51 files 28.4 GB
|
||||
# Pictures 385 files 0.9 GB
|
||||
#
|
||||
# 11 Plex library roots resolve onto these 8 mounts:
|
||||
# Audio Books -> /mnt/nas/audiobooks
|
||||
# Music Organized -> /mnt/nas/music-organized
|
||||
# Radio Shows -> /mnt/nas/radio-shows
|
||||
# Pictures -> /mnt/nas/pictures/Plex Pictures
|
||||
# Health -> /mnt/nas/health
|
||||
# Home Movies -> /mnt/nas/home-movies
|
||||
# Education Videos -> /mnt/nas/education-videos
|
||||
# media -> /mnt/nas/media/{movies,tvshows,music,audiobooks}
|
||||
# NOT mounted, deliberately:
|
||||
# Share not a Plex library root
|
||||
# Audio Books a library root, but EMPTY — 0 files, 0 bytes.
|
||||
# Music Organized a library root, but EMPTY — 0 files, 0 bytes.
|
||||
# All audio actually lives under media/audiobooks and media/music. The two
|
||||
# empty roots are vestigial and are deleted by migration/remap.sql.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# MOUNT POINTS MIRROR THE SHARE NAMES, VERBATIM.
|
||||
#
|
||||
# /mnt/nas/Home Movies — capital letters, and yes, a space in the path.
|
||||
#
|
||||
# This is not cosmetic. The Plex database stores \\korval\Home Movies\...,
|
||||
# so mounting at the identically-named path reduces the entire migration to
|
||||
# one uniform prefix substitution: \\korval\ -> /mnt/nas/
|
||||
# Rename the mount points to something tidier and every path becomes its own
|
||||
# special case in the remap, which is how migrations get silently wrong.
|
||||
#
|
||||
# The same paths are bind-mounted into the Plex container at the same
|
||||
# location, so the database, the host and the container all agree with no
|
||||
# further translation anywhere.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# WHY EACH OPTION IS THERE — none of these are decoration:
|
||||
# nofail an unreachable NAS must NOT drop a headless
|
||||
# box into an emergency shell. There is no
|
||||
# keyboard attached to type the root password.
|
||||
# box to an emergency shell. There is no
|
||||
# keyboard attached to type a root password.
|
||||
# _netdev tells systemd this needs the network up.
|
||||
# x-systemd.automount mount on first access rather than at boot, so
|
||||
# a slow NAS never stretches boot time.
|
||||
# x-systemd.mount-timeout=30 bounded failure instead of an indefinite hang.
|
||||
# x-systemd.idle-timeout=600 unmount when idle; keeps stale handles rare.
|
||||
# vers=3.1.1 dialect confirmed from the Windows box.
|
||||
# uid/gid=3000 MUST match Plex's PUID/PGID or every file is
|
||||
# x-systemd.automount mount on first access, so a slow NAS never
|
||||
# stretches boot.
|
||||
# x-systemd.mount-timeout=30 bounded failure instead of an endless hang.
|
||||
# vers=3.1.1 dialect negotiated by the Windows box. If a
|
||||
# mount fails on build night, try vers=3.0 —
|
||||
# arrsstack talks to this same NAS at 3.0.
|
||||
# uid/gid=3000 MUST equal Plex's PUID/PGID or every file is
|
||||
# permission-denied.
|
||||
# \040 fstab field separator is whitespace; share
|
||||
# names with spaces MUST escape them.
|
||||
# \040 fstab splits fields on whitespace, so spaces
|
||||
# must be escaped in BOTH the share name and
|
||||
# the mount point.
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
@@ -45,18 +63,18 @@ MARK_END="# <<< mediabox NAS mounts <<<"
|
||||
|
||||
OPTS="credentials=${CRED},vers=3.1.1,uid=${MEDIA_UID},gid=${MEDIA_GID},file_mode=0664,dir_mode=0775,iocharset=utf8,nofail,_netdev,x-systemd.automount,x-systemd.mount-timeout=30,x-systemd.idle-timeout=600"
|
||||
|
||||
# share-name-on-nas | local mount point
|
||||
# Share name on the NAS == directory name under /mnt/nas. Do not "tidy" these.
|
||||
SHARES=(
|
||||
"Audio Books|/mnt/nas/audiobooks"
|
||||
"Education Videos|/mnt/nas/education-videos"
|
||||
"Health|/mnt/nas/health"
|
||||
"Home Movies|/mnt/nas/home-movies"
|
||||
"media|/mnt/nas/media"
|
||||
"Music Organized|/mnt/nas/music-organized"
|
||||
"Pictures|/mnt/nas/pictures"
|
||||
"Radio Shows|/mnt/nas/radio-shows"
|
||||
"media"
|
||||
"Radio Shows"
|
||||
"Education Videos"
|
||||
"Health"
|
||||
"Home Movies"
|
||||
"Pictures"
|
||||
)
|
||||
|
||||
mp_for() { printf '/mnt/nas/%s' "$1"; }
|
||||
|
||||
# --- --verify mode: check mounts, change nothing ----------------------------
|
||||
if [ "${1:-}" = "--verify" ]; then
|
||||
echo " verifying NAS mounts"
|
||||
@@ -65,12 +83,12 @@ if [ "${1:-}" = "--verify" ]; then
|
||||
echo " [FAIL] $CRED is empty — write the NAS credentials first"
|
||||
exit 1
|
||||
fi
|
||||
for entry in "${SHARES[@]}"; do
|
||||
mp="${entry#*|}"
|
||||
for s in "${SHARES[@]}"; do
|
||||
mp="$(mp_for "$s")"
|
||||
if ls "$mp" >/dev/null 2>&1 && mountpoint -q "$mp"; then
|
||||
printf ' [ok] %-28s %s\n' "$(basename "$mp")" "$(df -h --output=size "$mp" 2>/dev/null | tail -1 | tr -d ' ')"
|
||||
printf ' [ok] %-20s %s\n' "$s" "$(df -h --output=size "$mp" 2>/dev/null | tail -1 | tr -d ' ')"
|
||||
else
|
||||
printf ' [FAIL] %-28s not mounted\n' "$(basename "$mp")"
|
||||
printf ' [FAIL] %-20s not mounted\n' "$s"
|
||||
rc=1
|
||||
fi
|
||||
done
|
||||
@@ -83,12 +101,11 @@ install -d -m 0700 /etc/cifs
|
||||
[ -f "$CRED" ] || install -m 0600 /dev/null "$CRED"
|
||||
chmod 600 "$CRED"
|
||||
|
||||
# The credentials file is created EMPTY by autoinstall on purpose. The real
|
||||
# password is written post-boot over the MCP so it never rides on the USB.
|
||||
if [ ! -s "$CRED" ]; then
|
||||
cat <<'CREDNOTE'
|
||||
NOTE: /etc/cifs/korval.cred is empty. That is expected at this stage.
|
||||
The mounts will fail (harmlessly, thanks to nofail) until you write:
|
||||
NOTE: /etc/cifs/korval.cred is empty. That is expected at this stage — the
|
||||
autoinstall creates it empty on purpose so the NAS password never
|
||||
rides on removable media. The mounts fail harmlessly (nofail) until:
|
||||
printf 'username=<user>\npassword=<pass>\ndomain=WORKGROUP\n' \
|
||||
> /etc/cifs/korval.cred
|
||||
chmod 600 /etc/cifs/korval.cred
|
||||
@@ -96,8 +113,8 @@ if [ ! -s "$CRED" ]; then
|
||||
CREDNOTE
|
||||
fi
|
||||
|
||||
for entry in "${SHARES[@]}"; do
|
||||
mp="${entry#*|}"
|
||||
for s in "${SHARES[@]}"; do
|
||||
mp="$(mp_for "$s")"
|
||||
install -d -m 0755 "$mp"
|
||||
chown ${MEDIA_UID}:${MEDIA_GID} "$mp"
|
||||
done
|
||||
@@ -112,12 +129,9 @@ awk -v b="$MARK_BEGIN" -v e="$MARK_END" '
|
||||
|
||||
{
|
||||
printf '%s\n' "$MARK_BEGIN"
|
||||
for entry in "${SHARES[@]}"; do
|
||||
share="${entry%%|*}"
|
||||
mp="${entry#*|}"
|
||||
# escape spaces as \040 in BOTH fields (mount points here have none, but
|
||||
# the escaping is applied uniformly so a future renamed mount is safe)
|
||||
esc_share="${share// /\\040}"
|
||||
for s in "${SHARES[@]}"; do
|
||||
mp="$(mp_for "$s")"
|
||||
esc_share="${s// /\\040}"
|
||||
esc_mp="${mp// /\\040}"
|
||||
printf '//%s/%s %s cifs %s 0 0\n' "$NAS" "$esc_share" "$esc_mp" "$OPTS"
|
||||
done
|
||||
@@ -137,12 +151,12 @@ rm -f "$tmp"
|
||||
systemctl daemon-reload
|
||||
|
||||
echo " fstab updated (backup written alongside). Managed block:"
|
||||
sed -n "/${MARK_BEGIN//\//\\/}/,/${MARK_END//\//\\/}/p" /etc/fstab | sed 's/^/ /'
|
||||
sed -n "/>>> mediabox NAS mounts/,/<<< mediabox NAS mounts/p" /etc/fstab | cut -c1-120 | sed 's/^/ /'
|
||||
|
||||
if [ -s "$CRED" ]; then
|
||||
echo " credentials present — attempting mounts"
|
||||
for entry in "${SHARES[@]}"; do
|
||||
mp="${entry#*|}"
|
||||
for s in "${SHARES[@]}"; do
|
||||
mp="$(mp_for "$s")"
|
||||
if timeout 40 mount "$mp" 2>/dev/null || mountpoint -q "$mp"; then
|
||||
printf ' [ok] %s\n' "$mp"
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user