feat(media): the music library's own relayout, filed by its tags #100
@@ -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
|
`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
|
the release. [[Jellyfin]] matches a movie by folder and file name, so the
|
||||||
release noise cost posters, extras and versions. These scripts turn that
|
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
|
library into Jellyfin's own shape — the movies, the TV packs, the TV that
|
||||||
turns up in the movie share — and nothing else in the repo touches a media file.
|
turns up in the movie share, and the music library — and nothing else in the repo touches a media file.
|
||||||
|
|
||||||
| Script | Touches media? | Output |
|
| 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-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-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-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)
|
[jellyfin.org/docs/general/server/media/movies](https://jellyfin.org/docs/general/server/media/movies)
|
||||||
in `media-plan.py`'s docstring.
|
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
|
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`).
|
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
|
## Laws
|
||||||
|
|
||||||
- **A moved file is a 404 in [[Plex]] until its section is rescanned.** The
|
- **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;
|
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
|
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.
|
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
|
## Rollback
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user