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.
188 lines
10 KiB
Markdown
188 lines
10 KiB
Markdown
---
|
|
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/<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:
|
|
|
|
```bash
|
|
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 stream` while 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 `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.
|
|
Proof carries downward: inside a folder the journal proves emptied, every
|
|
empty directory goes with it. A release's `Screens/` or `Sample/` 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 Game` is series
|
|
`Monk`. 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 named `Part 2` stays 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.mkv` version and `- 4K.mkv` handling:
|
|
<https://jellyfin.org/docs/general/server/media/movies>
|