Files
serverAppConfigs/tests/media_music.bats
T
Frank b03931f7ce feat(media): a music relayout that files by the tag row Jellyfin matched
The music library is rips and discography packs whose filenames say nothing the
matcher needs. The authority is Jellyfin's own audio rows: one per track, with
the album and album-artist ids it carries in BaseItemProviders (the item JSON
holds an empty ProviderIds for albums in this build).

Grouping is what the tags assert, not where files sit: (album-artist tag, album,
year), case-folded. A compilation is one album whose tracks name many performers,
so it files once under Various Artists; two album-artist tags on one album name
are two albums. The folder spelling is the dominant one, because a name taken
from whichever row sorted first depends on row order and re-names albums that are
already filed.

An occupied path is never a destination: the executor refuses occupied targets,
so a plan that aims at one fails after the renames meant to clear it. A file
standing on its own target, or already carrying a collision suffix, is a keep -
otherwise the plan shuffles instead of being a fixed point.
2026-10-30 23:58:34 -07:00

374 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bats
# Seam: the music-library relayout — scripts/media-census.py music (the tag and
# provider authority export) and scripts/media-music.py (the plan).
#
# Jellyfin's music rule is the opposite of the movie and TV ones: the embedded
# tags are the metadata and the file name is only a fallback title, so the
# authority here is the Audio row Jellyfin already matched (its album-artist,
# album, year, disc and track columns) and NOT the path a ripper chose. Every
# test states which authority a file has — row only, row with no album item,
# sidecar of a file that has a row, nothing at all — and asserts where the PLAN
# files it, never how the planner decided.
#
# The three outcomes that cost a re-run of a 90k-file plan: a track filed under
# the wrong artist (it splits one album across two artists), a destination that
# two tracks claim (the executor refuses, and the operator loses the run), and a
# lyric file that stops matching the basename of the track it belongs to, which
# silently loses the lyrics. Everything below is written to catch those, plus the
# gate that matters most: the plan must replay through scripts/media-apply.py,
# whose preflight is the fleet's only overwrite guard.
load helpers
PLAN="$REPO_ROOT/scripts/media-music.py"
CENSUS="$REPO_ROOT/scripts/media-census.py"
APPLY="$REPO_ROOT/scripts/media-apply.py"
# at PATH SIZE — a file with deterministic content.
at() { mkdir -p "$(dirname "$1")"; head -c "$2" /dev/zero > "$1"; }
# row FILE ARTIST ALBUM YEAR DISC TRACK TITLE [MB] [PERFORMER]
# One row of the authority export scripts/media-census.py music writes.
row() {
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$1" "$2" "$3" "${4:-}" "${5:-}" "${6:-}" "$7" "${8:-}" "${9:-}" >> "$ROWS"
}
# dst SRC — the destination the plan gives SRC, or nothing when it does not move.
dst() {
awk -F '\t' -v s="$1" '$1 == "move" && $2 == s {print $3}' "$OUT"
}
setup() {
MUSIC="$BATS_TEST_TMPDIR/Music"
PARK="$MUSIC/_unsorted"
ENUM="$BATS_TEST_TMPDIR/enum.tsv"
ROWS="$BATS_TEST_TMPDIR/rows.tsv"
OUT="$BATS_TEST_TMPDIR/plan.tsv"
EXC="$BATS_TEST_TMPDIR/exceptions.txt"
# A discography pack: the artist's own albums buried under "01. Albums",
# with a lyric, a cover, an EAC log and a playlist beside the tracks.
A="$MUSIC/Artist One - Discography [FLAC]/01. Albums/Album X"
at "$A/01. First.flac" 9000
at "$A/02. Second.flac" 9000
at "$A/01. First.lrc" 10
at "$A/cover.jpg" 20
at "$A/rip.log" 10
at "$A/Album X.m3u" 10
# A multi-disc compilation: one album (the album artist is the compilation
# tag), two discs, and a different performer on each track.
B="$MUSIC/Box (2019)"
at "$B/Disc 1/01 Track A.flac" 9000
at "$B/Disc 2/01 Track B.flac" 9000
# The box cover lies above the two disc folders: the discs below it own it.
at "$B/box.jpg" 10
# One image plus its cuesheet: a whole album in one file.
C="$MUSIC/Image Cue"
at "$C/Image.flac" 9000
at "$C/Image.cue" 10
# The hard cases: no row at all, a video container, two tracks claiming one
# number and one title, a track with no number, and a track whose album
# artist tag is empty so only the performer names it.
D="$MUSIC/Odd"
at "$D/loose.flac" 9000
at "$D/concert.mkv" 9000
at "$D/dup1.flac" 9000
at "$D/dup2.flac" 9000
at "$D/unnumbered.flac" 9000
at "$D/lone.flac" 9000
# A sidecar in a folder that holds two different albums.
E="$MUSIC/Mixed"
at "$E/one.flac" 9000
at "$E/two.flac" 9000
at "$E/notes.pdf" 10
# An album title carrying a character Jellyfin says breaks playback.
F="$MUSIC/Question Mark"
at "$F/01. Bang.flac" 9000
# An album whose tags name no album artist at all, and two different
# performers; and one album title carried by two different album artists.
G="$MUSIC/Tagless"
at "$G/one.flac" 9000
at "$G/two.flac" 9000
H="$MUSIC/Same Title"
at "$H/a.flac" 9000
at "$H/b.flac" 9000
# One album whose tracks disagree on the spelling of its name; the minority
# spelling sorts first, so it must not win the folder.
I="$MUSIC/Puppets"
at "$I/one.flac" 9000
at "$I/two.flac" 9000
at "$I/three.flac" 9000
# The authority export carries a header; the planner reads it by column name.
printf 'file\tartist\talbum\tyear\tdisc\ttrack\ttitle\tmb\tperformer\n' > "$ROWS"
row "$A/01. First.flac" "Artist One" "Album X" 2015 1 1 "First Song"
row "$A/02. Second.flac" "Artist One" "Album X" 2015 1 2 "Second Song: Reprise"
row "$B/Disc 1/01 Track A.flac" "Various Artists" "Best Of" 2019 1 1 "Track A" \
"" "Aaa Singer"
row "$B/Disc 2/01 Track B.flac" "Various Artists" "Best Of" 2019 2 1 "Track B" \
"" "Bbb Singer"
row "$C/Image.flac" "Solo" "Single Album" 2001 1 1 "Single Album"
row "$D/dup1.flac" "Dup Artist" "Dupes" "" "" 1 "Same"
row "$D/dup2.flac" "Dup Artist" "Dupes" "" "" 1 "Same"
row "$D/unnumbered.flac" "No Number" "No Number Album" "" "" "" "Untitled"
row "$D/lone.flac" "" "Session Works" "" "" 1 "Lone" "" "Session Singer"
row "$E/one.flac" "Mix Artist" "First Set" 2000 "" 1 "One"
row "$E/two.flac" "Mix Artist" "Second Set" 2001 "" 1 "Two"
row "$F/01. Bang.flac" "Billie" "WHEN WE ALL FALL ASLEEP, WHERE DO WE GO?" \
2019 "" 1 "!!!!!!"
row "$G/one.flac" "" "Two Seasons" 2005 "" 1 "One" "" "Alpha Band"
row "$G/two.flac" "" "Two Seasons" 2005 "" 2 "Two" "" "Beta Band"
row "$H/a.flac" "Aaa Singer" "Same Title" 2011 "" 1 "A Song"
row "$H/b.flac" "Bbb Singer" "Same Title" 2011 "" 1 "B Song"
row "$I/one.flac" "Metallica" "Master Of Puppets" 2015 "" 1 "One"
row "$I/two.flac" "Metallica" "Master of Puppets" 2015 "" 2 "Two"
row "$I/three.flac" "Metallica" "Master of Puppets" 2015 "" 3 "Three"
# The enum TSV is what scripts/media-census.py enumerate writes: bytes, ext,
# path, no header.
python3 - "$MUSIC" "$ENUM" <<'PY'
import os, sys
root, out = sys.argv[1], sys.argv[2]
with open(out, "w") as fh:
for dirpath, _dirs, files in os.walk(root):
for fn in files:
p = os.path.join(dirpath, fn)
fh.write(f"{os.path.getsize(p)}\t{os.path.splitext(fn)[1].lower()}\t{p}\n")
PY
}
plan() {
run python3 "$PLAN" --root "$MUSIC" --enum "$ENUM" --rows "$ROWS" \
--out "$OUT" --exceptions "$EXC"
[ "$status" -eq 0 ] || echo "planner said: $output"
}
@test "the census exports the tag and provider row Jellyfin matched for a track" {
DB="$BATS_TEST_TMPDIR/jellyfin.db"
python3 - "$DB" <<'PY'
import sqlite3, sys, uuid
c = sqlite3.connect(sys.argv[1])
c.execute("""create table BaseItems (Id, ParentId, Type, Name, Path, IndexNumber,
ParentIndexNumber, ProductionYear, Album,
AlbumArtists, Artists, Data)""")
c.execute("""create table BaseItemProviders (ItemId, ProviderId, ProviderValue)""")
AU = "MediaBrowser.Controller.Entities.Audio.Audio"
AL = "MediaBrowser.Controller.Entities.Audio.MusicAlbum"
aid, trk = str(uuid.uuid4()), "/data/Music/Pack/Album X/01. First.flac"
c.execute("insert into BaseItems values (?,?,?,?,?,?,?,?,?,?,?,?)",
(aid, None, AL, "Album X", "/data/Music/Pack/Album X", None, None, 2015,
None, "Artist One", None, '{}'))
c.execute("insert into BaseItemProviders values (?,?,?)",
(aid, "MusicBrainzReleaseGroup", "rg-1234"))
c.execute("insert into BaseItems values (?,?,?,?,?,?,?,?,?,?,?,?)",
(str(uuid.uuid4()), aid, AU, "First Song", trk, 1, 1, 2015,
"album x (the tag)", "Artist One", "Artist One", '{}'))
c.commit()
PY
run python3 "$CENSUS" music --root "$BATS_TEST_TMPDIR/Music" --db "$DB" \
--out "$BATS_TEST_TMPDIR/exp.tsv"
[ "$status" -eq 0 ]
run head -1 "$BATS_TEST_TMPDIR/exp.tsv"
[ "${lines[0]}" = "file artist album year disc track title mb performer" ]
tail -1 "$BATS_TEST_TMPDIR/exp.tsv" \
| grep -q "^$BATS_TEST_TMPDIR/Music/Pack/Album X/01\. First\.flac Artist One Album X 2015 1 1 First Song rg-1234 Artist One$"
}
@test "a track whose folder is no album item still exports from its own tags" {
DB="$BATS_TEST_TMPDIR/jellyfin.db"
python3 - "$DB" <<'PY'
import sqlite3, sys, uuid
c = sqlite3.connect(sys.argv[1])
c.execute("""create table BaseItems (Id, ParentId, Type, Name, Path, IndexNumber,
ParentIndexNumber, ProductionYear, Album,
AlbumArtists, Artists, Data)""")
c.execute("""create table BaseItemProviders (ItemId, ProviderId, ProviderValue)""")
c.execute("insert into BaseItems values (?,?,?,?,?,?,?,?,?,?,?,?)",
(str(uuid.uuid4()), None, "MediaBrowser.Controller.Entities.Audio.Audio",
"Lone", "/data/Music/Pack/lone.flac", 1, None, None,
"Session Works", None, "Session Singer", '{}'))
c.commit()
PY
run python3 "$CENSUS" music --root "$BATS_TEST_TMPDIR/Music" --db "$DB" \
--out "$BATS_TEST_TMPDIR/exp.tsv"
[ "$status" -eq 0 ]
tail -1 "$BATS_TEST_TMPDIR/exp.tsv" \
| grep -q "^$BATS_TEST_TMPDIR/Music/Pack/lone\.flac Session Singer Session Works 1 Lone Session Singer$"
}
@test "a tagged track files under artist, album and year with a numbered title" {
plan
[ "$status" -eq 0 ]
[ "$(dst "$A/01. First.flac")" = "$MUSIC/Artist One/Album X (2015)/01 - First Song.flac" ]
}
@test "the tag title wins over the name the ripper gave the file" {
plan
[ "$(dst "$A/02. Second.flac")" = "$MUSIC/Artist One/Album X (2015)/02 - Second Song Reprise.flac" ]
}
@test "a character Jellyfin says breaks playback never reaches a destination" {
plan
[ "$(dst "$F/01. Bang.flac")" = "$MUSIC/Billie/WHEN WE ALL FALL ASLEEP, WHERE DO WE GO (2019)/01 - !!!!!!.flac" ]
run awk -F '\t' '$1 == "move" && $3 ~ /[<>:"|?*]/ {print $3}' "$OUT"
[ -z "$output" ]
}
@test "every disc of a multi-disc album lands in one folder, numbered apart" {
plan
[ "$(dst "$B/Disc 1/01 Track A.flac")" = "$MUSIC/Various Artists/Best Of (2019)/1.01 - Track A.flac" ]
[ "$(dst "$B/Disc 2/01 Track B.flac")" = "$MUSIC/Various Artists/Best Of (2019)/2.01 - Track B.flac" ]
}
@test "an album whose tracks name several artists files under Various Artists" {
plan
run awk -F '\t' '$1 == "move" && $3 ~ /\/Best Of \(2019\)\/.*\.flac$/ {print $3}' "$OUT"
[ "${#lines[@]}" -eq 2 ]
[[ "${lines[0]}" == "$MUSIC/Various Artists/"* ]]
}
@test "an album with no album artist and two performers is a compilation" {
plan
[ "$(dst "$G/one.flac")" = "$MUSIC/Various Artists/Two Seasons (2005)/01 - One.flac" ]
[ "$(dst "$G/two.flac")" = "$MUSIC/Various Artists/Two Seasons (2005)/02 - Two.flac" ]
}
@test "the same album title under two album artists is two albums" {
plan
[ "$(dst "$H/a.flac")" = "$MUSIC/Aaa Singer/Same Title (2011)/01 - A Song.flac" ]
[ "$(dst "$H/b.flac")" = "$MUSIC/Bbb Singer/Same Title (2011)/01 - B Song.flac" ]
}
@test "the folder carries the album spelling most tracks carry" {
plan
[ "$(dst "$I/one.flac")" = "$MUSIC/Metallica/Master of Puppets (2015)/01 - One.flac" ]
[ "$(dst "$I/two.flac")" = "$MUSIC/Metallica/Master of Puppets (2015)/02 - Two.flac" ]
[ "$(dst "$I/three.flac")" = "$MUSIC/Metallica/Master of Puppets (2015)/03 - Three.flac" ]
}
@test "a lyric file is renamed to match the track it belongs to" {
plan
[ "$(dst "$A/01. First.lrc")" = "$MUSIC/Artist One/Album X (2015)/01 - First Song.lrc" ]
}
@test "the album image, the rip log and the playlist follow the album folder" {
plan
[ "$(dst "$A/cover.jpg")" = "$MUSIC/Artist One/Album X (2015)/cover.jpg" ]
[ "$(dst "$A/rip.log")" = "$MUSIC/Artist One/Album X (2015)/rip.log" ]
[ "$(dst "$A/Album X.m3u")" = "$MUSIC/Artist One/Album X (2015)/Album X.m3u" ]
}
@test "a cover above the disc folders of one album follows that album" {
plan
[ "$(dst "$B/box.jpg")" = "$MUSIC/Various Artists/Best Of (2019)/box.jpg" ]
}
@test "an image carrying a whole album keeps its own name beside its cuesheet" {
plan
[ "$(dst "$C/Image.flac")" = "$MUSIC/Solo/Single Album (2001)/Image.flac" ]
[ "$(dst "$C/Image.cue")" = "$MUSIC/Solo/Single Album (2001)/Image.cue" ]
}
@test "an audio file no provider row names is parked, not filed under an artist" {
plan
[[ "$(dst "$D/loose.flac")" == "$PARK/Odd/loose.flac" ]]
}
@test "a video container in the music library is parked, never renamed to an audio one" {
plan
[[ "$(dst "$D/concert.mkv")" == "$PARK/Odd/concert.mkv" ]]
run awk -F '\t' '$1 == "move" && $3 ~ /\.mka$|\.m4a$/ {print $3}' "$OUT"
[ -z "$output" ]
}
@test "two tracks claiming one number and one title get two destinations" {
plan
[ "$(dst "$D/dup1.flac")" = "$MUSIC/Dup Artist/Dupes/01 - Same.flac" ]
[ "$(dst "$D/dup2.flac")" = "$MUSIC/Dup Artist/Dupes/01 - Same (2).flac" ]
}
@test "an album with no year is named without one, a track with no number likewise" {
plan
[ "$(dst "$D/unnumbered.flac")" = "$MUSIC/No Number/No Number Album/Untitled.flac" ]
}
@test "a track naming only a performer files under that performer" {
plan
[ "$(dst "$D/lone.flac")" = "$MUSIC/Session Singer/Session Works/01 - Lone.flac" ]
}
@test "a sidecar in a folder holding two albums parks instead of guessing" {
plan
[[ "$(dst "$E/notes.pdf")" == "$PARK/Mixed/notes.pdf" ]]
}
@test "the plan creates every folder before the move that needs it" {
plan
python3 - "$OUT" "$MUSIC" <<'PY'
import os, sys
made = set()
for line in open(sys.argv[1]).read().splitlines()[1:]:
action, _src, dest, _kind = line.split("\t")
if action == "mkdir":
made.add(dest)
elif action == "move":
parent = os.path.dirname(dest)
while parent and parent != sys.argv[2]:
assert parent in made, f"{dest} needs {parent}, which is never made"
parent = os.path.dirname(parent)
PY
}
@test "no move lands outside the music library" {
plan
run awk -F '\t' -v l="$MUSIC" \
'$1 == "move" && $3 !~ "^" l "/" {print $3}' "$OUT"
[ -z "$output" ]
}
@test "the executor preflights the plan and no target is claimed twice" {
plan
run python3 "$APPLY" apply --plan "$OUT" --root "$BATS_TEST_TMPDIR" \
--journal "$BATS_TEST_TMPDIR/journal.jsonl"
[ "$status" -eq 0 ]
dup=$(awk -F '\t' '$1 == "move" {print $3}' "$OUT" | sort | uniq -d)
[ -z "$dup" ]
}
@test "re-planning a library the plan already satisfies moves nothing" {
plan
# Applying the plan and re-censusing is what the operator does next: both
# authority files are keyed by path, so both are replayed through the plan
# the way a fresh census of the moved tree would see them. (The enum has no
# header; the rows export does, and its header row is not a path.)
python3 - "$OUT" "$ENUM" "$ROWS" <<'PY'
import os, sys
plan = sys.argv[1]
moves = {}
for line in open(plan).read().splitlines()[1:]:
action, src, dest, _kind = line.split("\t")
if action == "move":
moves[src] = dest
os.makedirs(os.path.dirname(dest), exist_ok=True)
os.rename(src, dest)
for path, col in ((sys.argv[2], 2), (sys.argv[3], 0)):
rows = [line.rstrip("\n").split("\t") for line in open(path)]
with open(path, "w") as fh:
for f in rows:
f[col] = moves.get(f[col], f[col])
fh.write("\t".join(f) + "\n")
PY
plan
run awk -F '\t' '$1 == "move" {print $2 " -> " $3}' "$OUT"
[ -z "$output" ] || { echo "moved again: $output"; false; }
}