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.
374 lines
15 KiB
Bash
Executable File
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; }
|
|
}
|