diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d18adb..74cf2c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this fork. Upstream history before the fork point is in t point is tag `upstream-baseline`. Versions continue upstream's numbering (its last tag was `2.2.0-beta`); the fork's first release is 3.0.0 because exit codes and the cookie store changed in ways a caller can notice. +## Unreleased + +### Fixed +- Two releases that both use an unnumbered disc folder (`Title/CD/`, `Title/Discs/`) were grouped as one + book named `CD` and hardlinked to the first match. Grouping now walks past `CD/`/`CDs/`/`Disc/`/`Discs/`/ + `Disk/`/`Disks/` the same way it already walks past `cd1/`, `MP3/` and `64k/`. `cd1/CD/` and `cd2/CD/` also + get distinct disc subfolders; they used to collide in a flat `Author/Title/` folder and the second disc + was skipped. + ## 3.0.6 - 2026-10-07 ### Added diff --git a/myx_names.py b/myx_names.py index 44df646..d3976fe 100644 --- a/myx_names.py +++ b/myx_names.py @@ -36,12 +36,14 @@ 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) +# a disc kind folder with no number: Title/CD/, Title/Discs/. Numbered ones (cd1/, Disc 01/) are DISC_FOLDER. +CD_FOLDER = re.compile(r"^(cds?|discs?|disks?)$", re.IGNORECASE) def isWrapperFolder(name): - """Codec or bitrate folder that is never the release (`MP3/`, `64k/`, `128 kbps/`).""" + """Codec, bitrate or unnumbered disc folder that is never the release (`MP3/`, `64k/`, `CD/`).""" name = (name or "").strip() - return bool(FORMAT_FOLDER.match(name) or BITRATE_FOLDER.match(name)) + return bool(FORMAT_FOLDER.match(name) or BITRATE_FOLDER.match(name) or CD_FOLDER.match(name)) # series parts run 1..99 (with an optional .5); "Fahrenheit 451" / "Apollo 13"-style titles are not series @@ -278,10 +280,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, 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.""" + parents, codec folders (`MP3/`, `M4B/`, `cd1/MP3/`), bitrate folders (`64k/`, `MP3/128kbps/`) and + unnumbered disc folders (`CD/`, `Discs/`). Two discs of one release become one book; two releases + that both use `cd1/`, `MP3/`, `64k/` or `CD/` 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: @@ -293,7 +295,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/bitrate folder (Title/MP3/, Title/64k/, cd1/MP3/64k/), are not the release + # cd1/ itself, and a codec/bitrate/unnumbered-disc folder (Title/MP3/, Title/64k/, Title/CD/, + # cd1/MP3/64k/), are not the release if DISC_FOLDER.match(name) or isWrapperFolder(name): continue return parts[i] @@ -304,8 +307,8 @@ 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.""" + otherwise both target the same flat folder and the second is skipped. A codec, bitrate or unnumbered + disc folder that is not under a numbered disc (`Title/MP3/`, `Title/64k/`, `Title/CD/`) 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): diff --git a/tests/test_names.py b/tests/test_names.py index 29d0ceb..180be5f 100644 --- a/tests/test_names.py +++ b/tests/test_names.py @@ -137,6 +137,12 @@ def test_grouping_name_walks_past_disc_folders_and_keeps_releases_apart(self): 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") + # an unnumbered disc folder is not the release (two books that both use CD/ or Discs/) + self.assertEqual(N.groupingName(f"{src}/The Guest/CD/d1.mp3", src, "CD"), "The Guest") + self.assertEqual(N.groupingName(f"{src}/Along Came a Spider/CD/d1.mp3", src, "CD"), "Along Came a Spider") + self.assertEqual(N.groupingName(f"{src}/The Guest/Discs/d1.mp3", src, "Discs"), "The Guest") + self.assertEqual(N.groupingName(f"{src}/The Guest/cd1/CD/d1.mp3", src, "CD"), "The Guest") + self.assertEqual(N.groupingName(f"{src}/The Guest/cd2/CD/d2.mp3", src, "CD"), "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"), @@ -273,6 +279,18 @@ def test_two_releases_with_64k_are_not_the_same_book(self): 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_two_releases_with_cd_are_not_the_same_book(self): + import booktree + guest = self._bf("The Guest/CD/d1.mp3") + spider = self._bf("Along Came a Spider/CD/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/Discs/d1.mp3")), "The Guest") + self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/cd1/CD/d1.mp3")), "The Guest") + self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/cd2/CD/d2.mp3")), "The Guest") + self.assertEqual(booktree.bookGroupingKey(self._bf("Patterson/The Guest/Disk/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") @@ -394,6 +412,29 @@ def test_bitrate_folder_under_a_disc_uses_the_disc_not_64k(self): 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") + def test_cd_folder_under_a_disc_uses_the_disc_not_cd(self): + # grouping walks past cd1/CD/; 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/CD", "cd2/CD", "Disc 1/Discs", "disk 2/Disk"): + 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/CD/ is an unnumbered disc folder, not a numbered disc: no extra subfolder + bf = myx_classes.BookFile("Title/CD/01.mp3", "/dl/Title/CD/01.mp3", "/dl", "/lib") + self.assertEqual(bf.getConfigTargetPath(cfg, book), "/lib/Author/Title") + bf = myx_classes.BookFile("Title/Discs/01.mp3", "/dl/Title/Discs/01.mp3", "/dl", "/lib") + self.assertEqual(bf.getConfigTargetPath(cfg, book), "/lib/Author/Title") + class InSeriesNoPartTest(unittest.TestCase):