The live sweep removed 2271 husks and left 146 standing, every one a release folder blocked by a junk subfolder (Screens/, Sample/, a BDMV leaf) that no file ever moved out of. Eligibility was per-folder, so the blocker was never a target and its parent could never be removed. Proof now carries downward: inside a folder the journal shows emptied, every empty directory qualifies. Nothing under such a folder can hold data, and every removal is still journalled as rmdir and undone by rollback. The new test is red against the previous revision, green now. The live sweep touched no file: 8193 before, 8193 after.
10 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | |||||
|---|---|---|---|---|---|---|---|---|---|
| reference | Media Relayout | Census, plan and apply the movie library into Jellyfin format: the gates to read, and the rollback. |
|
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
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 niced — it is safe beside a live service
because it never competes for the GPU and never writes inside the library.
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:
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.
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.
# 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/<pid>/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/px.tok
Jellyfin has no credential on this box that can trigger a scan: the two keys
in its database are Seerr's, unused since 2026-09-01, and answer 401 to every
call. Mint one (Dashboard → Advanced → API Keys, Administrator) and store it in
~/.config/secrets/jellyfin, or press Library → Scan All Libraries in the web
UI. A scripted scan passes the key in a mode-600 curl config, never in a header
argument:
umask 077; printf 'header = "X-Emby-Token: %s"\n' "$(cat ~/.config/secrets/jellyfin)" >/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
- A moved file is a 404 in Plex until its section is rescanned. The
database keeps the release-folder path, so playback fails
Couldn't find the file to streamwhile the scanner works through the section. Never read such a 404 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
0means indexed but never matched — trusting it named foldersTitle (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 isV/H/S/2; kept verbatim it becameMovies/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 Lookis a typo forExtras; 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)/extrasthat 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, so1080and2160never pose as agreement between two names. - A file whose name carries no words is bonus material, not a folder.
.Extra 1 ( 1H 27min multisub ).mkvparses to1; a movie folder named1is 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.jsonlnext 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.
--forcerelaxes 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
rmdirandrollbackrecreates the folder, so the sweep is undone by the same command as the moves. Proof carries downward: inside a folder the journal proves emptied, every empty directory goes with it. A release'sScreens/orSample/is never a move source itself, so 146 of them held their emptied parents on disk after the first sweep. - An episode-shaped name marks a SERIES, not a film — and the series is the
name BEFORE the marker.
Monk - S05E03 - Mr. Monk and the Big Gameis seriesMonk. Keyed on the whole title instead, every episode became a group of one and never reached the five-episode size that calls something a series, so a box set was filed as 124 movies in 124 folders. The threshold still earns its keep: a lone film namedPart 2stays a film. Episodes of a series are parked in_TV needs a TV library - <series>for a human to move to a TV source — the movie tool never files TV.
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.mkvversion and- 4K.mkvhandling: https://jellyfin.org/docs/general/server/media/movies