feat(media): the audiobook relayout, filed by the book Plex matched #101
No files matched your search
+50
-10
@@ -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
|
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
|
narrator; the author stays visible in the book folder's own name, which is the
|
||||||
title Plex shows.
|
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
|
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.
|
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",
|
VIDEO_EXT = {".mkv", ".mp4", ".webm", ".avi", ".mov", ".mpg", ".mpeg", ".ts",
|
||||||
".m2ts", ".vob", ".ifo", ".wmv", ".iso", ".img", ".bdmv", ".ogm",
|
".m2ts", ".vob", ".ifo", ".wmv", ".iso", ".img", ".bdmv", ".ogm",
|
||||||
".divx"}
|
".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]", "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
|
# The characters Plex documents as breaking playback. They are dropped, not
|
||||||
# substituted: a replacement character is a guess at what the title meant.
|
# substituted: a replacement character is a guess at what the title meant.
|
||||||
BAD_CHARS = set('<>:"/\\|?*')
|
BAD_CHARS = set('<>:"/\\|?*')
|
||||||
@@ -170,15 +197,17 @@ def group_books():
|
|||||||
d = os.path.dirname(path)
|
d = os.path.dirname(path)
|
||||||
AUDIO_BY_DIR[d].add(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
|
CHILD_OF[os.path.dirname(d)].add(d) # so a cover above Part 1/Part 2 sees both
|
||||||
narrator = (r["narrator"] or "").strip()
|
narrator = identity(r["narrator"], PLACEHOLDER_NARRATOR)
|
||||||
book = (r["book"] or "").strip()
|
book = identity(r["book"], PLACEHOLDER_BOOK)
|
||||||
if not book:
|
if not book:
|
||||||
park(path, "a chapter row no book names — Plex has the file but no "
|
park(path, "a chapter row that names no book — Plex's album title is a "
|
||||||
"album item to put it in")
|
"placeholder like [Unknown Album], so filing on it would "
|
||||||
|
"merge unrelated books onto one shelf")
|
||||||
continue
|
continue
|
||||||
if not narrator:
|
if not narrator:
|
||||||
park(path, "a chapter row no narrator names — the book has no artist "
|
park(path, "a chapter row that names no narrator — the album's artist "
|
||||||
"row, so no shelf owns it")
|
"row is a placeholder like [Unknown Artist], so no shelf "
|
||||||
|
"owns it")
|
||||||
continue
|
continue
|
||||||
key = (narrator.casefold(), book.casefold(), (r["year"] or "").strip())
|
key = (narrator.casefold(), book.casefold(), (r["year"] or "").strip())
|
||||||
g = BOOKS.setdefault(key, {"narrators": [], "books": [], "year": key[2],
|
g = BOOKS.setdefault(key, {"narrators": [], "books": [], "year": key[2],
|
||||||
@@ -191,9 +220,15 @@ def group_books():
|
|||||||
|
|
||||||
|
|
||||||
def book_folder(g):
|
def book_folder(g):
|
||||||
name = safe(dominant(g["books"])) or "Untitled"
|
"""The shelf Plex's own rows name: <narrator>/<book> (<year>). None when a
|
||||||
return os.path.join(ARGS.root, safe(dominant(g["narrators"])) or "Unknown Narrator",
|
name is made of characters no path can hold — inventing a shelf is filing it
|
||||||
f"{name} ({g['year']})" if g["year"] else name)
|
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():
|
def place_books():
|
||||||
@@ -201,6 +236,11 @@ def place_books():
|
|||||||
for key in sorted(BOOKS):
|
for key in sorted(BOOKS):
|
||||||
g = BOOKS[key]
|
g = BOOKS[key]
|
||||||
folder = book_folder(g)
|
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"]):
|
for path in sorted(g["members"]):
|
||||||
ext = HELD[path][1]
|
ext = HELD[path][1]
|
||||||
base = os.path.splitext(os.path.basename(path))[0]
|
base = os.path.splitext(os.path.basename(path))[0]
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
Two laws are forced by what the rows actually hold:
|
||||||
the rows actually hold:
|
|
||||||
|
|
||||||
+- **A chapter is never renumbered.** Most books contain chapters whose index Plex
|
+- **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
|
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
|
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
|
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
|
`_unsorted` with the path they have today — and so does a chapter whose row names
|
||||||
**no book or no narrator**, because inventing a shelf is filing it wrong.
|
**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
|
## Laws
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,23 @@ setup() {
|
|||||||
at "$G/NoExt/part01" 9000
|
at "$G/NoExt/part01" 9000
|
||||||
row "$G/NoExt/part01" "Archivist" "Tapes" 1971 1 "Tape One"
|
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,
|
# The enum TSV is what scripts/media-census.py enumerate writes: bytes, ext,
|
||||||
# path, no header.
|
# path, no header.
|
||||||
python3 - "$AB" "$ENUM" <<'PY'
|
python3 - "$AB" "$ENUM" <<'PY'
|
||||||
@@ -319,6 +336,28 @@ PY
|
|||||||
[ "$(kind "$G/blank narrator.mp3")" = "park" ]
|
[ "$(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" {
|
@test "the row, not the extension, decides what a chapter is" {
|
||||||
plan
|
plan
|
||||||
[ "$(kind "$G/NoExt/part01")" = "chapter" ]
|
[ "$(kind "$G/NoExt/part01")" = "chapter" ]
|
||||||
|
|||||||
Reference in new issue
Block a user