#!/usr/bin/env python3 r"""media-audiobooks.py — a plan that re-files the audiobook library into one folder per book. media-audiobooks.py --root DIR --enum TSV --rows TSV [--out TSV] [--exceptions FILE] Plex reads this library and Jellyfin does not: its music scanner has no audiobook concept, and the tree is indexed entirely by Plex, which matched 58,975 of the 62,879 files here into its Artist/Album/Track shape. So the authority is `scripts/media-census.py audiobooks` — the BOOK (album row), its NARRATOR (that book's single artist row) and its CHAPTERS (track rows) — and never the folder a release pack made. The plan is: Audiobooks// ()/. ... the same folder, " (2)" on the name, when two chapters of one book carry the same name What that buys is the one property both media servers' book scanners assume: a folder holds exactly one book. Today 212 top-level folders are a mix of author-named shelves and collection packs, and the books inside the packs are split across `… [Part 1]`, `… [Part 2]` folders three or four deep. Three laws here are the opposite of what a music shelf does, and each is forced by what the rows actually hold: * A chapter is NEVER renumbered. 7,556 of the 10,485 books contain at least one chapter whose index Plex does not know, so a number written into a name would be invented — and it would buy nothing, because both servers order playback by the embedded track tag, not by the file name. A wrong number in a name is worse than none: it is the number a human later trusts. A file already carrying its own number keeps it, untouched. * The shelf is the NARRATOR, not the author. Every book has exactly one artist row and it is the narrator; the author survives only inside 10% of the album titles (`Kryptos by Graham McNeill`) and in no column Plex stores, so deriving the author would mean parsing titles for one book in ten and inventing it for the 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 — 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. Nothing is deleted and nothing is overwritten. The output is a list of rename(2) intents for `scripts/media-apply.py`, whose preflight refuses any target that is already claimed and whose journal is the rollback. """ import argparse import csv import os import re import sys from collections import Counter, defaultdict # What a chapter file can be. .m4b is the audiobook container; the rest are the # audio a pack ships when it was ripped as music. AUDIO_EXT = {".mp3", ".m4a", ".m4b", ".ogg", ".oga", ".opus", ".wav", ".flac", ".wma", ".aiff", ".aif", ".m4v", ".aac", ".mka"} # Never an audiobook, whatever the library it sits in says: Plex reads these as # video, and renaming one into an audio extension would claim it is a chapter. 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('<>:"/\\|?*') TRASH_DIR = re.compile(r"(^|/)(\.AGENTTRASH|\.thumbnails|\.Trash-\d+|@eaDir" r"|lost\+found)(/|$)") ARGS = None HELD = {} # path -> (bytes, ext) ROWS = {} # path -> the chapter row Plex matched PLACED = {} # chapter path -> the destination the plan gave it CLAIMED = set() # every destination path the plan has handed out MOVES, KEEPS = [], [] PARKED = defaultdict(list) # reason -> [path] NOTES = defaultdict(list) # section -> [line] AUDIO_BY_DIR = defaultdict(set) # source dir -> chapter files a row names CHILD_OF = defaultdict(set) # dir -> the dirs directly below it holding chapters FOLDERS_SEEN = {} # (dir, depth) -> book folders placed below it SUBTREE_DEPTH = 2 # how far below a folder its chapters may sit BOOKS = {} # (narrator, book, year) -> the book group AMBIGUOUS = object() def dominant(values): """The spelling the rows mostly agree on: the most frequent value, ties broken by sort so the answer never depends on the order rows arrived in.""" counts = Counter(values) return min(counts.items(), key=lambda kv: (-kv[1], kv[0]))[0] def rel(path): """The path as seen from the library root.""" return os.path.relpath(path, ARGS.root) def safe(name): """A name every client can carry: no reserved character, nothing unprintable, no trailing dot or space, and short enough to rename.""" out = "".join(c for c in (name or "") if c not in BAD_CHARS and (c.isprintable() or c == " ")) out = re.sub(r"\s+", " ", out).strip().rstrip(". ") while out and len(out.encode("utf-8", "ignore")) > 200: out = out[:-4].rstrip() return out def assign(src, folder, name, ext, kind, note): """Book one file into `folder` under `name`, renamed if that exact destination is already taken — by another file in the plan, or by a file standing there today. Two files claiming one path is the fault that costs a re-run of a 60k plan, and so is a plan that only works if the renames happen in the right order: the executor refuses any target that is occupied, so an occupied path is never a destination, even when its occupant is scheduled to leave. A file already standing where the contract puts it is a keep, not a move.""" stem = safe(name) or "file" dst = os.path.join(folder, stem + ext) if dst == src: CLAIMED.add(dst.lower()) # it is standing on its own target KEEPS.append((src, dst, kind)) return dst # A file already in this folder under this name — or under the numbered # variant the collision rule gives it — keeps the name it has. Renumbering it # every run would turn the plan into a shuffle instead of a fixed point. base = os.path.splitext(os.path.basename(src))[0] if os.path.dirname(src) == folder and (base == stem or base.startswith(stem + " (")): CLAIMED.add(src.lower()) KEEPS.append((src, src, kind)) return src n = 1 while dst.lower() in CLAIMED: n += 1 dst = os.path.join(folder, f"{stem} ({n}){ext}") if n > 1: NOTES[note].append(rel(dst)) CLAIMED.add(dst.lower()) MOVES.append((src, dst, kind)) return dst def park(path, reason): """Hold a file where it is, under the park folder, keeping its relative path.""" PARKED[reason].append(path) dst = os.path.join(ARGS.park_dir, rel(path)) if dst != path: CLAIMED.add(dst.lower()) MOVES.append((path, dst, "park")) def under_park(path): """True for a file already parked: that pile is for a human to review, and re-parking it every run would only push it deeper.""" return path.startswith(ARGS.park_dir + os.sep) # ------------------------------------------------------------------------ books def group_books(): """Every matched chapter into one group per (narrator, book, year). The key is the identity Plex asserts in its own database, not the folder the file sits in: one book arrives as four part folders inside a collection pack, and two copies of one book in one pack are one book to Plex, whose album item IS that narrator, title and year. The year is part of the key so a 1965 recording and a 2020 recording that share a title stay two books. """ for path, r in sorted(ROWS.items()): if path not in HELD or under_park(path) or TRASH_DIR.search(path): continue 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 = identity(r["narrator"], PLACEHOLDER_NARRATOR) book = identity(r["book"], PLACEHOLDER_BOOK) if not book: 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 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], "members": [], "indexed": 0}) g["members"].append(path) g["narrators"].append(narrator) g["books"].append(book) if str(r["track"] or "").isdigit(): g["indexed"] += 1 def book_folder(g): """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(): """One folder per book; every chapter keeps the name it has today.""" 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] # The name the ripper wrote, unchanged: the track tag inside the file # is what orders playback, and 72% of the books here have chapters no # row numbers, so any number added here would be invented. PLACED[path] = assign(path, folder, base, ext, "chapter", "two chapters of one book carried the same name") # --------------------------------------------------------------------- sidecars def folders_below(d, depth): """The book folders the chapters in `d`, or within `depth` directories below it, were placed in. Memoised, because every loose file in a big collection folder asks the same question about it.""" if (d, depth) not in FOLDERS_SEEN: seen = {os.path.dirname(PLACED[a]) for a in AUDIO_BY_DIR.get(d, ()) if a in PLACED} if depth: for child in CHILD_OF.get(d, ()): seen |= folders_below(child, depth - 1) FOLDERS_SEEN[(d, depth)] = seen return FOLDERS_SEEN[(d, depth)] def owner_folder(d): """The book folder that owns this directory — the first place up the tree where chapters sit, in the directory itself or within SUBTREE_DEPTH levels below it, which is how a pack whose parts are `… [Part 1]`/`… [Part 2]` owns the cover art lying above them. None when no chapter is near enough to say, AMBIGUOUS when the chapters there belong to more than one book.""" while d.startswith(ARGS.root + os.sep) and d != ARGS.root: folders = folders_below(d, SUBTREE_DEPTH) if folders: return next(iter(folders)) if len(folders) == 1 else AMBIGUOUS d = os.path.dirname(d) return None def place_other(): """Everything that is not a chapter Plex matched: covers, booklets, EPUBs, logs, checksums, playlists — and the clutter that is none of those.""" for path, (_size, ext) in sorted(HELD.items()): if path in ROWS or under_park(path) or TRASH_DIR.search(path): continue d = os.path.dirname(path) base = os.path.splitext(os.path.basename(path))[0] if ext in VIDEO_EXT: park(path, "a video container inside the audiobook library: Plex reads " "it as video, and renaming it would claim it is a chapter") elif ext in AUDIO_EXT: park(path, "an audio file no chapter row names — it is not in the " "library Plex matched, so nothing says which book it is") else: owner = owner_folder(d) if owner is AMBIGUOUS: park(path, "a file whose folder holds more than one book, so " "nothing says which one it belongs to") elif owner: assign(path, owner, base, ext, "playlist" if ext in (".m3u", ".m3u8") else "sidecar", "one book folder would have carried two files of the same" " sidecar name") else: park(path, "not an audiobook file, and no book's folder lies with" " it to carry it") # ---------------------------------------------------------------------- outputs def write_plan(): """mkdir rows for every destination folder and its whole chain, then the moves. Parents first: the executor creates directories in the order they are listed.""" chain = set() for _s, dst, _k in MOVES: d = os.path.dirname(dst) while d and d != ARGS.root and d != os.path.dirname(d): chain.add(d) d = os.path.dirname(d) dirs = sorted(chain, key=lambda p: (p.count("/"), p)) with open(ARGS.out, "w") as fh: fh.write("action\tsrc\tdst\tkind\n") for d in dirs: fh.write(f"mkdir\t\t{d}\tdir\n") for src, dst, kind in sorted(MOVES, key=lambda m: (m[1], m[0])): fh.write(f"move\t{src}\t{dst}\t{kind}\n") for src, dst, kind in sorted(KEEPS, key=lambda m: (m[1], m[0])): fh.write(f"keep\t{src}\t{dst}\t{kind}\n") parked = [m for m in MOVES if m[2] == "park"] sized = sum(HELD[s][0] for s, _d, _k in MOVES if s in HELD) print(f"plan: {len(MOVES) - len(parked)} moves, {len(parked)} parked" f" ({sized / 2**40:.2f} TiB touched), {len(KEEPS)} already placed," f" {len(dirs)} folders -> {ARGS.out}") def write_exceptions(): with open(ARGS.exceptions, "w") as fh: fh.write(f"Audiobook relayout exceptions for {ARGS.root}\n\n") fh.write(f"Everything below is parked under {ARGS.park_dir}, keeping the\n" "relative path it has today. Nothing is deleted and nothing is\n" "overwritten: a parked file is one rename from wherever the plan is\n" "pointed next.\n\n") for reason in sorted(PARKED, key=lambda r: -len(PARKED[r])): rs = PARKED[reason] fh.write(f"{len(rs):>6} files {reason}\n") for p in sorted(rs, key=rel)[:400]: fh.write(f" {rel(p)}\n") if len(rs) > 400: fh.write(f" … and {len(rs) - 400} more\n") fh.write("\n") for section in sorted(NOTES, key=lambda s: -len(NOTES[s])): lines = sorted(set(NOTES[section])) fh.write(f"{len(lines):>6} {section}\n") for line in lines[:200]: fh.write(f" {line}\n") if len(lines) > 200: fh.write(f" … and {len(lines) - 200} more\n") fh.write("\n") unindexed = sorted(f"{safe(dominant(g['narrators']))}/{safe(dominant(g['books']))}" for g in BOOKS.values() if 0 < g["indexed"] < len(g["members"])) if unindexed: fh.write(f"{len(unindexed):>6} books have chapters Plex does not number. Their\n") fh.write(" files keep the names they carry: playback order comes from the\n" " track tag inside each file, and a number written here would be\n" " invented for the chapters with no index. Fix it in the tags, not\n" " in the names — Plex re-reads tags on a scan and never names files.\n\n") fh.write(" Playlists move with their book and their contents are never\n" " rewritten: the paths inside an .m3u go stale.\n") print(f"exceptions -> {ARGS.exceptions}") def main(): global ARGS ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--root", default=os.environ.get("MEDIA_AUDIOBOOKS_ROOT", "/mnt/entropy/Audiobooks")) ap.add_argument("--enum", default="/tmp/audiobooks-enum.tsv", help="the enumerate TSV (bytes, ext, path)") ap.add_argument("--rows", default="/tmp/plex-audiobooks.tsv", help="the chapter rows scripts/media-census.py audiobooks wrote") ap.add_argument("--out", default="/tmp/audiobooks-plan.tsv") ap.add_argument("--exceptions", default="/tmp/audiobooks-exceptions.txt") ap.add_argument("--park", default="_unsorted", help="the folder inside the root that holds what cannot be filed") ARGS = ap.parse_args() ARGS.root = os.path.normpath(ARGS.root) ARGS.park_dir = os.path.join(ARGS.root, ARGS.park) read_inputs() # Every path a file occupies today is claimed before a single destination is # handed out: the executor refuses an occupied target, so a plan that aimed at # one would only fail after the renames that were supposed to clear it. CLAIMED.update(p.lower() for p in HELD) group_books() place_books() place_other() write_plan() write_exceptions() def read_inputs(): with open(ARGS.enum) as fh: for line in fh: parts = line.rstrip("\n").split("\t") if len(parts) >= 3: HELD[parts[2]] = (int(parts[0]), parts[1].lower()) with open(ARGS.rows, newline="") as fh: for row in csv.DictReader(fh, delimiter="\t"): if row.get("file"): ROWS[row["file"]] = row gone = [p for p in ROWS if p not in HELD] if gone: NOTES["a chapter row whose file the census no longer sees"].extend(gone) if __name__ == "__main__": main()