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
+200
View File
@@ -0,0 +1,200 @@
# Plex Migration — Windows → Linux, nothing lost
Supersedes the earlier "forward-only, fresh Plex" decision. That call was made
before the library was measured. At **27 TB, 25,148 video items and 337 GB of
already-generated preview cache**, rebuilding fresh costs one to three weeks of
saturated NAS link and permanently loses every manual match fix. Migration is
the cheaper and lower-risk option.
## What survives
Watch history and resume points across all items, play counts, ratings,
collections, playlists, every manual match correction, poster and artwork
choices, added-at dates (so Recently Added stays meaningful), the 337 GB
preview cache, and **the server identity** — shared users stay invited, clients
do not need to re-add the server, and there is no claim step at all.
Plex's own article calls cross-OS moves "possible but not officially supported
and not covered here." That is a documentation gap, not a technical barrier.
The database is platform-independent; what Plex declines to help with is
mapping the settings, which `gen-preferences.ps1` does.
## Facts everything below depends on
| | |
|---|---|
| Source | PMS **1.43.3.10828**, Windows 10, data dir `D:\Plex\Local Data` |
| Server identity | `machineIdentifier eb69ec8a…` — preserved, not regenerated |
| Data directory | ~374 GB total → ~364 GB after pruning |
| Library | 1,360 movies · 23,493 episodes · 295 other video · 20,798 audio |
| Path rows | 80,126 `media_parts` · 14,837 `media_streams` · 11 → 9 roots |
| Target layout | Whole data dir on the 500 GB SSD (~75 GB free) |
| NAS mounts | 6 shares at `/mnt/nas/<exact share name>` |
---
## M0 — Prepare, while Windows is still running
**Disable automatic trash emptying first.** This is step 1 of Plex's own
procedure and it matters more here than usual: the registry confirms
`autoEmptyTrash = 1`, and the new NAS account is read-write. A missing mount at
scan time would mean Plex deletes library records it has permission to delete.
Settings → Library → untick *Empty trash automatically after every scan*.
Generate the Linux settings file from the registry:
```powershell
powershell -ExecutionPolicy Bypass -File .\gen-preferences.ps1 -Out C:\mbx\Preferences.xml
[xml](Get-Content -Raw C:\mbx\Preferences.xml) | Out-Null; 'XML OK'
```
**Stop Plex Media Server completely** — tray icon → Exit, then confirm no
`Plex Media Server.exe` remains. The database must not be open when copied, or
the WAL will be mid-flight and the copy inconsistent.
Copy the data directory to the NAS. Skip what must not travel:
```powershell
robocopy "D:\Plex\Local Data\Plex Media Server" "\\korval\Share\backups\mediabox\PMS" `
/E /COPY:DAT /R:2 /W:5 /MT:16 `
/XD "Cache" "Codecs" "Drivers" "Crash Reports" "Logs" "Updates" `
/LOG:C:\mbx\pms-copy.log /TEE
```
`Codecs` and `Drivers` are **Windows binaries** — copying them to Linux breaks
transcoding. `Cache` is regenerable. Excluding them also trims the copy.
> **GATE M0** — robocopy reports 0 failures. `Plug-in Support\Databases\`,
> `Metadata\` and `Media\` are all present on the NAS. **Do not proceed on a
> copy you have not listed.**
---
## M1 — Dry run, before anything is destroyed
The whole migration can be rehearsed while Windows still works. This is the
step that makes the rest safe.
On arrsstack (or any Linux host with the NAS mounted), take **only** the
database and Preferences — a couple of GB, not 364 — and run the remap:
```bash
sudo ./migrate-db.sh /path/to/copy/com.plexapp.plugins.library.db
```
Then start a throwaway Plex against it **with no internet access**, so it
cannot contact plex.tv and collide with the live server that shares its
identity:
```bash
docker network create --internal plex-dryrun
docker run --rm -d --name plex-dryrun --network plex-dryrun \
-v /path/to/dryrun-config:/config \
-v "/mnt/nas/media:/mnt/nas/media:ro" \
lscr.io/linuxserver/plex:latest
```
> **GATE M1** — browse the unclaimed server directly and confirm: Movies shows
> **1,360**, TV Shows shows **23,493 episodes**, watch state and resume points
> are present, and a title's file path resolves. If this passes, the migration
> is proven. If it fails you have lost an afternoon and nothing else.
---
## M2 — Install Linux
Follow the main `runbook.md` phases 2–5 (BIOS, USB, install, first boot). The
autoinstall targets Disk 0 by serial `1808AE802176` and leaves the 3 TB disk
untouched.
Write the NAS credentials and bring up the six mounts:
```bash
printf 'username=mediabox\npassword=<pass>\ndomain=WORKGROUP\n' \
| sudo tee /etc/cifs/korval.cred >/dev/null
sudo chmod 600 /etc/cifs/korval.cred
sudo systemctl daemon-reload
sudo /srv/mediabox-bootstrap/scripts/20-cifs.sh --verify
```
> **GATE M2** — all six mounts report `[ok]`, and `stat -c '%u:%g' /mnt/nas/media`
> returns `3000:3000`. Wrong ownership means Plex hits permission errors on
> every file.
---
## M3 — Restore and remap
```bash
sudo install -d -o 3000 -g 3000 /srv/plex/config
sudo rsync -a --info=progress2 \
"/mnt/nas/Share/backups/mediabox/PMS/" \
"/srv/plex/config/Library/Application Support/Plex Media Server/"
sudo install -o 3000 -g 3000 -m 0644 /path/to/Preferences.xml \
"/srv/plex/config/Library/Application Support/Plex Media Server/Preferences.xml"
sudo /srv/mediabox-bootstrap/migration/migrate-db.sh \
"/srv/plex/config/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db"
sudo chown -R 3000:3000 /srv/plex/config
```
`migrate-db.sh` keeps a `.pre-remap` rollback copy, asserts that no Windows
paths remain, that the counts moved 1:1, that the "Dr. Korval" tag and the four
Liaden book summaries are **untouched**, and finally stats 200 random remapped
paths against the real filesystem.
> **GATE M3** — `migrate-db.sh` exits 0 with the sample check reporting
> `200/200 sampled files resolve on disk`. That last line is the one that
> matters; SQL running without error proves nothing.
---
## M4 — First start
```bash
sudo RENDER_GID=$(stat -c '%g' /dev/dri/renderD128) \
/srv/mediabox-bootstrap/scripts/50-plex.sh
```
No claim token. The server already has its identity.
> **GATE M4** — `plex.thewichersfamily.com` loads and shows *Magi Plex* with
> your libraries. Watch history and Continue Watching are intact. A remote
> client that already had this server still sees it without re-adding.
> Play something and confirm `intel_gpu_top` shows the video engine working.
---
## M5 — Soak
One to two weeks on the supported layout. Confirm mounts survive a NAS reboot,
the GPU survives a kernel update, and the box comes back **unattended** from a
real power cut. That last one is the acceptance test for "headless".
---
## M6 — Relocate the preview cache (optional, post-soak)
Only after M5. Moves the 337 GB `Media/` directory to the 3 TB ext4 disk so
future thumbnail growth (~28 MB per content-hour) stops consuming the SSD.
```bash
sudo /srv/mediabox-bootstrap/scripts/60-media-relocate.sh
```
Plex does **not** support relocating this directory. The script performs the
move, enables the bind mount, and then forces thumbnail generation on a single
title to prove writes succeed across the filesystem boundary — the one failure
mode that cannot be reasoned about, only tested. Rollback is moving it back.
---
## If something goes wrong
Before M2, everything is reversible — Windows still boots. After M2 the
recovery path is *reinstall*, not *roll back*: the USB rebuilds the box in
about twenty minutes and the data directory is still on the NAS. The database
copy on the NAS outlives every step here, and `migrate-db.sh` leaves a
`.pre-remap` copy beside every database it touches.
+110
View File
@@ -0,0 +1,110 @@
<#
================================================================================
gen-preferences.ps1 — build a Linux Preferences.xml from the Windows registry
Run ON THE WINDOWS BOX, before it is wiped. Read-only: it does not modify the
registry or the running server.
powershell -ExecutionPolicy Bypass -File .\gen-preferences.ps1 -Out C:\mbx\Preferences.xml
WHY THIS EXISTS
On Windows, Plex's live settings store is the REGISTRY
(HKCU\SOFTWARE\Plex, Inc.\Plex Media Server), not Preferences.xml. The two
disagree on this install — the on-disk Preferences.xml was last written in
2021 and is missing settings the registry has. Linux has no registry, so
everything must be folded into a single Preferences.xml. This is the
"mapping the additional server settings" that Plex's own migration article
calls complicated and declines to cover.
It also rebuilds the file cleanly, which is required anyway: the existing
Preferences.xml is malformed (duplicate allowedNetworks attribute) and will
not parse with a strict XML parser.
WHAT IS PRESERVED
MachineIdentifier, ProcessedMachineIdentifier, CertificateUUID and the
online token carry across. Those are what keep your server IDENTITY — shared
users stay invited, clients do not need to re-add the server.
Secrets are written to the output file but never printed to the console.
================================================================================
#>
[CmdletBinding()]
param(
[string]$Out = "$PSScriptRoot\Preferences.xml",
[string]$TranscodeDir = "/transcode"
)
$ErrorActionPreference = 'Stop'
$key = 'HKCU:\SOFTWARE\Plex, Inc.\Plex Media Server'
if (-not (Test-Path $key)) { throw "registry key not found: $key" }
# --- keys that are meaningless or harmful on Linux ---------------------------
$drop = @(
'InstallFolder' # C:\Program Files\... — Windows path
'LocalAppDataPath' # D:\Plex\Local Data — Windows path
'PreferredNetworkInterface' # a Windows adapter GUID
'LastAutomaticMappedPort' # regenerated on first run
'PubSubServer' # regenerated; the Ping value is a byte array
'PubSubServerPing'
'PubSubServerRegion'
'CloudSyncNeedsUpdate'
)
# Per-GPU transcode limits are keyed by PCI vendor:device. 10de:1b81 is the
# GTX 1070. Meaningless once the GPU is addressed through /dev/dri or the
# NVIDIA container runtime.
$dropPattern = '^_[0-9a-f]{16}-'
# --- keys we deliberately override -------------------------------------------
$override = @{
# Step 1 of Plex's own migration procedure, and doubly important here: the
# NAS account is read-write, so a missing mount at scan time plus this
# setting means Plex deletes library records it has permission to delete.
'autoEmptyTrash' = '0'
# Windows path -> the tmpfs mount defined in the compose file.
'TranscoderTempDirectory' = $TranscodeDir
}
$props = Get-ItemProperty $key
$pairs = [ordered]@{}
foreach ($p in ($props.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Sort-Object Name)) {
$n = $p.Name
if ($drop -contains $n) { continue }
if ($n -match $dropPattern) { continue }
$v = $p.Value
if ($v -is [byte[]]) { continue } # not representable as an attribute
$pairs[$n] = [string]$v
}
foreach ($k in $override.Keys) { $pairs[$k] = $override[$k] }
# --- emit ---------------------------------------------------------------------
$sb = New-Object System.Text.StringBuilder
[void]$sb.AppendLine('<?xml version="1.0" encoding="utf-8"?>')
[void]$sb.Append('<Preferences')
foreach ($k in $pairs.Keys) {
$esc = [System.Security.SecurityElement]::Escape($pairs[$k])
[void]$sb.Append(" $k=`"$esc`"")
}
[void]$sb.AppendLine('/>')
# UTF-8 WITHOUT BOM. A BOM here is the same class of bug that silently breaks
# administrators_authorized_keys on Windows OpenSSH.
[System.IO.File]::WriteAllText($Out, $sb.ToString(), (New-Object System.Text.UTF8Encoding($false)))
# --- report: NAMES ONLY, never values ----------------------------------------
$secretish = '(Token|Identifier|UUID|GracenoteUser|Mail)'
Write-Host "wrote $Out ($($pairs.Count) settings)"
Write-Host ""
Write-Host "carried across:"
$pairs.Keys | Where-Object { $_ -match $secretish } | ForEach-Object { Write-Host " $_ <redacted>" }
Write-Host ""
Write-Host "overridden:"
$override.Keys | ForEach-Object { Write-Host (" {0} = {1}" -f $_, $override[$_]) }
Write-Host ""
Write-Host "dropped (Windows-only):"
$drop | ForEach-Object { Write-Host " $_" }
Write-Host " <per-GPU transcode limit keys>"
Write-Host ""
Write-Host "Verify it parses before you rely on it:"
Write-Host " [xml](Get-Content -Raw '$Out') | Out-Null; 'XML OK'"
+162
View File
@@ -0,0 +1,162 @@
#!/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
+128
View File
@@ -0,0 +1,128 @@
-- ============================================================================
-- mediabox — Plex library path remap: Windows UNC -> Linux mount paths
--
-- RUN WITH "Plex SQLite", NOT stock sqlite3.
-- Plex ships its own SQLite build with specific compile options.
-- Windows: "C:\Program Files\Plex\Plex Media Server\Plex SQLite.exe"
-- Linux: /usr/lib/plexmediaserver/Plex\ SQLite
-- Stock sqlite3 may refuse to open it, or worse, open it and corrupt it.
--
-- RUN ON A COPY. Never against a database a server has open. Use either a
-- copy taken from a cleanly STOPPED server, or one of Plex's own dated
-- backups (com.plexapp.plugins.library.db-YYYY-MM-DD), which are already
-- consistent snapshots.
--
-- ---------------------------------------------------------------------------
-- VERIFIED AGAINST THE LIVE LIBRARY 2026-07-27
--
-- Every text column in all 82 tables was scanned for the string 'korval'.
-- Six columns matched. Only FOUR of them are paths:
--
-- media_parts.file 80,126 rows backslash / UNC form
-- section_locations.root_path 11 rows backslash / UNC form
-- media_streams.url 14,837 rows file:// form, %20-encoded
-- metadata_items.guid 9 rows file:// form, %20-encoded
--
-- TWO COLUMNS MATCH THE WORD BUT ARE NOT PATHS AND MUST NEVER BE TOUCHED:
--
-- taggings.text 1 row — "Dr. Korval", a credited person
-- metadata_items.summary 4 rows — Liaden Universe book blurbs that
-- mention "Clan Korval"
--
-- This is why every statement below is anchored to a PATH PREFIX
-- ('\\korval\' or 'file://korval/') and never to the bare word. A naive
-- REPLACE(col,'korval',...) across matching columns would silently corrupt
-- four book summaries and a cast credit.
-- ---------------------------------------------------------------------------
--
-- MOUNT CONTRACT — this SQL assumes each NAS share is mounted at
-- /mnt/nas/<exact share name>, capitalisation and spaces preserved:
--
-- //10.0.1.254/media -> /mnt/nas/media
-- //10.0.1.254/Radio Shows -> /mnt/nas/Radio Shows
-- //10.0.1.254/Education Videos -> /mnt/nas/Education Videos
-- //10.0.1.254/Health -> /mnt/nas/Health
-- //10.0.1.254/Home Movies -> /mnt/nas/Home Movies
-- //10.0.1.254/Pictures -> /mnt/nas/Pictures
--
-- Keeping the share names verbatim is what reduces this to one uniform
-- prefix substitution per format instead of eleven special cases. The same
-- paths must be bind-mounted into the container at the same location, so the
-- database and the container agree with no further translation.
-- ============================================================================
PRAGMA foreign_keys = OFF;
BEGIN TRANSACTION;
-- ---------------------------------------------------------------------------
-- 1. Drop the two vestigial library roots.
--
-- '\\korval\Audio Books' and '\\korval\Music Organized' are configured as
-- library roots but contain ZERO files — all audio actually lives under
-- \\korval\media\audiobooks and \\korval\media\music. Verified 2026-07-27:
-- 0 files / 0 bytes under both.
--
-- The NOT EXISTS guard makes this a no-op if that ever stops being true, so
-- this statement can never orphan a media_parts row.
-- ---------------------------------------------------------------------------
DELETE FROM section_locations
WHERE root_path IN ('\\korval\Audio Books', '\\korval\Music Organized')
AND NOT EXISTS (
SELECT 1
FROM media_parts mp
WHERE mp.file LIKE section_locations.root_path || '\%'
);
-- ---------------------------------------------------------------------------
-- 2. Backslash / UNC form.
--
-- \\korval\media\movies\Film (2019)\Film.mkv
-- -> /mnt/nas/media/movies/Film (2019)/Film.mkv
--
-- Order matters: swap the '\\korval\' prefix FIRST, then convert the
-- remaining separators. Doing it the other way round destroys the prefix
-- before it can be matched.
--
-- Spaces stay literal in this form — fstab needs \040 escaping, the database
-- does not.
-- ---------------------------------------------------------------------------
UPDATE section_locations
SET root_path = REPLACE(REPLACE(root_path, '\\korval\', '/mnt/nas/'), '\', '/')
WHERE root_path LIKE '\\korval\%';
UPDATE media_parts
SET file = REPLACE(REPLACE(file, '\\korval\', '/mnt/nas/'), '\', '/')
WHERE file LIKE '\\korval\%';
-- ---------------------------------------------------------------------------
-- 3. file:// URL form.
--
-- file://korval/media/tvshows/Person%20of%20Interest/...
-- -> file:///mnt/nas/media/tvshows/Person%20of%20Interest/...
--
-- Note the THIRD slash: 'file://korval/' is a host-qualified URL; a local
-- path is 'file:///'. Separators are already forward slashes here, so no
-- second REPLACE.
--
-- %20 IS LEFT ALONE ON PURPOSE. Plex URL-decodes these, so decoding them
-- here would produce paths with literal '%20' in them and break every
-- subtitle and stream reference that has a space in its name — which, given
-- share names like "Radio Shows" and "Home Movies", is most of them.
-- ---------------------------------------------------------------------------
UPDATE media_streams
SET url = REPLACE(url, 'file://korval/', 'file:///mnt/nas/')
WHERE url LIKE 'file://korval/%';
UPDATE metadata_items
SET guid = REPLACE(guid, 'file://korval/', 'file:///mnt/nas/')
WHERE guid LIKE 'file://korval/%';
COMMIT;
PRAGMA foreign_keys = ON;
-- Reclaim the space freed by the rewrite and leave the file tidy for its
-- first open by the new server. Safe, but slow on a 513 MB database — expect
-- a minute or two.
VACUUM;
+79 -35
View File
@@ -1,58 +1,102 @@
services: services:
plex: plex:
# Pin the tag. The migrated database is from PMS 1.43.3.10828 and SQLite
# schema upgrades are ONE-WAY — the server here must be equal or newer, and
# you do not want a surprise release performing an unexpected schema
# migration on build night. Set this to a concrete tag before first start.
image: lscr.io/linuxserver/plex:latest image: lscr.io/linuxserver/plex:latest
container_name: plex container_name: plex
restart: unless-stopped restart: unless-stopped
# NOT OPTIONAL. Plex's local discovery (GDM), DLNA and the Plex-for-client # NOT OPTIONAL. Plex's local discovery (GDM) and client auto-detection rely
# "found a local server" path all rely on broadcast traffic that a bridge # on broadcast traffic that a bridge network silently eats. (DLNA is off on
# network silently eats. This is why Plex does not sit behind NPM the way # this server, so discovery — not DLNA — is the reason.) This is also why
# every other service here does — NPM proxy host 17 already points # Plex does not sit behind NPM like everything else here: proxy host 17
# plex.thewichersfamily.com at 10.0.1.20:32400 and needs no change. # already points plex.thewichersfamily.com at 10.0.1.20:32400 and needs no
# change, because the IP does not move.
network_mode: host network_mode: host
environment: environment:
- PUID=3000 # MUST match uid= on the CIFS mounts - PUID=3000 # MUST equal uid= on the CIFS mounts
- PGID=3000 # MUST match gid= on the CIFS mounts - PGID=3000 # MUST equal gid= on the CIFS mounts
- TZ=${TZ:-America/New_York} - TZ=${TZ:-America/New_York}
- VERSION=docker - VERSION=docker
# PLEX_CLAIM is intentionally absent from this file. Claim tokens expire # No PLEX_CLAIM. The migration carries MachineIdentifier,
# four minutes after they are issued, so one can never be committed to a # ProcessedMachineIdentifier and the online token across in
# repo, written to a USB, or baked into an image. It is injected at first # Preferences.xml, so this server IS the existing server — already
# start by 50-plex.sh and never persisted. # claimed, shared users intact, clients not needing to re-add it.
- PLEX_CLAIM=${PLEX_CLAIM:-} # Claiming would create a NEW server identity and throw that away.
- ADVERTISE_IP=${PLEX_ADVERTISE_URL:-http://10.0.1.20:32400}
devices: devices:
# Quick Sync. The i7-8700K's UHD 630 is the transcode target: no session # Quick Sync on the UHD 630. No session cap and better HDR tone mapping
# cap, and better HDR tone mapping than the Pascal-era NVENC on the 1070. # than the Pascal-era NVENC on the 1070.
- /dev/dri:/dev/dri - /dev/dri:/dev/dri
group_add: group_add:
# The render node is root:render, and PGID 3000 is not in that group. # Render node is root:render and PGID 3000 is not in that group.
# Resolved from the live host by 50-plex.sh — do not hardcode, the gid # Resolved from the live host by 50-plex.sh — the gid differs across
# differs between distro releases. # distro releases, so do not hardcode it.
- "${RENDER_GID:-993}" - "${RENDER_GID:-993}"
volumes: volumes:
# Config lives on the SSD, deliberately. The Plex SQLite database is the # ---- Plex data directory -------------------------------------------
# most latency-sensitive thing on this box; it does not belong on the # Whole directory on the SSD: database, Metadata/ (25 GB of posters) and
# 3TB spinner. # Media/ (337 GB of migrated preview cache). ~364 GB in ~440 GB usable.
- /srv/plex/config:/config # Fully supported layout — no split, no bind-mount trickery.
- type: bind
source: /srv/plex/config
target: /config
# NAS media, read-write per the account scope. Mounted read-only INTO the # ---- PHASE 2 (post-soak, optional) ----------------------------------
# container for every library that Plex has no business writing to. # Relocate the 337 GB preview cache to the 3 TB ext4 disk so future
- /mnt/nas/audiobooks:/media/audiobooks:ro # thumbnail growth (~28 MB per content-hour) stops consuming the SSD.
- /mnt/nas/education-videos:/media/education-videos:ro # Long-form syntax deliberately: the target path contains spaces, which
- /mnt/nas/health:/media/health:ro # the short "a:b" form handles badly. Docker orders mounts by target
- /mnt/nas/home-movies:/media/home-movies:ro # depth, so this correctly lands inside the /config mount above.
- /mnt/nas/media:/media/media:ro #
- /mnt/nas/music-organized:/media/music-organized:ro # Plex does NOT support relocating this directory. Enable it only after
- /mnt/nas/pictures:/media/pictures:ro # running scripts/60-media-relocate.sh, which performs the move and then
- /mnt/nas/radio-shows:/media/radio-shows:ro # forces thumbnail generation on a single title to prove that writes
# succeed across the filesystem boundary. Rollback is moving it back.
#
# - type: bind
# source: /srv/data/plex-media
# target: /config/Library/Application Support/Plex Media Server/Media
# ---- NAS media, mounted at the SAME paths the database stores -------
# The remap rewrites \\korval\X -> /mnt/nas/X, so the container must see
# exactly /mnt/nas/X. Six separate entries rather than one bind of
# /mnt/nas, because a plain bind does not carry the submounts beneath it.
# Read-only: Plex has no reason to write to the library, and the NAS
# account is read-write.
- type: bind
source: /mnt/nas/media
target: /mnt/nas/media
read_only: true
- type: bind
source: /mnt/nas/Radio Shows
target: /mnt/nas/Radio Shows
read_only: true
- type: bind
source: /mnt/nas/Education Videos
target: /mnt/nas/Education Videos
read_only: true
- type: bind
source: /mnt/nas/Health
target: /mnt/nas/Health
read_only: true
- type: bind
source: /mnt/nas/Home Movies
target: /mnt/nas/Home Movies
read_only: true
- type: bind
source: /mnt/nas/Pictures
target: /mnt/nas/Pictures
read_only: true
tmpfs: tmpfs:
# Transcodes are throwaway. Keeping them in RAM saves a great deal of # Matches TranscoderTempDirectory=/transcode in the generated
# SSD write wear. 4G of the box's 16G, capped so a pathological transcode # Preferences.xml. Transcodes are throwaway; keeping them in RAM saves
# cannot pressure the rest of the system. # a great deal of SSD write wear. 4G of 16G, capped so a pathological
# transcode cannot pressure the rest of the system.
- /transcode:rw,size=4g,mode=1777 - /transcode:rw,size=4g,mode=1777
+67 -53
View File
@@ -1,38 +1,56 @@
#!/usr/bin/env bash #!/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): # SIX shares. Confirmed live from the Windows box and cross-checked against
# Audio Books · Education Videos · Health · Home Movies · media # the Plex database 2026-07-27:
# Music Organized · Pictures · Radio Shows (+ "Share" — NOT mounted)
# #
# "Share" is mounted on Windows today but is not a Plex library root, so it is # media 1,360 movies + 23,493 episodes + all audio (~26.5 TB)
# deliberately left out. Every entry below maps to a real library section. # 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: # NOT mounted, deliberately:
# Audio Books -> /mnt/nas/audiobooks # Share not a Plex library root
# Music Organized -> /mnt/nas/music-organized # Audio Books a library root, but EMPTY — 0 files, 0 bytes.
# Radio Shows -> /mnt/nas/radio-shows # Music Organized a library root, but EMPTY — 0 files, 0 bytes.
# Pictures -> /mnt/nas/pictures/Plex Pictures # All audio actually lives under media/audiobooks and media/music. The two
# Health -> /mnt/nas/health # empty roots are vestigial and are deleted by migration/remap.sql.
# Home Movies -> /mnt/nas/home-movies #
# Education Videos -> /mnt/nas/education-videos # ---------------------------------------------------------------------------
# media -> /mnt/nas/media/{movies,tvshows,music,audiobooks} # 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: # WHY EACH OPTION IS THERE — none of these are decoration:
# nofail an unreachable NAS must NOT drop a headless # nofail an unreachable NAS must NOT drop a headless
# box into an emergency shell. There is no # box to an emergency shell. There is no
# keyboard attached to type the root password. # keyboard attached to type a root password.
# _netdev tells systemd this needs the network up. # _netdev tells systemd this needs the network up.
# x-systemd.automount mount on first access rather than at boot, so # x-systemd.automount mount on first access, so a slow NAS never
# a slow NAS never stretches boot time. # stretches boot.
# x-systemd.mount-timeout=30 bounded failure instead of an indefinite hang. # x-systemd.mount-timeout=30 bounded failure instead of an endless hang.
# x-systemd.idle-timeout=600 unmount when idle; keeps stale handles rare. # vers=3.1.1 dialect negotiated by the Windows box. If a
# vers=3.1.1 dialect confirmed from the Windows box. # mount fails on build night, try vers=3.0 —
# uid/gid=3000 MUST match Plex's PUID/PGID or every file is # arrsstack talks to this same NAS at 3.0.
# uid/gid=3000 MUST equal Plex's PUID/PGID or every file is
# permission-denied. # permission-denied.
# \040 fstab field separator is whitespace; share # \040 fstab splits fields on whitespace, so spaces
# names with spaces MUST escape them. # must be escaped in BOTH the share name and
# the mount point.
# ============================================================================= # =============================================================================
set -uo pipefail 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" 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=( SHARES=(
"Audio Books|/mnt/nas/audiobooks" "media"
"Education Videos|/mnt/nas/education-videos" "Radio Shows"
"Health|/mnt/nas/health" "Education Videos"
"Home Movies|/mnt/nas/home-movies" "Health"
"media|/mnt/nas/media" "Home Movies"
"Music Organized|/mnt/nas/music-organized" "Pictures"
"Pictures|/mnt/nas/pictures"
"Radio Shows|/mnt/nas/radio-shows"
) )
mp_for() { printf '/mnt/nas/%s' "$1"; }
# --- --verify mode: check mounts, change nothing ---------------------------- # --- --verify mode: check mounts, change nothing ----------------------------
if [ "${1:-}" = "--verify" ]; then if [ "${1:-}" = "--verify" ]; then
echo " verifying NAS mounts" echo " verifying NAS mounts"
@@ -65,12 +83,12 @@ if [ "${1:-}" = "--verify" ]; then
echo " [FAIL] $CRED is empty — write the NAS credentials first" echo " [FAIL] $CRED is empty — write the NAS credentials first"
exit 1 exit 1
fi fi
for entry in "${SHARES[@]}"; do for s in "${SHARES[@]}"; do
mp="${entry#*|}" mp="$(mp_for "$s")"
if ls "$mp" >/dev/null 2>&1 && mountpoint -q "$mp"; then 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 else
printf ' [FAIL] %-28s not mounted\n' "$(basename "$mp")" printf ' [FAIL] %-20s not mounted\n' "$s"
rc=1 rc=1
fi fi
done done
@@ -83,12 +101,11 @@ install -d -m 0700 /etc/cifs
[ -f "$CRED" ] || install -m 0600 /dev/null "$CRED" [ -f "$CRED" ] || install -m 0600 /dev/null "$CRED"
chmod 600 "$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 if [ ! -s "$CRED" ]; then
cat <<'CREDNOTE' cat <<'CREDNOTE'
NOTE: /etc/cifs/korval.cred is empty. That is expected at this stage. NOTE: /etc/cifs/korval.cred is empty. That is expected at this stage — the
The mounts will fail (harmlessly, thanks to nofail) until you write: 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' \ printf 'username=<user>\npassword=<pass>\ndomain=WORKGROUP\n' \
> /etc/cifs/korval.cred > /etc/cifs/korval.cred
chmod 600 /etc/cifs/korval.cred chmod 600 /etc/cifs/korval.cred
@@ -96,8 +113,8 @@ if [ ! -s "$CRED" ]; then
CREDNOTE CREDNOTE
fi fi
for entry in "${SHARES[@]}"; do for s in "${SHARES[@]}"; do
mp="${entry#*|}" mp="$(mp_for "$s")"
install -d -m 0755 "$mp" install -d -m 0755 "$mp"
chown ${MEDIA_UID}:${MEDIA_GID} "$mp" chown ${MEDIA_UID}:${MEDIA_GID} "$mp"
done done
@@ -112,12 +129,9 @@ awk -v b="$MARK_BEGIN" -v e="$MARK_END" '
{ {
printf '%s\n' "$MARK_BEGIN" printf '%s\n' "$MARK_BEGIN"
for entry in "${SHARES[@]}"; do for s in "${SHARES[@]}"; do
share="${entry%%|*}" mp="$(mp_for "$s")"
mp="${entry#*|}" esc_share="${s// /\\040}"
# 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}"
esc_mp="${mp// /\\040}" esc_mp="${mp// /\\040}"
printf '//%s/%s %s cifs %s 0 0\n' "$NAS" "$esc_share" "$esc_mp" "$OPTS" printf '//%s/%s %s cifs %s 0 0\n' "$NAS" "$esc_share" "$esc_mp" "$OPTS"
done done
@@ -137,12 +151,12 @@ rm -f "$tmp"
systemctl daemon-reload systemctl daemon-reload
echo " fstab updated (backup written alongside). Managed block:" 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 if [ -s "$CRED" ]; then
echo " credentials present — attempting mounts" echo " credentials present — attempting mounts"
for entry in "${SHARES[@]}"; do for s in "${SHARES[@]}"; do
mp="${entry#*|}" mp="$(mp_for "$s")"
if timeout 40 mount "$mp" 2>/dev/null || mountpoint -q "$mp"; then if timeout 40 mount "$mp" 2>/dev/null || mountpoint -q "$mp"; then
printf ' [ok] %s\n' "$mp" printf ' [ok] %s\n' "$mp"
else else
+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