Two relayouts writing one journal cannot be rolled back apart: the mover (Movies -> TVShows) and the library re-filer both pointed at media-relayout-tv.jsonl. The re-filer now writes media-relayout-shows.jsonl, and its plan/exceptions move off /tmp, which a reboot eats and which is not a record.
501 lines
29 KiB
Markdown
501 lines
29 KiB
Markdown
---
|
|
type: reference
|
|
title: Media Relayout
|
|
description: "Census, plan and apply the movie, TV, music and audiobook libraries: the gates, the refusals, 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 scripts turn that
|
|
library into the shape its media server reads — the movies, the TV packs, the TV
|
|
that turns up in the movie share, the music library, and the audiobooks, whose
|
|
only reader is Plex — and nothing else in the repo touches a media file.
|
|
|
|
| Script | Touches media? | Output |
|
|
|---|---|---|
|
|
| `scripts/media-census.py` | reads headers and the servers' own DBs, never writes | `enum`, `probed`, and one authority TSV per library |
|
|
| `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 |
|
|
| `scripts/media-shows.py` | no — TSV out | a plan that re-files the TV library itself |
|
|
| `scripts/media-music.py` | no — TSV out | a plan that re-files the music library by its tags |
|
|
| `scripts/media-audiobooks.py` | no — TSV out | a plan that re-files the audiobooks by the book Plex matched |
|
|
|
|
The seams are `tests/media_relayout.bats`, `tests/media_shows.bats`,
|
|
`tests/media_music.bats` and `tests/media_audiobooks.bats`; the movie and TV
|
|
target contracts are 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 —
|
|
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.
|
|
|
|
```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]]'s scan key lives in `~/.config/secrets/jellyfin` (mode 600; the
|
|
same value is `JELLYFIN_API_KEY` in the ignored `dankserver/.env`). It rides a
|
|
mode-600 curl config, never a header argument, and it must use the
|
|
`MediaBrowser` form — Jellyfin 12.4 refuses a bare `X-Emby-Token` for any key:
|
|
|
|
```bash
|
|
umask 077
|
|
{ printf 'header = "Authorization: MediaBrowser Token=\\"%s\\""\n' \
|
|
"$(cat ~/.config/secrets/jellyfin)"
|
|
echo 'url = "http://127.0.0.1:8096/Library/Refresh"'
|
|
echo 'request = "POST"'; } >/tmp/jf.hdr
|
|
curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n"
|
|
shred -u /tmp/jf.hdr
|
|
```
|
|
|
|
`204` is acceptance; the scan then runs on the server's own schedule.
|
|
|
|
### Proving the move landed
|
|
|
|
A scan answers `204` whether or not it finds anything, so proof is the journal
|
|
replayed against both servers' own tables: every `move` row has a row at its
|
|
`dst`, and **zero** rows still name its `src`. There is no `sqlite3` binary on
|
|
these hosts and none inside either container — read the DBs with the host
|
|
`python3` and `mode=ro&immutable=1`, which takes no lock while the server runs.
|
|
|
|
| | path | path column | shape |
|
|
|---|---|---|---|
|
|
| Jellyfin | `/home/dank/Containers/jellyfin/config/data/jellyfin.db` | `BaseItems.Path` | one `BaseItems` table; `Type` holds the .NET class (`…Entities.TV.Episode`). There is no `MediaItems`/`TypedItems`. |
|
|
| Plex | `/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db` | `media_parts.file` | join `media_parts → media_items → metadata_items`; filter `deleted_at is null` on **both** or deleted scans pollute the count. |
|
|
|
|
**Jellyfin stores container-side paths.** The share binds `/mnt/entropy → /data`
|
|
in that container, so a journal `src` of `/mnt/entropy/TVShows/…` is stored as
|
|
`/data/TVShows/…`. Plex is bound at the same path it has on the host, so its
|
|
rows match the journal verbatim.
|
|
|
|
Three doors that look right and are not: `GET /search?query=` matches metadata
|
|
titles, never file paths; `?path=` on `/library/sections/N/all` makes Plex's
|
|
HTTPD close without a single byte; and the same HTTPD drops requests that carry
|
|
no `Accept` and client-identifier header (see the scan block above for the form
|
|
that works).
|
|
|
|
## 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:
|
|
|
|
```bash
|
|
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 named `Part 2`
|
|
is 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.
|
|
|
|
Below the threshold the mover prints and leaves, so a lone downloaded episode of
|
|
a real show stays in the movie library on purpose — six did on 2026-10-30.
|
|
Filing one by hand is the same executor on a hand-written plan and the same
|
|
journal; the trap is the series year. Read it from the file's own name or the
|
|
folder it owns, never from the title whose bonus folder it happens to be parked
|
|
in — `The Cleveland Show S03E07` inside `Die Hard (1988)/extras/` is not a 1988
|
|
show. A name whose marker is `Part 2` is a film and stays put.
|
|
|
|
## The TV library's own relayout
|
|
|
|
`/mnt/entropy/TVShows` is scene packs rather than a library, and Jellyfin has
|
|
already matched and numbered most of it. `scripts/media-shows.py` re-files that
|
|
library into the same shape, and it never guesses: every series name and every
|
|
episode number it writes traces to an authority, and what has none is parked for
|
|
a human to read.
|
|
|
|
```bash
|
|
# 1. The authority: Jellyfin's own matched rows (the DB opens read-only).
|
|
python3 scripts/media-census.py jellyfin --root /mnt/entropy/TVShows \
|
|
--out /tmp/tv_titles.tsv
|
|
# 2. What is on disk (stat only), and the runtimes the film arm needs.
|
|
python3 scripts/media-census.py enumerate --root /mnt/entropy/TVShows \
|
|
--out /tmp/tv_enum.tsv
|
|
python3 scripts/media-census.py probe --root /mnt/entropy/TVShows \
|
|
--enum /tmp/tv_enum.tsv --jobs 6 --out /tmp/tv_probed.tsv
|
|
# The probe is the slow step: ffprobe reads both ends of every file.
|
|
# 3. The plan. --tmdb needs TMDB_API_KEY; without it a series no provider dated
|
|
# is simply named without a year, which Jellyfin accepts.
|
|
python3 scripts/media-shows.py --root /mnt/entropy/TVShows --enum /tmp/tv_enum.tsv \
|
|
--titles /tmp/tv_titles.tsv --probed /tmp/tv_probed.tsv --out /mnt/entropy/media-relayout-shows-plan.tsv \
|
|
--exceptions /mnt/entropy/media-relayout-shows-exceptions.txt
|
|
# 4. Read the exceptions file FIRST — it is the review, not a log. Same executor
|
|
# as above, but on this arm's OWN journal: a shared journal cannot be unwound.
|
|
scripts/media-apply.py apply --plan /mnt/entropy/media-relayout-shows-plan.tsv \
|
|
--root /mnt/entropy --journal /mnt/entropy/media-relayout-shows.jsonl --apply
|
|
```
|
|
|
|
Evidence, strongest first — the order the planner believes things:
|
|
|
|
- the **episode marker the file's own name states** (`S01E02-E03`, `1x04`);
|
|
- the **`Season NN` folder it sits in and the number its name leads with inside
|
|
it**: `Season 19/01 Charles Barkley, Nirvana.avi` is S19E01;
|
|
- the **provider row**: identity (the series name and its year), and the
|
|
numbering of a file whose own name says nothing;
|
|
- a **bonus folder** anywhere above the file, or Jellyfin's `ExtraType` → the
|
|
series' canonical extra folder;
|
|
- a runtime over `SHORT_MAX` makes it film-shaped, and it leaves for `--movies`
|
|
only when a movie provider names it — otherwise it parks.
|
|
|
|
Two refusals carry the design, and a dry run on the real library found both:
|
|
|
|
- **Folders resolve a series; they never invent one.** A folder's words may
|
|
resolve a file to a series a provider has already named — `(CBB) Tengen Toppa
|
|
Gurren Lagann - The Complete Series (BD 1080p)` names the registered *Tengen
|
|
Toppa Gurren Lagann* — but a bonus subfolder is not a show. An early run made
|
|
`…/03. Extra/01. NCOP/NCOP 01.mkv` into a root series called `01 NCOP`.
|
|
- **A scraped row does not outrank the filesystem.** Jellyfin numbers
|
|
multi-season packs badly: every file under `SNL 16-20/Season 16..19` carries
|
|
season 16 episode 20, and one Gundam row says season 2004. The folder, then
|
|
the file's own leading number, win; a row that still insists on season 1989
|
|
parks its file instead of creating `Season 1989`.
|
|
|
|
The provider's name is copied verbatim, warts included: Jellyfin carries a Series
|
|
named `Black Clover 001` (92 episodes, no year) because it matched a pack off a
|
|
filename. Renaming a show is Jellyfin's work — a folder rename never touches its
|
|
provider ids — so the mover keeps the name it is handed and strips only the pack
|
|
index a release leads with (`4a. Seed DESTINY` → `Seed DESTINY`).
|
|
|
|
## The music library's own relayout
|
|
|
|
`/mnt/entropy/Music` is rips and discography packs, and Jellyfin has matched and
|
|
tagged almost all of it. `scripts/media-music.py` re-files it into the shape
|
|
Jellyfin's music scanner reads —
|
|
|
|
Music/<album artist>/<Album> (<year>)/NN - <Title>.ext
|
|
|
|
— and the authority is the tag row of the track, never the name the ripper gave
|
|
the file.
|
|
|
|
```bash
|
|
# 1. The authority: Jellyfin's audio rows, one per track, with the album and
|
|
# artist ids its own database carries (the DB opens read-only).
|
|
python3 scripts/media-census.py music --root /mnt/entropy/Music \
|
|
--out /tmp/music_rows.tsv
|
|
# 2. What is on disk (stat only).
|
|
python3 scripts/media-census.py enumerate --root /mnt/entropy/Music \
|
|
--out /tmp/music_enum.tsv
|
|
# 3. The plan.
|
|
python3 scripts/media-music.py --root /mnt/entropy/Music --enum /tmp/music_enum.tsv \
|
|
--rows /tmp/music_rows.tsv --out /tmp/music_plan.tsv \
|
|
--exceptions /tmp/music_exceptions.txt
|
|
# 4. Read the exceptions file FIRST — it is the review, not a log. Then the same
|
|
# executor, journal and rollback as above, on its own journal.
|
|
scripts/media-apply.py apply --plan /tmp/music_plan.tsv --root /mnt/entropy \
|
|
--journal /mnt/entropy/media-relayout-music.jsonl --apply
|
|
```
|
|
|
|
The group is what the tags assert, not where the files sit: **(album-artist tag,
|
|
album, year)**. One album arrives as three disc folders and as two copies of one
|
|
rip, and one MusicBrainz release group is one album. A compilation is one album
|
|
whose tracks name many performers, so it files once under `Various Artists`:
|
|
keying on the performer would file one album once per artist. Two different
|
|
album-artist tags on one album name are two albums, which is what Jellyfin's two
|
|
album items say.
|
|
|
|
What it refuses:
|
|
|
|
+- **A sidecar is placed by the audio around it, never by its own name.** A
|
|
`.lrc`, `.txt` or picture that shares a track's stem follows that track and
|
|
takes the name the track now has, or Jellyfin cannot find it; anything else
|
|
goes to the album its own folder was placed in, or to the folder whose discs
|
|
sit one level below it. A folder whose audio belongs to two albums parks its
|
|
sidecar rather than guessing.
|
|
+- **An image album keeps its name.** A folder holding one audio file and a
|
|
`.cue` is a whole album in one file; numbering it `01 - …` tells Jellyfin to
|
|
index it as one track.
|
|
+- **A video container parks**, because Jellyfin will not index it as music and a
|
|
rename to `.m4a` would claim it is; **audio no provider row names parks** too,
|
|
because unmatched audio is a tagging job, not a renaming job.
|
|
|
|
The plan applies on 2026-10-31 through `/mnt/entropy/media-relayout-music.jsonl`;
|
|
its rollback is that journal run through `media-apply.py rollback --apply`. ONE
|
|
writer at a time on this array: two rename passes race, and neither journal tells.
|
|
|
|
## The audiobook library's own relayout
|
|
|
|
`/mnt/entropy/Audiobooks` is the one library here that [[Jellyfin]] does not read
|
|
at all — its music scanner has no audiobook concept — while [[Plex]] indexes all
|
|
of it as Artist → Album → Track: the album is the BOOK, that book's single artist
|
|
is the NARRATOR, both matched out of the files' own tags. Today one book is split
|
|
across `… [Part 1]`, `… [Part 2]` folders three or four deep inside a collection
|
|
pack, so a scan serves three quarters of a novel; `scripts/media-audiobooks.py`
|
|
re-files the tree so the property both servers' book scanners assume is true —
|
|
|
|
Audiobooks/<Narrator>/<Book> (<year>)/<the file keeps its own name>
|
|
|
|
```bash
|
|
# 1. The authority: Plex's chapter rows for this root (the DB opens read-only).
|
|
python3 scripts/media-census.py audiobooks --root /mnt/entropy/Audiobooks \
|
|
--out /tmp/ab_rows.tsv
|
|
# 2. What is on disk (stat only).
|
|
python3 scripts/media-census.py enumerate --root /mnt/entropy/Audiobooks \
|
|
--out /tmp/ab_enum.tsv
|
|
# 3. The plan.
|
|
python3 scripts/media-audiobooks.py --root /mnt/entropy/Audiobooks \
|
|
--enum /tmp/ab_enum.tsv --rows /tmp/ab_rows.tsv --out /tmp/ab_plan.tsv \
|
|
--exceptions /tmp/ab_exceptions.txt
|
|
# 4. Read the exceptions file FIRST. Then the same executor, journal and rollback
|
|
# as above, on its own journal, and refresh the Plex section as in step 5 of
|
|
# the movie Procedure — from inside the container, never over the host port.
|
|
scripts/media-apply.py apply --plan /tmp/ab_plan.tsv --root /mnt/entropy \
|
|
--journal /mnt/entropy/media-relayout-audiobooks.jsonl --apply
|
|
```
|
|
|
|
The group is the identity Plex asserts in its own database — **(narrator, book,
|
|
year)** — so one book arriving in four part folders becomes one folder, and a
|
|
1965 and a 2020 recording that share a title stay two books.
|
|
|
|
Two laws are forced by what the rows actually hold:
|
|
|
|
+- **A chapter is never renumbered.** Most books contain chapters whose index Plex
|
|
does not know, so a number written into a name would be invented — and it would
|
|
buy nothing, because both servers order playback by the embedded track tag, not
|
|
by the file name. A wrong number in a name is the number a human later trusts.
|
|
Fix order in the tags: a Plex scan re-reads tags and never renames a file.
|
|
+- **The shelf is the narrator, not the author.** Every book has exactly one artist
|
|
row and it is the narrator; the author survives only inside some album titles
|
|
(`Kryptos by Graham McNeill`) and in no column Plex stores, so deriving it would
|
|
mean parsing titles where a ripper wrote one and inventing it everywhere else.
|
|
The author stays visible in the folder name Plex itself shows.
|
|
|
|
What it refuses, the music arm's list with one addition: a video container, audio
|
|
no chapter row names, and a loose file in a folder holding two books all park under
|
|
`_unsorted` with the path they have today — and so does a chapter whose row names
|
|
**no book or no narrator**: a placeholder (`[Unknown Album]`, `[Unknown Artist]`)
|
|
that unrelated books share, or a chapter number mistagged as the artist (`0.4`).
|
|
`Various Artists` is not a placeholder — that is a multi-cast read.
|
|
|
|
The plan stands reviewed at `/mnt/entropy/media-relayout-audiobooks-plan.tsv`
|
|
(55,114 moves, 7,764 parked, executor preflight refused nothing); it applies on its
|
|
own journal, behind the music writer, and then refreshes the Plex section.
|
|
|
|
## 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.
|
|
- **Compare two media libraries with the series engine, never a path
|
|
substring.** Filtering paths for `monk` also 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 is `media-titles.ep_of`
|
|
on 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 `/home` and `/mnt/entropy` are 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, and `rename(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 Jellyfin 401 is the header, not the key.** 12.4 authenticates only
|
|
`Authorization: MediaBrowser Token="…"` and refuses a bare `X-Emby-Token` for
|
|
every key, valid or not. This page carried "no credential on this box can
|
|
trigger a scan, the two Seerr keys answer 401" for weeks because of that
|
|
header — the keys were innocent. Before believing a 401, prove the key against
|
|
the server's own table: `select count(*) from ApiKeys where AccessToken = ?`
|
|
on `jellyfin.db` opened `mode=ro&immutable=1`.
|
|
- **A scan that must see a new folder has to be recursive.** `POST
|
|
/Library/Refresh` is the incremental pass, and inotify does not cross a
|
|
rootless podman bind, so folders created on the host stay invisible to it. The
|
|
move is `POST /Items/{id}/Refresh?Recursive=true` (204) per library, the id
|
|
from `GET /Library/VirtualFolders` — its field is `ItemId`, not `Id` — and
|
|
that same record's `RefreshStatus` is the progress read.
|
|
- **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.
|
|
A failed `rmdir` is 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 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.
|
|
- **One journal is one undo.** The TV move runs on its own journal, and with
|
|
`--root` set 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.
|
|
|
|
- **Sidecar metadata does not follow a rename, because it lives on another
|
|
filesystem.** The media servers write `.nfo` and artwork through the
|
|
[[Media Metadata View]] (NVMe upper layer); this tool moves bytes on the raw
|
|
array path. After any sweep or TV move, run `scripts/media-view.sh reap`, then
|
|
the same command with `--apply`: the sidecars left at the old coordinates become
|
|
ghost folders — artwork and an `.nfo` with no video — that both servers browse
|
|
and report as content.
|
|
- **Nothing deletes through the metadata view.** A delete there writes an overlayfs
|
|
whiteout in the upper layer, which hides the array's copy without removing it;
|
|
the file returns when the view is unmounted. Deletions in this pipeline happen
|
|
on the raw path only, which is what the journal can undo.
|
|
- **The music album key is case-folded; the folder name is not.** `Master Of
|
|
Puppets` and `Master of Puppets` are one album to the matcher, so the spelling
|
|
that reaches the folder is the one most tracks carry. Taking it from whichever
|
|
row sorted first made the folder name depend on row order, and re-planning an
|
|
already-filed library renamed albums that were already in place.
|
|
- **An occupied path is never a destination**, not even when its occupant is
|
|
scheduled to leave: the executor refuses an occupied target, so a plan that
|
|
aims at one fails after the renames that were supposed to clear it. A file
|
|
standing where the contract puts it is a keep, and a file already carrying a
|
|
collision suffix keeps that suffix — otherwise the plan is a shuffle, not a
|
|
fixed point, and a re-run moves albums that are already right.
|
|
- **`media-census.py music` reads the `BaseItemProviders` table, not the item's
|
|
JSON.** This Jellyfin build carries an empty `ProviderIds` inside
|
|
`BaseItems.Data` for albums, so a census that parses the JSON finds no
|
|
MusicBrainz or Spotify id on any album and reads as "the library is untagged".
|
|
|
|
## 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]], [[Media Metadata View]], [[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>
|