diff --git a/migration/README.md b/migration/README.md new file mode 100644 index 0000000..7895941 --- /dev/null +++ b/migration/README.md @@ -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/` | + +--- + +## 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=\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. diff --git a/migration/gen-preferences.ps1 b/migration/gen-preferences.ps1 new file mode 100644 index 0000000..2351178 --- /dev/null +++ b/migration/gen-preferences.ps1 @@ -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('') +[void]$sb.Append('') + +# 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 " $_ " } +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 " " +Write-Host "" +Write-Host "Verify it parses before you rely on it:" +Write-Host " [xml](Get-Content -Raw '$Out') | Out-Null; 'XML OK'" diff --git a/migration/migrate-db.sh b/migration/migrate-db.sh new file mode 100755 index 0000000..3af0e6d --- /dev/null +++ b/migration/migrate-db.sh @@ -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 [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 diff --git a/migration/remap.sql b/migration/remap.sql new file mode 100644 index 0000000..541913c --- /dev/null +++ b/migration/remap.sql @@ -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/, 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; diff --git a/plex/docker-compose.yml b/plex/docker-compose.yml index 43b4fb9..496ced0 100644 --- a/plex/docker-compose.yml +++ b/plex/docker-compose.yml @@ -1,58 +1,102 @@ services: 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 container_name: plex restart: unless-stopped - # NOT OPTIONAL. Plex's local discovery (GDM), DLNA and the Plex-for-client - # "found a local server" path all rely on broadcast traffic that a bridge - # network silently eats. This is why Plex does not sit behind NPM the way - # every other service here does — NPM proxy host 17 already points - # plex.thewichersfamily.com at 10.0.1.20:32400 and needs no change. + # NOT OPTIONAL. Plex's local discovery (GDM) and client auto-detection rely + # on broadcast traffic that a bridge network silently eats. (DLNA is off on + # this server, so discovery — not DLNA — is the reason.) This is also why + # Plex does not sit behind NPM like everything else here: proxy host 17 + # already points plex.thewichersfamily.com at 10.0.1.20:32400 and needs no + # change, because the IP does not move. network_mode: host environment: - - PUID=3000 # MUST match uid= on the CIFS mounts - - PGID=3000 # MUST match gid= on the CIFS mounts + - PUID=3000 # MUST equal uid= on the CIFS mounts + - PGID=3000 # MUST equal gid= on the CIFS mounts - TZ=${TZ:-America/New_York} - VERSION=docker - # PLEX_CLAIM is intentionally absent from this file. Claim tokens expire - # four minutes after they are issued, so one can never be committed to a - # repo, written to a USB, or baked into an image. It is injected at first - # start by 50-plex.sh and never persisted. - - PLEX_CLAIM=${PLEX_CLAIM:-} - - ADVERTISE_IP=${PLEX_ADVERTISE_URL:-http://10.0.1.20:32400} + # No PLEX_CLAIM. The migration carries MachineIdentifier, + # ProcessedMachineIdentifier and the online token across in + # Preferences.xml, so this server IS the existing server — already + # claimed, shared users intact, clients not needing to re-add it. + # Claiming would create a NEW server identity and throw that away. devices: - # Quick Sync. The i7-8700K's UHD 630 is the transcode target: no session - # cap, and better HDR tone mapping than the Pascal-era NVENC on the 1070. + # Quick Sync on the UHD 630. No session cap and better HDR tone mapping + # than the Pascal-era NVENC on the 1070. - /dev/dri:/dev/dri group_add: - # The 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 - # differs between distro releases. + # Render node is root:render and PGID 3000 is not in that group. + # Resolved from the live host by 50-plex.sh — the gid differs across + # distro releases, so do not hardcode it. - "${RENDER_GID:-993}" volumes: - # Config lives on the SSD, deliberately. The Plex SQLite database is the - # most latency-sensitive thing on this box; it does not belong on the - # 3TB spinner. - - /srv/plex/config:/config + # ---- Plex data directory ------------------------------------------- + # Whole directory on the SSD: database, Metadata/ (25 GB of posters) and + # Media/ (337 GB of migrated preview cache). ~364 GB in ~440 GB usable. + # 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 - # container for every library that Plex has no business writing to. - - /mnt/nas/audiobooks:/media/audiobooks:ro - - /mnt/nas/education-videos:/media/education-videos:ro - - /mnt/nas/health:/media/health:ro - - /mnt/nas/home-movies:/media/home-movies:ro - - /mnt/nas/media:/media/media:ro - - /mnt/nas/music-organized:/media/music-organized:ro - - /mnt/nas/pictures:/media/pictures:ro - - /mnt/nas/radio-shows:/media/radio-shows:ro + # ---- PHASE 2 (post-soak, optional) ---------------------------------- + # Relocate the 337 GB preview cache to the 3 TB ext4 disk so future + # thumbnail growth (~28 MB per content-hour) stops consuming the SSD. + # Long-form syntax deliberately: the target path contains spaces, which + # the short "a:b" form handles badly. Docker orders mounts by target + # depth, so this correctly lands inside the /config mount above. + # + # Plex does NOT support relocating this directory. Enable it only after + # running scripts/60-media-relocate.sh, which performs the move and then + # 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: - # Transcodes are throwaway. Keeping them in RAM saves a great deal of - # SSD write wear. 4G of the box's 16G, capped so a pathological transcode - # cannot pressure the rest of the system. + # Matches TranscoderTempDirectory=/transcode in the generated + # Preferences.xml. Transcodes are throwaway; keeping them in RAM saves + # 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 diff --git a/scripts/20-cifs.sh b/scripts/20-cifs.sh index 63e8e8b..6be22ab 100755 --- a/scripts/20-cifs.sh +++ b/scripts/20-cifs.sh @@ -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=\npassword=\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 diff --git a/scripts/60-media-relocate.sh b/scripts/60-media-relocate.sh new file mode 100755 index 0000000..e57a7b9 --- /dev/null +++ b/scripts/60-media-relocate.sh @@ -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. 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