Files
serverAppConfigs/serverAppConfigs-docs/Dankserver/Media Relayout.md
T
Frank 1cb28095a7 feat(media): sweep removes the folders a completed run emptied
An apply leaves every release folder it emptied standing where it was: the
live library run left 1675 husks at the root. sweep takes the same journal
and deletes only folders it can prove this run emptied - a journalled move
out of the folder or something under it, nothing but empty directories left,
no journalled destination beneath it, not a folder the run created. A folder
nobody moved cannot be selected at all, so a hand-made placeholder survives,
and a symlink anywhere inside disqualifies the subtree. Every rmdir is
journalled, so rollback recreates what a sweep removed.
2026-10-30 20:03:16 -07:00

9.6 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.
dankserver
media-server
jellyfin
plex
procedure
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

  • 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