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.
180 lines
9.6 KiB
Markdown
180 lines
9.6 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
|
|
|
|
- **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:
|
|
<https://jellyfin.org/docs/general/server/media/movies>
|