From b2e7e9067b7eb81ede09c2022e8ca1ef2d121529 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 14:21:39 +0000 Subject: [PATCH] grouping: do not merge different books that both use MP3/ #15 walked past cd1/MP3/ but treated Title/MP3/ as the release, so two downloads that both used a codec folder were keyed as "MP3" and hardlinked to the first match. Walk past FORMAT_FOLDER always. Filing also uses the disc parent of cd1/MP3/ so the second disc is not skipped. Co-authored-by: Mancolt --- CHANGELOG.md | 6 ++++++ CONFIG.md | 2 +- myx_classes.py | 7 ++++++- myx_names.py | 13 ++++++------- tests/test_names.py | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23af809..1ed2e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ 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 codec folder (`Title/MP3/`, `Title/M4B/`) were grouped as one book + named `MP3` and hardlinked to the first match. Grouping now walks past codec folders the same way + it already walks past `cd1/`. `cd1/MP3/01.mp3` and `cd2/MP3/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.0 - 2026-09-18 First release of the fork. Every item of the roadmap in docs/FORK.md is included; outputs stay compatible with diff --git a/CONFIG.md b/CONFIG.md index f48eed9..0dc1c11 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`) | {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 folder under those: `cd1/MP3/`) | {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 337fc61..b4d3cfb 100644 --- a/myx_classes.py +++ b/myx_classes.py @@ -368,8 +368,13 @@ def getConfigTargetPath(self, cfg, book): else: narrator="" - #is this a MultiCd file? + #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 if (not myx_utilities.isMultiCD(disc)): disc = "" diff --git a/myx_names.py b/myx_names.py index 185543c..d62ffd4 100644 --- a/myx_names.py +++ b/myx_names.py @@ -268,10 +268,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. Two discs of one release become one book; two releases that both use `cd1/` stay separate. - Author/Title layouts still key on the immediate (non-disc) parent. A format folder under a disc - (`cd1/MP3/`) is skipped the same way. Falls back to `fallback` for a loose file or a path outside - the source.""" + 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.""" try: rel = os.path.relpath(fullPath, sourcePath) if sourcePath and fullPath else "" except ValueError: @@ -283,9 +283,8 @@ 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 sitting under it (cd1/MP3/), are not the release - parent_is_disc = i > 0 and bool(DISC_FOLDER.match(parts[i - 1].strip())) - if DISC_FOLDER.match(name) or (parent_is_disc and FORMAT_FOLDER.match(name)): + # 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): continue return parts[i] return parts[0] diff --git a/tests/test_names.py b/tests/test_names.py index f8a51dc..6ce5892 100644 --- a/tests/test_names.py +++ b/tests/test_names.py @@ -124,6 +124,10 @@ def test_grouping_name_walks_past_disc_folders_and_keeps_releases_apart(self): self.assertEqual(N.groupingName(f"{src}/The Guest/part 2/track.mp3", src, "part 2"), "The Guest") self.assertEqual(N.groupingName(f"{src}/The Guest/cd1/MP3/d1.mp3", src, "MP3"), "The Guest") self.assertEqual(N.groupingName(f"{src}/Along Came a Spider/cd1/MP3/d1.mp3", src, "MP3"), "Along Came a Spider") + # a codec folder that is not under a disc is still not the release (two books that both use MP3/) + 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 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"), @@ -238,6 +242,16 @@ def test_two_releases_with_cd1_are_not_the_same_book(self): self.assertNotEqual(booktree.bookGroupingKey(guest), booktree.bookGroupingKey(spider)) self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/cd2/d2.m4b")), "The Guest") + def test_two_releases_with_mp3_are_not_the_same_book(self): + import booktree + guest = self._bf("The Guest/MP3/d1.mp3") + spider = self._bf("Along Came a Spider/MP3/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/cd1/MP3/d1.mp3")), "The Guest") + self.assertEqual(booktree.bookGroupingKey(self._bf("Patterson/The Guest/MP3/book.m4b")), "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") @@ -315,6 +329,27 @@ def test_grouped_discs_get_distinct_target_folders(self): self.assertEqual(len(targets), 4) self.assertIn("/lib/Author/Title/Title Disk 1", targets) + def test_codec_folder_under_a_disc_uses_the_disc_not_mp3(self): + # grouping walks past cd1/MP3/; 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", "cd2/MP3", "Disc 1/m4b", "disk 2/FLAC"): + 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/MP3/ is a codec folder, not a disc: no extra subfolder + bf = myx_classes.BookFile("Title/MP3/01.mp3", "/dl/Title/MP3/01.mp3", "/dl", "/lib") + self.assertEqual(bf.getConfigTargetPath(cfg, book), "/lib/Author/Title") + class InSeriesNoPartTest(unittest.TestCase):