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
+79 -35
View File
@@ -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