#!/usr/bin/env bats # Seam: the audiobook-library relayout — scripts/media-census.py audiobooks (the # export of the rows Plex matched) and scripts/media-audiobooks.py (the plan). # # The audiobook tree is the one library on this box that Jellyfin does not read # at all and Plex reads completely: every chapter here is a Plex Track row under # an Album row (the BOOK) under one Artist row (the NARRATOR). That is the # authority this seam files by — never the collection folder a release pack made. # # Two laws here are the opposite of the music tool's, and both come from what the # rows actually contain. Files are NEVER renumbered: 7,556 of 10,485 books carry # at least one chapter whose index Plex does not know, so a `00 -` invented from # a missing index would order a book wrong, and playback order comes from the # embedded track tag either way — the name buys nothing and can only lie. And the # folder artist is the NARRATOR even though a reader thinks in authors: every # book has exactly one artist row, while the author survives only in 10% of the # album titles (`Kryptos by Graham McNeill`) and in no column Plex stores, so one # rule beats a shelf that is half authors and half narrators. # # Every test states which authority a file has and asserts where the PLAN files # it, never how the planner decided. load helpers PLAN="$REPO_ROOT/scripts/media-audiobooks.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 NARRATOR BOOK YEAR TRACK CHAPTER # One row of the authority export scripts/media-census.py audiobooks writes. row() { printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ "$1" "$2" "$3" "${4:-}" "${5:-}" "$6" >> "$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" } # kind SRC — the kind the plan gave SRC's move row. kind() { awk -F '\t' -v s="$1" '$1 == "move" && $2 == s {print $4}' "$OUT" } setup() { AB="$BATS_TEST_TMPDIR/Audiobooks" PARK="$AB/_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" # The header is part of the authority export, so the fixture carries it. printf 'file\tnarrator\tbook\tyear\ttrack\tchapter\n' > "$ROWS" # One book of one author, split across the two part folders a pack shipped it # in, with a cover above part 1 and a playlist beside the chapters. A="$AB/Stephen King/It (1986)" at "$A [Part 1]/01 - Chapter 1.mp3" 9000 at "$A [Part 1]/02 - Chapter 2.mp3" 9000 at "$A [Part 1]/cover.jpg" 20 at "$A [Part 1]/It.m3u" 30 at "$A [Part 2]/03 - Chapter 3.mp3" 9000 row "$A [Part 1]/01 - Chapter 1.mp3" "George Guidall" "It" 1986 1 "Chapter 1" row "$A [Part 1]/02 - Chapter 2.mp3" "George Guidall" "It" 1986 2 "Chapter 2" row "$A [Part 2]/03 - Chapter 3.mp3" "George Guidall" "It" 1986 3 "Chapter 3" # A book whose album row carries no year at all. B="$AB/Collected/Project Gold" at "$B/01.ogg" 9000 row "$B/01.ogg" "Operator X" "Project Gold" "" 1 "Opening" # One book title, two narrators: two different books. C="$AB/Shared" at "$C/Dune side a.m4b" 9000 at "$C/Dune side b.m4b" 9000 at "$C/Dune 2020.m4b" 9000 row "$C/Dune side a.m4b" "Scott Brick" "Dune" 1965 1 "Prologue" row "$C/Dune side b.m4b" "Scott Brick" "Dune" 1965 2 "Dune" row "$C/Dune 2020.m4b" "Malcolm Hillgartner" "Dune" 2020 1 "Book One" # Two part folders of one book whose chapters share a file name. D="$AB/Pulp" at "$D/Book A/vol1.mp3" 9000 at "$D/Book A vol2/vol1.mp3" 9000 row "$D/Book A/vol1.mp3" "Nora Pace" "Hard Food" 2001 1 "Vol 1" row "$D/Book A vol2/vol1.mp3" "Nora Pace" "Hard Food" 2001 2 "Vol 1" # The same narrator spelled two ways across one book's chapters. E="$AB/Spell" at "$E/kharn 1.mp3" 9000 at "$E/kharn 2.mp3" 9000 at "$E/kharn 3.mp3" 9000 row "$E/kharn 1.mp3" "David Timson" "Kharn" 2013 1 "One" row "$E/kharn 2.mp3" "David timson" "Kharn" 2013 2 "Two" row "$E/kharn 3.mp3" "David Timson" "Kharn" 2013 3 "Three" # A folder holding two different books: its loose note has no owner. F="$AB/Mixed" at "$F/book one chapter.mp3" 9000 at "$F/book two chapter.mp3" 9000 at "$F/notes.txt" 10 row "$F/book one chapter.mp3" "Ann Reader" "Book One" 2001 1 "One" row "$F/book two chapter.mp3" "Bob Reader" "Book Two" 2002 1 "Two" # A file already parked: the pile is for a human, so a row that would file it # must not pull it back into the tree. at "$PARK/Old/nobody.mp3" 9000 row "$PARK/Old/nobody.mp3" "Ann Reader" "Book One" 2001 1 "One" # The odd cases: no row, a video container, a name carrying a character Plex # says breaks playback, rows with a blank book and a blank narrator, and a # matched chapter whose file has no extension. G="$AB/Odd" at "$G/loose.mp3" 9000 at "$G/concert.mp4" 9000 at "$G/Bad: Name/01 chapter.mp3" 9000 row "$G/Bad: Name/01 chapter.mp3" "Some Reader" "Trail: Marked" 1999 1 "Marked" at "$G/blank book.mp3" 9000 row "$G/blank book.mp3" "Some Reader" "" 2000 1 "No Book" at "$G/blank narrator.mp3" 9000 row "$G/blank narrator.mp3" "" "No Artist" 2000 1 "No Artist" at "$G/NoExt/part01" 9000 row "$G/NoExt/part01" "Archivist" "Tapes" 1971 1 "Tape One" # Tag-scan placeholders. Two unrelated books whose album rows both read # `[Unknown Album]`: the name is shared, so filing on it would merge books # that have nothing to do with each other. A rip timestamp can be glued to # the placeholder, and a multi-cast read says `Various Artists` — which IS a # narrator. H="$AB/Packs" at "$H/Stross/Ship/c1.mp3" 9000 at "$H/Leckie/State/c1.mp3" 9000 at "$H/Rip/tape.mp3" 9000 at "$H/NoWho/Real Book.mp3" 9000 at "$H/Multicast/Genesis.mp3" 9000 row "$H/Stross/Ship/c1.mp3" "Various Artists" "[Unknown Album]" "" 1 "Part 1" row "$H/Leckie/State/c1.mp3" "Various Artists" "[Unknown Album]" "" 1 "Part 1" row "$H/Rip/tape.mp3" "Ann Reader" "Unknown album (03/03/2015 21:39:55)" "" 1 "One" row "$H/NoWho/Real Book.mp3" "[Unknown Artist]" "A Named Book" 2001 1 "One" row "$H/Multicast/Genesis.mp3" "Various Artists" "The Bible" "" 1 "Genesis" # The enum TSV is what scripts/media-census.py enumerate writes: bytes, ext, # path, no header. python3 - "$AB" "$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 "$AB" --enum "$ENUM" --rows "$ROWS" \ --out "$OUT" --exceptions "$EXC" if [ "$status" -ne 0 ]; then echo "planner said: $output" return 1 fi } # db_with_part DB FILE NARRATOR BOOK YEAR TRACK CHAPTER — one Plex Track row # under an Album under an Artist, in the three tables the export joins. db_with_part() { local db="$1" file="$2" narrator="$3" book="$4" year="$5" track="$6" chapter="$7" local del="${8:-0}" python3 - "$db" "$file" "$narrator" "$book" "$year" "$track" "$chapter" "$del" <<'PY' import sqlite3, sys db, file, narrator, book, year, track, chapter, deleted = sys.argv[1:9] c = sqlite3.connect(db) c.execute("create table if not exists media_parts (id integer primary key," " media_item_id, file, deleted_at)") c.execute("create table if not exists media_items (id integer primary key," " metadata_item_id, deleted_at)") c.execute("""create table if not exists metadata_items (id integer primary key, parent_id, metadata_type, title, year, "index", deleted_at)""") now = None if deleted == "0" else "2026-01-01" ar = c.execute("insert into metadata_items (parent_id, metadata_type, title, year," " \"index\", deleted_at) VALUES (NULL, 8, ?, NULL, NULL, NULL)", (narrator,)).lastrowid al = c.execute("insert into metadata_items (parent_id, metadata_type, title, year," " \"index\", deleted_at) VALUES (?, 9, ?, ?, NULL, NULL)", (ar, book, int(year) if year else None)).lastrowid mi = c.execute("insert into metadata_items (parent_id, metadata_type, title, year," " \"index\", deleted_at) VALUES (?, 10, ?, NULL, ?, NULL)", (al, chapter, int(track) if track else -1)).lastrowid it = c.execute("insert into media_items (metadata_item_id, deleted_at) VALUES (?, NULL)", (mi,)).lastrowid c.execute("insert into media_parts (media_item_id, file, deleted_at) VALUES (?,?,?)", (it, file, now)) c.commit() PY } # ------------------------------------------------------------------ the export @test "the census exports the narrator, book and chapter Plex matched for a part" { DB="$BATS_TEST_TMPDIR/plex.db" db_with_part "$DB" "$BATS_TEST_TMPDIR/Audiobooks/Pack/It part 1/01.mp3" \ "George Guidall" "It" 1986 1 "Chapter 1" run python3 "$CENSUS" audiobooks --root "$BATS_TEST_TMPDIR/Audiobooks" --db "$DB" \ --out "$BATS_TEST_TMPDIR/exp.tsv" [ "$status" -eq 0 ] run head -1 "$BATS_TEST_TMPDIR/exp.tsv" [ "${lines[0]}" = "file narrator book year track chapter" ] tail -1 "$BATS_TEST_TMPDIR/exp.tsv" \ | grep -q "^$BATS_TEST_TMPDIR/Audiobooks/Pack/It part 1/01\.mp3 George Guidall It 1986 1 Chapter 1$" } @test "the census exports nothing outside the root, and nothing Plex deleted" { DB="$BATS_TEST_TMPDIR/plex.db" db_with_part "$DB" "/mnt/other/Library/01.mp3" "Someone" "Elsewhere" 1990 1 "One" db_with_part "$DB" "$BATS_TEST_TMPDIR/Audiobooks/Gone/01.mp3" "Someone" \ "Deleted" 1990 1 "One" 1 run python3 "$CENSUS" audiobooks --root "$BATS_TEST_TMPDIR/Audiobooks" --db "$DB" \ --out "$BATS_TEST_TMPDIR/exp.tsv" [ "$status" -eq 0 ] [ "$(wc -l < "$BATS_TEST_TMPDIR/exp.tsv")" -eq 1 ] # header only } # -------------------------------------------------------------------- the plan @test "a matched chapter files under its narrator and its book, name untouched" { plan [ "$(dst "$A [Part 1]/01 - Chapter 1.mp3")" = \ "$AB/George Guidall/It (1986)/01 - Chapter 1.mp3" ] } @test "one book arrives in one folder however many part folders it shipped in" { plan [ "$(dst "$A [Part 2]/03 - Chapter 3.mp3")" = \ "$AB/George Guidall/It (1986)/03 - Chapter 3.mp3" ] [ "$(dst "$A [Part 1]/02 - Chapter 2.mp3")" = \ "$AB/George Guidall/It (1986)/02 - Chapter 2.mp3" ] } @test "a chapter is never renumbered, whether or not Plex knows its index" { # The law this guards: playback order comes from the embedded track tag, and # three quarters of the books have chapters with no index at all, so a # number written into a name would be invented and would lie. plan [ "$(dst "$B/01.ogg")" = "$AB/Operator X/Project Gold/01.ogg" ] [ "$(dst "$A [Part 1]/01 - Chapter 1.mp3")" = \ "$AB/George Guidall/It (1986)/01 - Chapter 1.mp3" ] } @test "a book whose album row has no year gets no parentheses" { plan [ "$(dst "$B/01.ogg")" = "$AB/Operator X/Project Gold/01.ogg" ] } @test "two chapters claiming one name inside one book are named apart" { plan first="$(dst "$D/Book A/vol1.mp3")" second="$(dst "$D/Book A vol2/vol1.mp3")" [ "$first" != "$second" ] [ -n "$first" ] && [ -n "$second" ] # and the plan still passes the executor's preflight, which refuses a target # two sources claim. run python3 "$APPLY" apply --plan "$OUT" --root "$AB" [ "$status" -eq 0 ] [[ "$output" != *"double-claim"* ]] } @test "a cover follows the book whose folder it lies in" { plan [ "$(dst "$A [Part 1]/cover.jpg")" = "$AB/George Guidall/It (1986)/cover.jpg" ] } @test "a playlist moves with its book and its bytes are never rewritten" { plan [ "$(dst "$A [Part 1]/It.m3u")" = "$AB/George Guidall/It (1986)/It.m3u" ] before=$(md5sum "$A [Part 1]/It.m3u" | cut -d" " -f1) run python3 "$APPLY" apply --plan "$OUT" --root "$AB" --journal "$BATS_TEST_TMPDIR/j.jsonl" --apply [ "$status" -eq 0 ] [ "$(md5sum "$AB/George Guidall/It (1986)/It.m3u" | cut -d" " -f1)" = "$before" ] # the stored paths inside it still name the old file names, which the tool # leaves alone rather than editing a file the operator owns. [ "$(kind "$A [Part 1]/It.m3u")" != "chapter" ] } @test "a note in a folder holding two books has no owner and parks" { plan [ "$(kind "$F/notes.txt")" = "park" ] [ "$(dst "$F/notes.txt")" = "$PARK/Mixed/notes.txt" ] } @test "an audio file no row names parks with the path it has today" { plan [ "$(kind "$G/loose.mp3")" = "park" ] [ "$(dst "$G/loose.mp3")" = "$PARK/Odd/loose.mp3" ] } @test "a video container parks instead of being renamed into audio" { plan [ "$(kind "$G/concert.mp4")" = "park" ] run grep -c "concert\.mp4" "$EXC" [ "$output" != "0" ] } @test "a character Plex says breaks playback never reaches a destination" { plan [ "$(dst "$G/Bad: Name/01 chapter.mp3")" = \ "$AB/Some Reader/Trail Marked (1999)/01 chapter.mp3" ] run cut -f3 "$OUT" [[ "$output" != *":"* ]] } @test "one book title with two narrators stays two books" { plan [ "$(dst "$C/Dune side a.m4b")" = "$AB/Scott Brick/Dune (1965)/Dune side a.m4b" ] [ "$(dst "$C/Dune 2020.m4b")" = \ "$AB/Malcolm Hillgartner/Dune (2020)/Dune 2020.m4b" ] } @test "the spelling most chapters carry names the folder, and they share one folder" { plan [ "$(dst "$E/kharn 1.mp3")" = "$AB/David Timson/Kharn (2013)/kharn 1.mp3" ] [ "$(dst "$E/kharn 2.mp3")" = "$AB/David Timson/Kharn (2013)/kharn 2.mp3" ] [ "$(dst "$E/kharn 3.mp3")" = "$AB/David Timson/Kharn (2013)/kharn 3.mp3" ] } @test "a row with no book title parks rather than inventing a folder" { plan [ "$(kind "$G/blank book.mp3")" = "park" ] } @test "a row with no narrator parks rather than inventing a shelf" { plan [ "$(kind "$G/blank narrator.mp3")" = "park" ] } @test "books that share a placeholder album name never share a shelf" { plan [ "$(dst "$H/Stross/Ship/c1.mp3")" = "$PARK/Packs/Stross/Ship/c1.mp3" ] [ "$(dst "$H/Leckie/State/c1.mp3")" = "$PARK/Packs/Leckie/State/c1.mp3" ] } @test "a placeholder with a rip timestamp glued to it is still a placeholder" { plan [ "$(kind "$H/Rip/tape.mp3")" = "park" ] } @test "a narrator placeholder parks even when the book is named" { plan [ "$(kind "$H/NoWho/Real Book.mp3")" = "park" ] } @test "Various Artists is a multi-cast narrator and files its book" { plan [ "$(dst "$H/Multicast/Genesis.mp3")" = \ "$AB/Various Artists/The Bible/Genesis.mp3" ] } @test "the row, not the extension, decides what a chapter is" { plan [ "$(kind "$G/NoExt/part01")" = "chapter" ] [ "$(dst "$G/NoExt/part01")" = "$AB/Archivist/Tapes (1971)/part01" ] } @test "the plan replays through the executor with nothing refused" { plan run python3 "$APPLY" apply --plan "$OUT" --root "$AB" \ --journal "$BATS_TEST_TMPDIR/journal.jsonl" [ "$status" -eq 0 ] [[ "$output" != *"refusing"* ]] # The refusal this guards is two sources claiming one target: the executor # stops the whole run on it, so the plan must never hand a path out twice. [ -z "$(awk -F '\t' '$1 == "move" {print $3}' "$OUT" | sort | uniq -d)" ] } @test "an occupied destination is refused rather than overwritten" { plan mkdir -p "$AB/George Guidall/It (1986)" at "$AB/George Guidall/It (1986)/01 - Chapter 1.mp3" 1 run python3 "$APPLY" apply --plan "$OUT" --root "$AB" [ "$status" -ne 0 ] [[ "$output" == *"occupied"* ]] [ -e "$A [Part 1]/01 - Chapter 1.mp3" ] } @test "applying the plan and re-planning moves nothing" { plan run python3 "$APPLY" apply --plan "$OUT" --root "$AB" \ --journal "$BATS_TEST_TMPDIR/j.jsonl" --apply [ "$status" -eq 0 ] python3 - "$AB" "$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 # The rows still name the paths the chapters were placed at: that is what a # server re-scan of the moved tree reports. python3 - "$OUT" "$ROWS" <<'PY' import csv, sys out, rows = sys.argv[1], sys.argv[2] moved = {r[1]: r[2] for r in csv.reader(open(out), delimiter="\t") if r[0] == "move"} with open(rows) as fh, open(rows + ".2", "w") as fh2: fh2.write(fh.readline().rstrip("\n") + "\n") for line in fh: f = line.split("\t")[0] fh2.write("\t".join([moved.get(f, f)] + line.rstrip("\n").split("\t")[1:]) + "\n") PY run python3 "$PLAN" --root "$AB" --enum "$ENUM" --rows "$ROWS.2" \ --out "$OUT.2" --exceptions "$EXC.2" [ "$status" -eq 0 ] [ "$(awk -F '\t' '$1 == "move"' "$OUT.2" | wc -l)" -eq 0 ] } @test "nothing under the park root is planned again" { plan run awk -F '\t' '$1 == "move" && $2 ~ /_unsorted/' "$OUT" [ -z "$output" ] }