From 65c137a71dfc307ef6280b71b221cdbf33a97f40 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 14:15:07 +0000 Subject: [PATCH] grouping: do not merge different books that both use 64k/ release, so two downloads that both used a bitrate folder were keyed as "64k" and hardlinked to the first match. Walk past BITRATE_FOLDER always. Filing also walks past codec/bitrate wrappers under a disc so cd1/MP3/64k/ and cd2/MP3/64k/ get distinct subfolders. Co-authored-by: Mancolt Co-authored-by: Cursor --- CHANGELOG.md | 7 +++++++ CONFIG.md | 2 +- myx_classes.py | 11 ++++------- myx_names.py | 36 ++++++++++++++++++++++++++++++------ tests/test_names.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afc4a13..c52abb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ first release is 3.0.0 because exit codes and the cookie store changed in ways a ## Unreleased +### Fixed +- Two releases that both use a bitrate folder (`Title/64k/`, `Title/MP3/128kbps/`) were grouped as one + book named `64k` and hardlinked to the first match. Grouping now walks past bitrate folders the same + way it already walks past `MP3/` and `cd1/`. `cd1/MP3/64k/01.mp3` and `cd2/MP3/64k/01.mp3` also get + distinct disc subfolders; they used to collide in a flat `Author/Title/` folder and the second disc + was skipped. + ## 3.0.2 - 2026-09-22 ### Fixed diff --git a/CONFIG.md b/CONFIG.md index a4fa994..04a3090 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -95,7 +95,7 @@ A copy of default_config.cfg can be found under the /templates folder. It is re | | in_series | Format of the generated tree for books in a series | {author}/{series}/{series} #{part} - {title} | | | in_series_no_part | Format for books in a series whose part number is unknown (a novella collection, a companion book): `in_series` would render `{part}` empty and leave `Series # - Title`. Set it to your `in_series` value to keep that | {author}/{series}/{series} - {title} | | | no_series | Format of the generated tree for books that are NOT in a series | {author}/{title} | -| | disc_folder | Format of the folder name for multi-disc books (files whose parent folder is `cd N`, `disc N`, `disk N` or `part N`, including a codec folder under those: `cd1/MP3/`) | {title} {disc} | +| | disc_folder | Format of the folder name for multi-disc books (files whose parent folder is `cd N`, `disc N`, `disk N` or `part N`, including a codec or bitrate folder under those: `cd1/MP3/`, `cd1/MP3/64k/`) | {title} {disc} | | tokens | | | | | | skip_series | Used when fixid3 is true and an alt Title is generated from the id3-series data | 0 | | | kw_ignore | Characters ignored when generating keywords for search | | [".", ":", "_", "[", "]", "{", "}", ",", ";", "(", ")"] | diff --git a/myx_classes.py b/myx_classes.py index 3e46bed..1fbc017 100644 --- a/myx_classes.py +++ b/myx_classes.py @@ -368,13 +368,10 @@ def getConfigTargetPath(self, cfg, book): else: narrator="" - #is this a MultiCd file? Walk past a codec folder under a disc (cd1/MP3/01.mp3) so - #two grouped discs with the same track names are not filed into one flat folder. - disc = self.getParentFolder() - if disc and myx_names.FORMAT_FOLDER.match(disc.strip()): - grand = os.path.basename(os.path.dirname(os.path.dirname(self.file or ""))) - if grand and myx_utilities.isMultiCD(grand): - disc = grand + #is this a MultiCd file? Walk past codec/bitrate wrappers under a disc + #(cd1/MP3/01.mp3, cd1/MP3/64k/01.mp3) so two grouped discs with the same + #track names are not filed into one flat folder. + disc = myx_names.discFolderFromPath(self.file) or self.getParentFolder() if (not myx_utilities.isMultiCD(disc)): disc = "" diff --git a/myx_names.py b/myx_names.py index d62ffd4..44df646 100644 --- a/myx_names.py +++ b/myx_names.py @@ -34,6 +34,16 @@ TITLE_TAIL = re.compile(r"[\s:,]+an?\s+(?:novel|thriller|mystery|memoir|novella)$", re.IGNORECASE) DISC_FOLDER = re.compile(r"^(cd|disc|disk|part)\s*\d+$", re.IGNORECASE) FORMAT_FOLDER = re.compile(r"^(mp3|m4b|m4a|flac|ogg|opus|aac|wma|mp4)$", re.IGNORECASE) +# torrent quality folders that sit under (or instead of) a codec folder: Title/64k/, Title/MP3/128kbps/ +BITRATE_FOLDER = re.compile(r"^\d{2,3}\s?k(?:bps)?$", re.IGNORECASE) + + +def isWrapperFolder(name): + """Codec or bitrate folder that is never the release (`MP3/`, `64k/`, `128 kbps/`).""" + name = (name or "").strip() + return bool(FORMAT_FOLDER.match(name) or BITRATE_FOLDER.match(name)) + + # series parts run 1..99 (with an optional .5); "Fahrenheit 451" / "Apollo 13"-style titles are not series SERIES_NUM = re.compile(r"^(?P[^\d,]+?)\s+(?:#\s*|book\s+|vol\.?\s*|volume\s+)?(?P\d{1,2}(?:\.\d)?)$", re.IGNORECASE) BOOK_N_TAIL = re.compile(r"^(?P.+?)[,\s]+(?:book|vol\.?|volume)\s+(?P\d{1,3}(?:\.\d)?)$", re.IGNORECASE) @@ -268,10 +278,10 @@ def authorScore(seg): def groupingName(fullPath, sourcePath, fallback): """The folder that identifies a book: the release under `sourcePath`, walking past cd/disc/disk/part N - parents and codec folders (`MP3/`, `M4B/`, `cd1/MP3/`). Two discs of one release become one book; - two releases that both use `cd1/` or both use `MP3/` stay separate. Author/Title layouts still key - on the immediate (non-disc, non-codec) parent. Falls back to `fallback` for a loose file or a path - outside the source.""" + parents, codec folders (`MP3/`, `M4B/`, `cd1/MP3/`) and bitrate folders (`64k/`, `MP3/128kbps/`). + Two discs of one release become one book; two releases that both use `cd1/`, `MP3/` or `64k/` stay + separate. Author/Title layouts still key on the immediate (non-wrapper) parent. Falls back to + `fallback` for a loose file or a path outside the source.""" try: rel = os.path.relpath(fullPath, sourcePath) if sourcePath and fullPath else "" except ValueError: @@ -283,13 +293,27 @@ def groupingName(fullPath, sourcePath, fallback): return fallback for i in range(len(parts) - 2, -1, -1): name = parts[i].strip() - # cd1/ itself, and a codec folder (Title/MP3/ or cd1/MP3/), are not the release - if DISC_FOLDER.match(name) or FORMAT_FOLDER.match(name): + # cd1/ itself, and a codec/bitrate folder (Title/MP3/, Title/64k/, cd1/MP3/64k/), are not the release + if DISC_FOLDER.match(name) or isWrapperFolder(name): continue return parts[i] return parts[0] +def discFolderFromPath(file_path): + """The disc folder a media file sits in, walking past codec/bitrate wrappers. + + `Title/cd1/MP3/64k/01.mp3` and `Title/cd2/MP3/64k/01.mp3` must return distinct discs (`cd1`, `cd2`); + otherwise both target the same flat folder and the second is skipped. A codec or bitrate folder + that is not under a disc (`Title/MP3/`, `Title/64k/`) is not a disc.""" + parts = [p for p in os.path.normpath(file_path or "").split(os.sep) if p and p != "."] + for name in reversed(parts[:-1]): + if isWrapperFolder(name): + continue + return name + return "" + + def releaseNameForBook(files, sourcePath, name): """The best name to parse for a book: the first path component under the source path (the release folder, or the loose file name). Falls back to `name` (booktree's grouping key).""" diff --git a/tests/test_names.py b/tests/test_names.py index 6ce5892..99afe4d 100644 --- a/tests/test_names.py +++ b/tests/test_names.py @@ -128,6 +128,13 @@ def test_grouping_name_walks_past_disc_folders_and_keeps_releases_apart(self): self.assertEqual(N.groupingName(f"{src}/The Guest/MP3/d1.mp3", src, "MP3"), "The Guest") self.assertEqual(N.groupingName(f"{src}/Along Came a Spider/MP3/d1.mp3", src, "MP3"), "Along Came a Spider") self.assertEqual(N.groupingName(f"{src}/The Guest/M4B/book.m4b", src, "M4B"), "The Guest") + # a bitrate folder is not the release (two books that both use 64k/ or MP3/64k/) + self.assertEqual(N.groupingName(f"{src}/The Guest/64k/d1.mp3", src, "64k"), "The Guest") + self.assertEqual(N.groupingName(f"{src}/Along Came a Spider/64k/d1.mp3", src, "64k"), "Along Came a Spider") + self.assertEqual(N.groupingName(f"{src}/The Guest/128kbps/d1.mp3", src, "128kbps"), "The Guest") + self.assertEqual(N.groupingName(f"{src}/The Guest/MP3/64k/d1.mp3", src, "64k"), "The Guest") + self.assertEqual(N.groupingName(f"{src}/Along Came a Spider/MP3/64k/d1.mp3", src, "64k"), "Along Came a Spider") + self.assertEqual(N.groupingName(f"{src}/The Guest/cd1/MP3/64k/d1.mp3", src, "64k"), "The Guest") # a title folder under cd1/ is the release, not the ancestor above the disc self.assertEqual( N.groupingName(f"{src}/Patterson/cd1/Along Came a Spider/a.m4b", src, "Along Came a Spider"), @@ -252,6 +259,18 @@ def test_two_releases_with_mp3_are_not_the_same_book(self): self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/cd1/MP3/d1.mp3")), "The Guest") self.assertEqual(booktree.bookGroupingKey(self._bf("Patterson/The Guest/MP3/book.m4b")), "The Guest") + def test_two_releases_with_64k_are_not_the_same_book(self): + import booktree + guest = self._bf("The Guest/64k/d1.mp3") + spider = self._bf("Along Came a Spider/64k/d1.mp3") + self.assertEqual(booktree.bookGroupingKey(guest), "The Guest") + self.assertEqual(booktree.bookGroupingKey(spider), "Along Came a Spider") + self.assertNotEqual(booktree.bookGroupingKey(guest), booktree.bookGroupingKey(spider)) + self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/MP3/64k/d1.mp3")), "The Guest") + self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/cd1/MP3/64k/d1.mp3")), "The Guest") + self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/cd2/MP3/64k/d2.mp3")), "The Guest") + self.assertEqual(booktree.bookGroupingKey(self._bf("Patterson/The Guest/128 kbps/book.mp3")), "The Guest") + def test_normal_and_author_title_layouts_and_multibook_are_unchanged(self): import booktree self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/book.m4b")), "The Guest") @@ -350,6 +369,29 @@ def test_codec_folder_under_a_disc_uses_the_disc_not_mp3(self): bf = myx_classes.BookFile("Title/MP3/01.mp3", "/dl/Title/MP3/01.mp3", "/dl", "/lib") self.assertEqual(bf.getConfigTargetPath(cfg, book), "/lib/Author/Title") + def test_bitrate_folder_under_a_disc_uses_the_disc_not_64k(self): + # grouping walks past cd1/MP3/64k/; filing must too or both discs target Author/Title/01.mp3 + import myx_classes + book = myx_classes.Book(asin="B000000001", title="Title") + book.authors = [myx_classes.Contributor("Author")] + cfg = FakeConfig("/tmp", **{"Config/target_path/no_series": "{author}/{title}", + "Config/target_path/disc_folder": "{title} {disc}"}) + targets = set() + for disc in ("cd1/MP3/64k", "cd2/MP3/64k", "Disc 1/128kbps", "disk 2/64 kbps"): + bf = myx_classes.BookFile(f"Title/{disc}/01.mp3", f"/dl/Title/{disc}/01.mp3", "/dl", "/lib") + targets.add(bf.getConfigTargetPath(cfg, book)) + self.assertEqual(targets, { + "/lib/Author/Title/Title cd1", + "/lib/Author/Title/Title cd2", + "/lib/Author/Title/Title Disc 1", + "/lib/Author/Title/Title disk 2", + }) + # Title/64k/ is a bitrate folder, not a disc: no extra subfolder + bf = myx_classes.BookFile("Title/64k/01.mp3", "/dl/Title/64k/01.mp3", "/dl", "/lib") + self.assertEqual(bf.getConfigTargetPath(cfg, book), "/lib/Author/Title") + bf = myx_classes.BookFile("Title/MP3/64k/01.mp3", "/dl/Title/MP3/64k/01.mp3", "/dl", "/lib") + self.assertEqual(bf.getConfigTargetPath(cfg, book), "/lib/Author/Title") + class InSeriesNoPartTest(unittest.TestCase):