diff --git a/CHANGELOG.md b/CHANGELOG.md index 4097689..61958c0 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 mixed torrents that both use a kind folder (`Title/Audiobook/`, `Title/Unabridged/`) were grouped as one + book named `Audiobook` and hardlinked to the first match. Grouping now walks past those folders the same + way it already walks past `MP3/` and `64k/`. `cd1/Audiobook/` and `cd2/Audiobook/` also get distinct disc + subfolders; they used to collide in a flat `Author/Title/` folder and the second disc was skipped. + ## 3.0.4 - 2026-09-22 ### Fixed diff --git a/CONFIG.md b/CONFIG.md index 04a3090..ace2a68 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 or bitrate folder under those: `cd1/MP3/`, `cd1/MP3/64k/`) | {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, bitrate or kind folder under those: `cd1/MP3/`, `cd1/MP3/64k/`, `cd1/Audiobook/`) | {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/booktree.py b/booktree.py index 2e8240a..d0c4d84 100644 --- a/booktree.py +++ b/booktree.py @@ -380,7 +380,8 @@ def noteRunTotals(cfg, logfile, books, matched): def bookGroupingKey(bf, multibook=False): """The dict key for a scanned file: filename when multibook or the file sits at the source root, - otherwise the release folder (walking past cd/disc/part parents so discs of one book stay together).""" + otherwise the release folder (walking past cd/disc/part parents and codec/bitrate/kind wrappers + so discs of one book stay together).""" if multibook or bf.hasNoParentFolder(): return bf.getFileName() return myx_names.groupingName(bf.fullPath, bf.sourcePath, bf.getParentFolder()) diff --git a/myx_classes.py b/myx_classes.py index f3fae2f..8e12898 100644 --- a/myx_classes.py +++ b/myx_classes.py @@ -374,9 +374,9 @@ def getConfigTargetPath(self, cfg, book): else: narrator="" - #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. + #is this a MultiCd file? Walk past codec/bitrate/kind wrappers under a disc + #(cd1/MP3/01.mp3, cd1/MP3/64k/01.mp3, cd1/Audiobook/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 44df646..b7be0de 100644 --- a/myx_names.py +++ b/myx_names.py @@ -36,12 +36,16 @@ 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) +# mixed-torrent kind folders that sit under the release: Title/Audiobook/, Title/Unabridged/ +KIND_FOLDER = re.compile( + r"^(audio ?books?|unabridged|abridged|dramati[sz]ed|graphic ?audio)$", + re.IGNORECASE) def isWrapperFolder(name): - """Codec or bitrate folder that is never the release (`MP3/`, `64k/`, `128 kbps/`).""" + """Codec, bitrate or kind folder that is never the release (`MP3/`, `64k/`, `Audiobook/`).""" 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 KIND_FOLDER.match(name)) # series parts run 1..99 (with an optional .5); "Fahrenheit 451" / "Apollo 13"-style titles are not series @@ -278,10 +282,11 @@ 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 + kind folders (`Audiobook/`, `Unabridged/`). Two discs of one release become one book; two releases + that both use `cd1/`, `MP3/`, `64k/` or `Audiobook/` 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 +298,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/kind folder (Title/MP3/, Title/64k/, Title/Audiobook/, + # cd1/MP3/64k/), are not the release if DISC_FOLDER.match(name) or isWrapperFolder(name): continue return parts[i] @@ -301,11 +307,11 @@ def groupingName(fullPath, sourcePath, fallback): def discFolderFromPath(file_path): - """The disc folder a media file sits in, walking past codec/bitrate wrappers. + """The disc folder a media file sits in, walking past codec/bitrate/kind 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 kind + folder that is not under a disc (`Title/MP3/`, `Title/64k/`, `Title/Audiobook/`) 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..1f53977 100644 --- a/tests/test_names.py +++ b/tests/test_names.py @@ -137,6 +137,15 @@ 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") + # a kind folder is not the release (two mixed torrents that both use Audiobook/ or Unabridged/) + self.assertEqual(N.groupingName(f"{src}/The Guest/Audiobook/book.m4b", src, "Audiobook"), "The Guest") + self.assertEqual(N.groupingName(f"{src}/Along Came a Spider/Audiobook/book.m4b", src, "Audiobook"), + "Along Came a Spider") + self.assertEqual(N.groupingName(f"{src}/The Guest/Unabridged/book.m4b", src, "Unabridged"), "The Guest") + self.assertEqual(N.groupingName(f"{src}/The Guest/AUDIOBOOKS/book.m4b", src, "AUDIOBOOKS"), "The Guest") + self.assertEqual(N.groupingName(f"{src}/The Guest/Audio Books/book.m4b", src, "Audio Books"), "The Guest") + self.assertEqual(N.groupingName(f"{src}/The Guest/Graphic Audio/book.m4b", src, "Graphic Audio"), "The Guest") + self.assertEqual(N.groupingName(f"{src}/The Guest/cd1/Audiobook/d1.mp3", src, "Audiobook"), "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 +282,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_audiobook_are_not_the_same_book(self): + import booktree + guest = self._bf("The Guest/Audiobook/book.m4b") + spider = self._bf("Along Came a Spider/Audiobook/book.m4b") + 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/Unabridged/book.m4b")), "The Guest") + self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/cd1/Audiobook/d1.mp3")), "The Guest") + self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/cd2/Audiobook/d2.mp3")), "The Guest") + self.assertEqual(booktree.bookGroupingKey(self._bf("Patterson/The Guest/Audiobook/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") @@ -394,6 +415,26 @@ 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_kind_folder_under_a_disc_uses_the_disc_not_audiobook(self): + # grouping walks past cd1/Audiobook/; 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/Audiobook", "cd2/Audiobook", "Disc 1/Unabridged"): + 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", + }) + # Title/Audiobook/ is a kind folder, not a disc: no extra subfolder + bf = myx_classes.BookFile("Title/Audiobook/01.mp3", "/dl/Title/Audiobook/01.mp3", "/dl", "/lib") + self.assertEqual(bf.getConfigTargetPath(cfg, book), "/lib/Author/Title") + class InSeriesNoPartTest(unittest.TestCase):