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>
163 lines
7.6 KiB
Bash
Executable File
163 lines
7.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ============================================================================
|
|
# migrate-db.sh — apply the path remap to a COPY of the Plex database and
|
|
# prove it worked.
|
|
#
|
|
# Usage:
|
|
# sudo ./migrate-db.sh /path/to/com.plexapp.plugins.library.db [PLEX_IMAGE]
|
|
#
|
|
# This script NEVER touches a live database. It operates on the file you hand
|
|
# it, which must already be a copy taken from a stopped server or one of
|
|
# Plex's own dated backups.
|
|
#
|
|
# It uses Plex's own SQLite build, borrowed from the Plex container image, so
|
|
# nothing has to be installed on the host and stock sqlite3 is never involved.
|
|
#
|
|
# The final check is the one that actually matters: it takes a random sample
|
|
# of remapped paths and stats them on disk. SQL that runs without error proves
|
|
# nothing; files that open prove everything.
|
|
# ============================================================================
|
|
set -uo pipefail
|
|
|
|
DB="${1:?usage: migrate-db.sh <path-to-library.db> [plex-image]}"
|
|
IMAGE="${2:-lscr.io/linuxserver/plex:latest}"
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
SAMPLE=200
|
|
|
|
[ -f "$DB" ] || { echo "no such database: $DB"; exit 1; }
|
|
|
|
DBDIR="$(cd "$(dirname "$DB")" && pwd)"
|
|
DBNAME="$(basename "$DB")"
|
|
|
|
PASS=0; FAIL=0
|
|
ok(){ printf ' \033[1;32m[pass]\033[0m %s\n' "$*"; PASS=$((PASS+1)); }
|
|
bad(){ printf ' \033[1;31m[FAIL]\033[0m %s\n' "$*"; FAIL=$((FAIL+1)); }
|
|
note(){ printf ' %s\n' "$*"; }
|
|
|
|
# Run a query through Plex's SQLite inside a throwaway container.
|
|
psql() {
|
|
docker run --rm -i \
|
|
-v "$DBDIR:/db" \
|
|
-v "$HERE:/migration:ro" \
|
|
--entrypoint "/usr/lib/plexmediaserver/Plex SQLite" \
|
|
"$IMAGE" "/db/$DBNAME" "$@" 2>/dev/null
|
|
}
|
|
|
|
echo "=== database: $DB"
|
|
echo "=== image: $IMAGE"
|
|
echo
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BEFORE — capture the baseline
|
|
# ---------------------------------------------------------------------------
|
|
echo "--- baseline (Windows paths present) ---"
|
|
B_PARTS=$(psql "SELECT COUNT(*) FROM media_parts WHERE file LIKE '\\\\korval\\%';")
|
|
B_ROOTS=$(psql "SELECT COUNT(*) FROM section_locations WHERE root_path LIKE '\\\\korval\\%';")
|
|
B_STREAMS=$(psql "SELECT COUNT(*) FROM media_streams WHERE url LIKE 'file://korval/%';")
|
|
B_GUIDS=$(psql "SELECT COUNT(*) FROM metadata_items WHERE guid LIKE 'file://korval/%';")
|
|
B_TAG=$(psql "SELECT COUNT(*) FROM taggings WHERE text LIKE '%Korval%';")
|
|
B_SUM=$(psql "SELECT COUNT(*) FROM metadata_items WHERE summary LIKE '%Korval%';")
|
|
note "media_parts.file $B_PARTS"
|
|
note "section_locations.root_path $B_ROOTS"
|
|
note "media_streams.url $B_STREAMS"
|
|
note "metadata_items.guid $B_GUIDS"
|
|
note "taggings.text (NOT a path) $B_TAG <- must survive untouched"
|
|
note "metadata_items.summary $B_SUM <- must survive untouched"
|
|
|
|
if [ "${B_PARTS:-0}" -eq 0 ]; then
|
|
echo
|
|
echo " Nothing to remap — this database has no Windows paths left."
|
|
echo " (Already migrated? Wrong file?)"
|
|
exit 1
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# REMAP
|
|
# ---------------------------------------------------------------------------
|
|
echo
|
|
echo "--- applying remap.sql ---"
|
|
cp -a "$DB" "${DB}.pre-remap" && note "rollback copy: ${DB}.pre-remap"
|
|
if psql ".read /migration/remap.sql"; then
|
|
note "remap applied"
|
|
else
|
|
bad "remap.sql failed — restore from ${DB}.pre-remap"
|
|
exit 1
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AFTER — assert
|
|
# ---------------------------------------------------------------------------
|
|
echo
|
|
echo "--- verification ---"
|
|
|
|
A_WINPARTS=$(psql "SELECT COUNT(*) FROM media_parts WHERE file LIKE '\\\\korval\\%';")
|
|
A_WINROOTS=$(psql "SELECT COUNT(*) FROM section_locations WHERE root_path LIKE '\\\\korval\\%';")
|
|
A_WINSTRM=$(psql "SELECT COUNT(*) FROM media_streams WHERE url LIKE 'file://korval/%';")
|
|
A_WINGUID=$(psql "SELECT COUNT(*) FROM metadata_items WHERE guid LIKE 'file://korval/%';")
|
|
|
|
[ "$A_WINPARTS" = 0 ] && ok "no Windows paths left in media_parts" || bad "media_parts still has $A_WINPARTS Windows paths"
|
|
[ "$A_WINROOTS" = 0 ] && ok "no Windows paths left in section_locations" || bad "section_locations still has $A_WINROOTS"
|
|
[ "$A_WINSTRM" = 0 ] && ok "no Windows URLs left in media_streams" || bad "media_streams still has $A_WINSTRM"
|
|
[ "$A_WINGUID" = 0 ] && ok "no Windows URLs left in metadata_items.guid" || bad "metadata_items.guid still has $A_WINGUID"
|
|
|
|
A_PARTS=$(psql "SELECT COUNT(*) FROM media_parts WHERE file LIKE '/mnt/nas/%';")
|
|
A_STREAMS=$(psql "SELECT COUNT(*) FROM media_streams WHERE url LIKE 'file:///mnt/nas/%';")
|
|
A_GUIDS=$(psql "SELECT COUNT(*) FROM metadata_items WHERE guid LIKE 'file:///mnt/nas/%';")
|
|
A_ROOTS=$(psql "SELECT COUNT(*) FROM section_locations WHERE root_path LIKE '/mnt/nas/%';")
|
|
|
|
[ "$A_PARTS" = "$B_PARTS" ] && ok "media_parts remapped 1:1 ($A_PARTS)" || bad "media_parts $B_PARTS -> $A_PARTS"
|
|
[ "$A_STREAMS" = "$B_STREAMS" ] && ok "media_streams remapped 1:1 ($A_STREAMS)" || bad "media_streams $B_STREAMS -> $A_STREAMS"
|
|
[ "$A_GUIDS" = "$B_GUIDS" ] && ok "metadata_items.guid remapped 1:1" || bad "guid $B_GUIDS -> $A_GUIDS"
|
|
# roots: 11 in, 9 out — the two empty legacy roots are deliberately deleted
|
|
[ "$A_ROOTS" = 9 ] && ok "9 library roots (2 empty legacy roots dropped)" || bad "expected 9 roots, got $A_ROOTS"
|
|
|
|
# --- the false positives must be untouched --------------------------------
|
|
A_TAG=$(psql "SELECT COUNT(*) FROM taggings WHERE text LIKE '%Korval%';")
|
|
A_SUM=$(psql "SELECT COUNT(*) FROM metadata_items WHERE summary LIKE '%Korval%';")
|
|
[ "$A_TAG" = "$B_TAG" ] && ok "taggings.text untouched ($A_TAG row, 'Dr. Korval')" || bad "taggings.text changed: $B_TAG -> $A_TAG"
|
|
[ "$A_SUM" = "$B_SUM" ] && ok "metadata_items.summary untouched ($A_SUM Liaden blurbs)" || bad "summary changed: $B_SUM -> $A_SUM"
|
|
|
|
# --- no double-encoding or mangled separators ------------------------------
|
|
BAD_SEP=$(psql "SELECT COUNT(*) FROM media_parts WHERE file LIKE '%\\%';")
|
|
[ "$BAD_SEP" = 0 ] && ok "no backslashes remain in media_parts.file" || bad "$BAD_SEP rows still contain a backslash"
|
|
BAD_PCT=$(psql "SELECT COUNT(*) FROM media_parts WHERE file LIKE '%|%20%';")
|
|
[ "${BAD_PCT:-0}" = 0 ] && ok "no %20 leaked into filesystem paths" || bad "$BAD_PCT rows have %20 in a filesystem path"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# THE REAL TEST — do the remapped paths open?
|
|
# ---------------------------------------------------------------------------
|
|
echo
|
|
echo "--- resolving $SAMPLE random remapped paths against the filesystem ---"
|
|
if ! mountpoint -q /mnt/nas/media 2>/dev/null; then
|
|
note "SKIPPED: /mnt/nas/media is not mounted. Mount the NAS and re-run:"
|
|
note " $0 $DB"
|
|
else
|
|
found=0; missing=0; shown=0
|
|
while IFS= read -r p; do
|
|
[ -z "$p" ] && continue
|
|
if [ -e "$p" ]; then
|
|
found=$((found+1))
|
|
else
|
|
missing=$((missing+1))
|
|
if [ "$shown" -lt 5 ]; then note "MISSING: $p"; shown=$((shown+1)); fi
|
|
fi
|
|
done < <(psql "SELECT file FROM media_parts WHERE file LIKE '/mnt/nas/%' ORDER BY RANDOM() LIMIT $SAMPLE;")
|
|
|
|
total=$((found+missing))
|
|
if [ "$total" -gt 0 ] && [ "$missing" -eq 0 ]; then
|
|
ok "$found/$total sampled files resolve on disk"
|
|
else
|
|
bad "$missing/$total sampled files DO NOT resolve — remap or mounts are wrong"
|
|
fi
|
|
fi
|
|
|
|
echo
|
|
echo "=== $PASS passed, $FAIL failed ==="
|
|
if [ "$FAIL" -eq 0 ]; then
|
|
echo "Database is ready. Rollback copy kept at ${DB}.pre-remap"
|
|
exit 0
|
|
else
|
|
echo "DO NOT USE THIS DATABASE. Restore: mv ${DB}.pre-remap $DB"
|
|
exit 1
|
|
fi
|