The audiobook tree is the one library Jellyfin does not read at all and Plex reads completely: 58,975 chapters matched into Artist/Album/Track, where the album is the BOOK and the book's single artist row is the NARRATOR. That is the authority the new planner files by, never the collection folder a pack made. Audiobooks/<Narrator>/<Book> (<year>)/<the file's own name> Two laws are the opposite of the music tool's, both forced by the rows: files are never renumbered (72% of the books carry chapters with no usable index, and playback order comes from the embedded track tag either way, so an invented number can only lie), and the shelf is the narrator, not the author (every book has exactly one artist row; the author survives in 10% of album titles and in no column Plex stores, so one rule beats a shelf half authors and half narrators). media-census.py gains the audiobooks export: the chapters Plex matched under a root, host paths straight through, nothing outside the root and nothing Plex deleted. 22 tests in tests/media_audiobooks.bats pin the export's join and the plan's output.
386 lines
16 KiB
Bash
Executable File
386 lines
16 KiB
Bash
Executable File
#!/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"
|
|
|
|
# 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 "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" ]
|
|
}
|