#!/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; } }