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>
129 lines
5.7 KiB
SQL
129 lines
5.7 KiB
SQL
-- ============================================================================
|
|
-- 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;
|