docs(media): the music library's own relayout, with the laws it earned

This commit is contained in:
Frank committed 2026-10-30 23:58:34 -07:00
1 parent b03931f7ce
commit a5459b0b1e
1 file changed
+77 -3
@@ -12,8 +12,8 @@ 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 Jellyfin's own shape — the movies, the TV packs, and the TV that
turns up in the movie share — and nothing else in the repo touches a media file.
library into Jellyfin's own shape — the movies, the TV packs, the TV that
turns up in the movie share, and the music library — and nothing else in the repo touches a media file.
| Script | Touches media? | Output |
|---|---|---|
@@ -22,8 +22,10 @@ turns up in the movie share — and nothing else in the repo touches a media fil
| `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 |
The seams are `tests/media_relayout.bats` and `tests/media_shows.bats`; the target contract is quoted from
The seams are `tests/media_relayout.bats`, `tests/media_shows.bats` and
`tests/media_music.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.
@@ -214,6 +216,63 @@ filename. Renaming a show is Jellyfin's work — a folder rename never touches i
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 2026-10-30 dry run files 114k tracks and parks 6.8k — mostly sidecars
sitting in a folder that holds two albums, plus audio no provider row names. The
plan is NOT applied: the exceptions file is the review, and it is Frank's to
read.
## Laws
- **A moved file is a 404 in [[Plex]] until its section is rescanned.** The
@@ -322,6 +381,21 @@ index a release leads with (`4a. Seed DESTINY` → `Seed DESTINY`).
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