fix(media): a tag-scan placeholder is not a book identity

The first real plan filed 1,106 chapters from 14 unrelated top-level folders
onto one shelf, because all 1,106 carry the album tag [Unknown Album] and the
same artist tag. Plex's album title is the authority for the shelf only when it
names a book; a placeholder names nothing, and unrelated books share it. Those
chapters now park with the path they have today, timestamp-glued variants
included; Various Artists stays a legitimate multi-cast narrator.
This commit is contained in:
dank committed 2026-10-31 00:31:30 -07:00
1 parent 4ae2bde9ab
commit f1b8b60817
3 files changed
+94 -14

No files matched your search

+50 -10
View File
@@ -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]", "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: <narrator>/<book> (<year>). 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]