feat(media): the movie-library relayout toolchain — census, plan, executor
The movie library on the entropy volume is release-folded: the video inside
`Some.Movie.2019.1080p…-FOO/` is named after the release, and Jellyfin matches
a movie by folder and file name, so the noise cost posters, extras and versions.
Three scripts own the fix, and nothing else in the repo moves a media file.
* media-census.py the read-only inputs: a stat pass, an ffprobe header pass
(nice'd, so it is safe beside a live endpoint), and the
Plex metadata cache read that outranks any filename guess
* media-plan.py the Jellyfin-format planner: one folder per movie, the
video named exactly like the folder, extras in the
canonical subfolders, copies of one film as versions. It
opens no media file, so a whole-library plan costs seconds,
and it ends in gates — bad-names, occupied, double-claim,
left-unmoved, unplanned — that must all read zero before a
human points the executor at 24 TB
* media-apply.py the executor: rename(2) only, the intent fsynced to a
write-ahead journal BEFORE the move it authorises, so an
interrupted run is finishable and every run is undoable.
Refusals are all-or-nothing and it never overwrites a file
Laws the fixture pins (tests/media_relayout.bats, 16 tests, each proven red
against a reverted copy of the rule it guards):
* a Plex year of 0 means "indexed, never matched" — trusting it produced
folders "Title (0)": items with no year, no poster, no identity
* a Plex title carrying "/" is sanitised, or "V/H/S/2" became a five-level
tree neither matcher reads as a movie
* a folder inside a movie's folder is bonus territory even when its name is
misspelled ("Exrtas"), unless the file's own name carries another year
* matching words split on everything but a letter or digit — the earlier
tokenizer left "(1960)/extras" glued, so a film could not be found by the
folder holding it and one box set's featurettes drifted into a neighbour
* a name with no words in it is bonus material, never a movie folder named 1
Docs: the runbook page owns the procedure, the gates and the rollback; AGENTS.md
links it from the runbook index and the summary table.
This commit is contained in:
1 parent
4db985b90d
commit
272e06daf6
8 files changed
+2435
No files matched your search
@@ -0,0 +1,130 @@
|
||||
---
|
||||
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** | 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 …/media-relayout.jsonl
|
||||
scripts/media-apply.py rollback --journal …/media-relayout.jsonl --apply
|
||||
```
|
||||
|
||||
After it lands, both servers need a scan or the change is invisible:
|
||||
[[Jellyfin]] *Library → Scan All Libraries*, and Plex's library scan
|
||||
(`podman exec plex …` or the web UI). `verify` reporting empty folders is normal
|
||||
for the folders the piles own; they are left in place per the deletion policy.
|
||||
|
||||
## Laws
|
||||
|
||||
- **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.
|
||||
|
||||
## Rollback
|
||||
|
||||
One command, from the journal of the run:
|
||||
`media-apply.py rollback --journal …/media-relayout.jsonl --apply`. It 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>
|
||||
Reference in new issue
Block a user