--- type: reference title: Media Relayout description: "Census, plan and apply the movie library into Jellyfin format: the gates to read, and the rollback." tags: [dankserver, media-server, jellyfin, plex, procedure] timestamp: 2026-10-30T00:00:00Z --- # Media Relayout The movie library on [[Entropy Volume]] arrived as release folders — `Some.Movie.2019.1080p.WiKiTaLx.x265-FOO/` with the video inside named after the release. [[Jellyfin]] matches a movie by folder and file name, so the release noise cost posters, extras and versions. These three scripts turn that library into Jellyfin's own shape, and nothing else in the repo moves a media file. | Script | Touches media? | Output | |---|---|---| | `scripts/media-census.py` | reads headers only | `enum`, `probed`, `plex` TSVs | | `scripts/media-plan.py` | no — TSVs in, TSVs out | the plan + the exception report | | `scripts/media-apply.py` | **renames, never copies**; sweeps only what a run emptied | the moves, plus a journal | The seam is `tests/media_relayout.bats`; the target contract is quoted from [jellyfin.org/docs/general/server/media/movies](https://jellyfin.org/docs/general/server/media/movies) in `media-plan.py`'s docstring. ## Procedure Run the census on the machine that mounts the library. `probe` is the slow step (a header read per container) and is `nice`d — it is safe beside a live service because it never competes for the GPU and never writes inside the library. ```bash C=scripts/media-census.py $C enumerate --root /mnt/entropy/Movies --out /tmp/media-enum.tsv $C probe --enum /tmp/media-enum.tsv --out /tmp/media-probed.tsv --jobs 6 $C plex --root /mnt/entropy/Movies --out /tmp/plex-movies.tsv ``` `plex` reads the Plex metadata cache (`--db` defaults to the path the [[Plex]] container bind-mounts at `/var/lib/plexmediaserver`) with `mode=ro&immutable=1`: a live server is never locked or written. Then plan — this reads only the three TSVs, so it costs seconds and can run anywhere, including a laptop: ```bash scripts/media-plan.py --root /mnt/entropy/Movies \ --enum /tmp/media-enum.tsv --probed /tmp/media-probed.tsv \ --plex /tmp/plex-movies.tsv --out /tmp/jf-plan.tsv --exceptions /tmp/jf-exceptions.txt ``` **Read `/tmp/jf-exceptions.txt` before applying.** It is not a log: every line is a file the plan could not name a film for, a pile that needs a README, or a collision it refused. The printed gate lines are the go/no-go: `problems`, `bad-names`, `occupied`, `double-claim`, `left-unmoved` must all be 0 and `unplanned=0` must appear in the report. Any non-zero number means a file that would be filed wrong, and the plan is not run until it is understood. ```bash scripts/media-apply.py apply --plan /tmp/jf-plan.tsv --root /mnt/entropy/Movies # dry, writes nothing scripts/media-apply.py apply --plan /tmp/jf-plan.tsv --root /mnt/entropy/Movies --apply scripts/media-apply.py verify --plan /tmp/jf-plan.tsv --journal /mnt/entropy/media-relayout.jsonl scripts/media-apply.py sweep --journal /mnt/entropy/media-relayout.jsonl --root /mnt/entropy/Movies # dry scripts/media-apply.py sweep --journal /mnt/entropy/media-relayout.jsonl --root /mnt/entropy/Movies --apply scripts/media-apply.py rollback --journal /mnt/entropy/media-relayout.jsonl --root /mnt/entropy/Movies --apply ``` An apply also leaves the release folders it emptied standing where they were — the live run left 1675 of them at the library root — which is what `sweep` removes, dry-run first, from the same journal. After it lands, both servers need a scan or the change is invisible. ```bash # Run this from the host shell through `podman exec`, never from a neighbour # container: Plex's HTTPD closes without a single byte on any request that # arrives from another container's netns (10.89.x.x reads as "empty reply", not # as a 401). The token rides stdin, never argv, where any process on the host # could read it out of /proc//cmdline. umask 077 podman exec plex sh -c "sed -n 's/.*PlexOnlineToken=\"\\([^\"]*\\)\".*/\\1/p' \ '/config/Library/Application Support/Plex Media Server/Preferences.xml'" >/tmp/px.tok podman exec -i plex sh -c 'read -r T for k in $(curl -s -H "X-Plex-Token: $T" -H "Accept: application/json" \ http://127.0.0.1:32400/library/sections | grep -o "\"key\":\"[0-9]*\"" | cut -d\" -f4); do curl -s -o /dev/null -w "plex section $k: %{http_code}\n" -X POST \ -H "X-Plex-Token: $T" "http://127.0.0.1:32400/library/sections/$k/refresh" done' /tmp/jf.hdr curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n" \ -X POST http://127.0.0.1:8096/Library/Refresh ``` ## Laws - **The journal lives beside the library, never in `/tmp`.** The live run's `/mnt/entropy/media-relayout.jsonl` is the only rollback instrument; a journal a reboot clears is not an instrument. - **An apply breaks [[Plex]] playback for every moved item until its section is rescanned.** The database keeps the release-folder path, so streams fail `Couldn't find the file to stream` while the scanner works through the section. Trigger the scans in the same window as the apply, and never read a 404 in that window as a lost file — the file is where the journal says it is. - **Plex's matched title outranks the filename**, and a Plex year of `0` means *indexed but never matched* — trusting it named folders `Title (0)`, which is a Jellyfin item with no year, no poster and no identity. A year that is not a plausible year is no information. - **`<>:"/\|?*` never survive a name.** Plex's own title for *V/H/S/2* is `V/H/S/2`; kept verbatim it became `Movies/V/H/S/2 (2013)/` — a tree five levels deep that neither matcher reads as a movie. - **A folder inside a movie's folder is bonus territory even when its name is misspelled.** `Saboteur (1942)/Exrtas/Saboteur - A Closer Look` is a typo for `Extras`; the vocabulary rule never fired, the featurette became a movie folder, and its words then out-scored the real film and drew that box set's other extras into it. The rule that replaced it needs no metadata: the folder holding the longest film owns the shorter video filed under it, unless that file's own name carries a different year — which is another film speaking. - **Matching words are split on everything that is not a letter or a digit.** Splitting on separators but not on `/` left tokens like `(1960)/extras` that no other name could ever agree with, so a film could not be found by the folder holding it, and extras drifted into a neighbour. A bare number counts only when it could be a release year, so `1080` and `2160` never pose as agreement between two names. - **A file whose name carries no words is bonus material, not a folder.** `.Extra 1 ( 1H 27min multisub ).mkv` parses to `1`; a movie folder named `1` is not a movie. It becomes a review line telling a human to name the file. - **The executor renames and nothing else.** `rename(2)` cannot cross a filesystem, so a plan that would is refused rather than silently copying tens of terabytes. - **The journal is a write-ahead log: the intent is fsynced before the rename.** An interrupted run therefore leaves only two states — an intent whose move happened, and an intent whose move never ran — and the next run finishes the job without moving anything twice. A file that moved with no journal line did not move through this tool, and the tool refuses to guess on its behalf. - **The journal lives BESIDE the library root, never inside it** (default: `media-relayout.jsonl` next to the root): a log inside the library is a file Jellyfin indexes. - **Refusals are all-or-nothing.** An occupied target, a vanished source or a duplicate destination stops the run *before the first rename*, so the operator fixes one thing and re-runs instead of untangling a half-applied plan. `--force` relaxes only the opinion guards; it never permits overwriting. - **A sweep deletes only what the journal proves the run emptied.** A folder is eligible when a journalled file moved out of it or out of something under it, it now holds nothing but empty directories, no journalled destination lives beneath it, and it is not a folder the run created. A folder nobody moved — someone's placeholder — cannot be selected at all, and a symlink anywhere inside disqualifies the subtree, because the thing behind a link is not this tool's to delete. Every removal is journalled as `rmdir` and `rollback` recreates the folder, so the sweep is undone by the same command as the moves. ## Rollback One command, from the journal of the run: `media-apply.py rollback --journal …/media-relayout.jsonl --apply`. It recreates any folder a sweep removed, restores every source path, removes only the folders that run created *and that are now empty*, and refuses everything if any moved file cannot be accounted for. A rolled-back journal refuses to roll back twice. ## See Also [[Jellyfin]], [[Plex]], [[Entropy Volume]], [[Testing Policy]], [[Storage Policy]] ## Citations - Jellyfin Movie documentation — folder/file naming, extras subfolders, `Movie (2013) - 216p.mkv` version and `- 4K.mkv` handling: