diff --git a/scripts/media-audiobooks.py b/scripts/media-audiobooks.py index 1556e38..bc7e040 100755 --- a/scripts/media-audiobooks.py +++ b/scripts/media-audiobooks.py @@ -35,7 +35,9 @@ by what the rows actually hold: rest. One rule that always resolves beats a shelf that is half author and half narrator; the author stays visible in the book folder's own name, which is the title Plex shows. - * Nothing is guessed at. A chapter row with no book or no narrator parks, an audio + * Nothing is guessed at. A chapter row with no book or no narrator parks — and so + does one whose names are a tag-scan placeholder (`[Unknown Album]`), which + unrelated books share: filing on it would merge them onto one shelf. An audio file Plex never matched parks, a video container parks, and a loose file in a folder holding two books parks: nothing says which book owns it. @@ -59,6 +61,31 @@ AUDIO_EXT = {".mp3", ".m4a", ".m4b", ".ogg", ".oga", ".opus", ".wav", ".flac", VIDEO_EXT = {".mkv", ".mp4", ".webm", ".avi", ".mov", ".mpg", ".mpeg", ".ts", ".m2ts", ".vob", ".ifo", ".wmv", ".iso", ".img", ".bdmv", ".ogm", ".divx"} + +# Tag-scan placeholders. These strings are not identities: 1,106 chapters from +# 14 unrelated top-level folders carry the album `[Unknown Album]`, so filing on +# them would merge books that have nothing to do with each other — the exact +# shape this relayout exists to make impossible. Such a chapter parks with the +# path it has today and the fix belongs in its tags. `Various Artists` is NOT a +# placeholder: that is what a multi-cast read carries, and the one book here +# with 1,189 chapters under it is genuinely one book. +PLACEHOLDER_BOOK = {"[unknown album]", "unknown album", "no title", "untitled", + "[untitled]", "unknown title", "n/a", "-"} +PLACEHOLDER_NARRATOR = {"[unknown artist]", "unknown artist", "unknown author", + "", "[unknown]", "unknown"} + + +def identity(value, placeholders): + """The tag value Plex matched, when it names something; '' when it does not. + + A trailing parenthesized group is stripped for the test only, because rippers + glue timestamps to the placeholder (`Unknown album (03/03/2015 21:39:55)`). + """ + v = (value or "").strip() + bare = re.sub(r"\s*\([^()]*\)\s*$", "", v).strip().casefold() + return "" if bare in placeholders else v + + # The characters Plex documents as breaking playback. They are dropped, not # substituted: a replacement character is a guess at what the title meant. BAD_CHARS = set('<>:"/\\|?*') @@ -170,15 +197,17 @@ def group_books(): d = os.path.dirname(path) AUDIO_BY_DIR[d].add(path) CHILD_OF[os.path.dirname(d)].add(d) # so a cover above Part 1/Part 2 sees both - narrator = (r["narrator"] or "").strip() - book = (r["book"] or "").strip() + narrator = identity(r["narrator"], PLACEHOLDER_NARRATOR) + book = identity(r["book"], PLACEHOLDER_BOOK) if not book: - park(path, "a chapter row no book names — Plex has the file but no " - "album item to put it in") + park(path, "a chapter row that names no book — Plex's album title is a " + "placeholder like [Unknown Album], so filing on it would " + "merge unrelated books onto one shelf") continue if not narrator: - park(path, "a chapter row no narrator names — the book has no artist " - "row, so no shelf owns it") + park(path, "a chapter row that names no narrator — the album's artist " + "row is a placeholder like [Unknown Artist], so no shelf " + "owns it") continue key = (narrator.casefold(), book.casefold(), (r["year"] or "").strip()) g = BOOKS.setdefault(key, {"narrators": [], "books": [], "year": key[2], @@ -191,9 +220,15 @@ def group_books(): def book_folder(g): - name = safe(dominant(g["books"])) or "Untitled" - return os.path.join(ARGS.root, safe(dominant(g["narrators"])) or "Unknown Narrator", - f"{name} ({g['year']})" if g["year"] else name) + """The shelf Plex's own rows name: / (). None when a + name is made of characters no path can hold — inventing a shelf is filing it + wrong, so those chapters park instead.""" + narrator = safe(dominant(g["narrators"])) + book = safe(dominant(g["books"])) + if not narrator or not book: + return None + return os.path.join(ARGS.root, narrator, + f"{book} ({g['year']})" if g["year"] else book) def place_books(): @@ -201,6 +236,11 @@ def place_books(): for key in sorted(BOOKS): g = BOOKS[key] folder = book_folder(g) + if folder is None: + for path in sorted(g["members"]): + park(path, "a book or narrator Plex spells with characters no " + "folder name can hold") + continue for path in sorted(g["members"]): ext = HELD[path][1] base = os.path.splitext(os.path.basename(path))[0] diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index c88aaf9..ae29bc5 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -336,8 +336,7 @@ The group is the identity Plex asserts in its own database — **(narrator, book year)** — so one book arriving in four part folders becomes one folder, and a 1965 and a 2020 recording that share a title stay two books. -Two laws here are the opposite of the music arm's, and both are forced by what -the rows actually hold: +Two laws are forced by what the rows actually hold: +- **A chapter is never renumbered.** Most books contain chapters whose index Plex does not know, so a number written into a name would be invented — and it would @@ -352,8 +351,10 @@ the rows actually hold: What it refuses, the music arm's list with one addition: a video container, audio no chapter row names, and a loose file in a folder holding two books all park under -`_unsorted` with the path they have today — and so does a chapter whose row has -**no book or no narrator**, because inventing a shelf is filing it wrong. +`_unsorted` with the path they have today — and so does a chapter whose row names +**no book or no narrator**, placeholder included (`[Unknown Album]`, `no title`, +`[Unknown Artist]`): unrelated books share those strings, so filing on one merges +them. `Various Artists` is not a placeholder — that is a multi-cast read. ## Laws diff --git a/tests/media_audiobooks.bats b/tests/media_audiobooks.bats index ca87048..d27b1b9 100755 --- a/tests/media_audiobooks.bats +++ b/tests/media_audiobooks.bats @@ -126,6 +126,23 @@ setup() { 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' @@ -319,6 +336,28 @@ PY [ "$(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" ]