From 09217d7637d8cd77bbb9f8a0450ea0df0df966f8 Mon Sep 17 00:00:00 2001 From: Mancolt Date: Fri, 18 Sep 2026 11:23:12 -0400 Subject: [PATCH] disc folders: file disk N/ and part N/ into a disc subfolder like cd N/ Since #15 groups cd/disc/disk/part N folders into one book, isMultiCD must recognise the same vocabulary, or Disk 1/01.mp3 and Disk 2/01.mp3 are filed into one flat folder and the second is silently skipped. cd/disc keep their unanchored match; part requires a word boundary so a title like "Counterpart 2" is not taken for a disc. Replay corpus: no differences. Co-authored-by: Cursor --- CHANGELOG.md | 3 +++ CONFIG.md | 2 +- myx_utilities.py | 9 ++++++++- tests/test_names.py | 27 +++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2487868..b49e749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,9 @@ point is tag `upstream-baseline`. - `Config/pin_max_runtime_delta_min`: optional hard limit on the runtime mismatch of a pinned ASIN (default off). ### Fixed +- `disk N/` and `part N/` folders, which are grouped into one book since the multi-disc fix below, were filed + into a flat `Author/Title/` folder, so `Disk 1/01.mp3` and `Disk 2/01.mp3` collided and the second was + silently skipped. They now get a `disc_folder` subfolder like `cd N/` and `disc N/` always did. - Multi-disc releases (`cd1/`, `Disc 01/`, `part 2/`) were grouped by the disc folder. Two downloads that both used `cd1/` became one book (files from both hardlinked to the first match) and a single release's discs were matched separately with one-disc runtime (the wrong edition could win). Grouping now walks past disc parents diff --git a/CONFIG.md b/CONFIG.md index d4c27c8..c28135c 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -93,7 +93,7 @@ A copy of default_config.cfg can be found under the /templates folder. It is re | | multi_author | How to handle the Author folder for multi-author books: first_author, authors, "", "Static folder name" | first_author | | | in_series | Format of the generated tree for books in a series | {author}/{series}/{series} #{part} - {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 | {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`) | {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_utilities.py b/myx_utilities.py index 104a781..f6b0bed 100644 --- a/myx_utilities.py +++ b/myx_utilities.py @@ -434,8 +434,15 @@ def loadFromCache(key, category, cfg): except (OSError, ValueError): return None +#the disc-folder vocabulary must cover everything myx_names.DISC_FOLDER groups into one book (cd/disc/disk/part N), +#or two grouped discs with the same file names would be filed into one flat folder and the second silently skipped. +#cd/disc stay unanchored as upstream had them ("Title Disc 2" is a disc folder too); part needs a word boundary +#so that a title such as "Counterpart 2" is not mistaken for one. +MULTI_CD = re.compile(r"(?:cd|disc|disk)\s?\d+|\bpart\s?\d+", re.IGNORECASE) + + def isMultiCD(parent): - return re.search(r"disc\s?\d+", parent.lower()) or re.search(r"cd\s?\d+", parent.lower()) + return MULTI_CD.search(parent) is not None def isGraphicAudio(author): m = re.search(r"graphic[\s]?audio[\s]?(llc[.]?)*", author.lower()) diff --git a/tests/test_names.py b/tests/test_names.py index db68bc1..e7d7b9b 100644 --- a/tests/test_names.py +++ b/tests/test_names.py @@ -2,6 +2,7 @@ import unittest import myx_names as N +from tests.support import FakeConfig def parse(name, known=(), file_name=None): @@ -288,3 +289,29 @@ def test_last_scan_of_one_new_disc_pulls_in_the_older_disc(self): finally: import shutil shutil.rmtree(src) + + +class DiscFolderTest(unittest.TestCase): + def test_every_folder_grouped_as_a_disc_is_filed_into_a_disc_subfolder(self): + import myx_utilities + for folder in ("cd1", "CD 2", "Disc 1", "disc01", "disk 1", "Disk2", "Part 2", "part 10", "Title Disc 2"): + self.assertTrue(N.DISC_FOLDER.match(folder) is None or myx_utilities.isMultiCD(folder), folder) + for folder in ("cd1", "Disk 1", "Part 2", "Title Disc 2"): + self.assertTrue(myx_utilities.isMultiCD(folder), folder) + for folder in ("Counterpart 2", "Rampart 5", "Some Book", "Chapter 3", "1984"): + self.assertFalse(myx_utilities.isMultiCD(folder), folder) + + def test_grouped_discs_get_distinct_target_folders(self): + # Disk 1/01.mp3 and Disk 2/01.mp3 are one book since #15; they must not both 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 ("Disk 1", "Disk 2", "Part 1", "Part 2"): + 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(len(targets), 4) + self.assertIn("/lib/Author/Title/Title Disk 1", targets) +