Trashing 31 GiB of duplicate Monk to ~/.AGENTTRASH would have been a cross-device copy of the thing being deleted, into 187 GiB of free space, on a volume at 100% -- and rename(2) refuses it regardless. The stage belongs beside the item.
14 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 scripts turn that
library into Jellyfin's own shape — and move TV back out of it — and nothing
else in the repo touches 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 |
scripts/media-tv.py |
no — TSV out | a plan that moves TV into the TV library |
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 —
hundreds 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
TV in the movie library
A box set that arrives in the movie share stays there until someone moves it:
media-plan.py refuses to file TV and parks each series in
_TV needs a TV library - <series>. scripts/media-tv.py is the mover. It
walks the movie library itself (no census needed), groups episode-shaped names
by series, and writes a plan in the executor's own format:
scripts/media-tv.py --root /mnt/entropy/Movies --library /mnt/entropy/TVShows \
--out /mnt/entropy/media-tv-plan.tsv
scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv --root /mnt/entropy
scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv --root /mnt/entropy \
--journal /mnt/entropy/media-relayout-tv.jsonl --apply
scripts/media-apply.py sweep --journal /mnt/entropy/media-relayout-tv.jsonl \
--root /mnt/entropy --apply
The shape it writes is the one both matchers read — Monk (2002)/Season 05/ Monk - S05E03 - Mr. Monk and the Big Game.mkv — keeping the file's own name,
because the publisher's spelling beats a re-derivation, with the year taken
from the box-set folder. What it refuses it prints and leaves alone:
- fewer than
--min(5) files under one series name: one file namedPart 2is a film, not a series; - an episode whose name states no season number — no invented
Season 01; - a destination two files both claim: the executor refuses the whole plan before its first rename, and only Frank decides which copy is the keeper.
- an episode the destination library already holds at the same byte count. The movie share carried a second copy of the whole Monk box set — 124 episodes both sides, the same episode list, every byte count identical, 31.5 GiB each way, and head+tail blake2b identical on a 6-episode sample. The mover leaves such a series in place and says so; a duplicate is deleted or kept by Frank, never copied into one library by a tool.
A sidecar that shares an episode's own name (.srt, .nfo) moves with it.
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. - Compare two media libraries with the series engine, never a path
substring. Filtering paths for
monkalso returns Monkey Dust, and letting whichever file a walk happens to reach first stand for an episode paired two libraries into a fake duplicate — the same sloppiness pointed the other way deletes a file that is unique. The comparison ismedia-titles.ep_ofon both sides: series, season, episode, bytes, and a hash before anything is trashed. - A deletion is staged on the item's own filesystem. The trash rule names
~/.AGENTTRASH, but/homeand/mnt/entropyare different devices: a 31 GiB stage there is a copy rather than a rename, it has to fit in the 187 GiB of free space, andrename(2)refuses it anyway. Trash goes beside the item —/mnt/entropy/.AGENTTRASH/<date>-<topic>/— so it costs one inode change and rolls back exactly like the move that made it. - 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. A failedrmdiris not a removal: only a folder that is really gone leaves scope, so the next sweep finishes what a permission problem stopped. - 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. - One journal is one undo. The TV move runs on its own journal, and with
--rootset to the share that holds BOTH libraries: the executor refuses any path outside--root, and a journal shared with the movie relayout would undo both runs when only one of them is wanted.
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