From 91b10646893543787998d24b9c52000921bcd916 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Tue, 21 Jul 2026 12:41:03 -0700 Subject: [PATCH] fix(naming): respect subfolder order when assigning audiobook part numbers (#1123) ## Summary Audiobooks whose files are split across nested subfolders now keep their on-disk folder order when "Rename and Organize" assigns part numbers. Previously `natural_sort_key` keyed on `Path(path).name`, so identically named files from different folders (`001.mp3` in `00_Introduction/` vs `06_Side 6/`) collapsed together and the part sequence scrambled. ## Background zazizou reported in #1007 that the total file count was right but `06_Side 6/001.mp3` came out as part 1 instead of `00_Introduction/001_About.mp3`. `scan.py` flattens every nested file into one list and `assign_part_numbers` sorts it with `natural_sort_key`, which discarded the parent-folder prefix. The fix pads the existing `PAD_NUMBERS_PATTERN` numbers over the full relative path (`str(path).lower()`) rather than the basename, so nested audiobooks sort by folder first and then by filename. Flat directories are unaffected because their files share a prefix, and `natural_sort_key` is only called from `assign_part_numbers`, so nothing else changes. I updated the test that pinned the old basename-only ordering and added nested-subfolder and duplicate-basename cases; `pytest tests/core/test_naming.py tests/core/test_part_number_extraction.py` passes (102 tests). Closes #1007 Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- shelfmark/core/naming.py | 3 +-- tests/core/test_part_number_extraction.py | 26 ++++++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/shelfmark/core/naming.py b/shelfmark/core/naming.py index bf21ee8..fa908de 100644 --- a/shelfmark/core/naming.py +++ b/shelfmark/core/naming.py @@ -91,8 +91,7 @@ PAD_NUMBERS_PATTERN = re.compile(r"\d+") def natural_sort_key(path: str | Path) -> str: """Generate a sort key with padded numbers for natural sorting.""" - filename = Path(path).name.lower() - return PAD_NUMBERS_PATTERN.sub(lambda m: m.group().zfill(9), filename) + return PAD_NUMBERS_PATTERN.sub(lambda m: m.group().zfill(9), str(path).lower()) def assign_part_numbers( diff --git a/tests/core/test_part_number_extraction.py b/tests/core/test_part_number_extraction.py index e10f441..41a4c09 100644 --- a/tests/core/test_part_number_extraction.py +++ b/tests/core/test_part_number_extraction.py @@ -43,10 +43,10 @@ class TestNaturalSortKey: "file10.mp3", ] - def test_uses_filename_only(self): + def test_uses_full_path(self): files = [Path("/z/dir/file1.mp3"), Path("/a/dir/file2.mp3")] sorted_files = sorted(files, key=natural_sort_key) - assert sorted_files[0].name == "file1.mp3" + assert sorted_files == [Path("/a/dir/file2.mp3"), Path("/z/dir/file1.mp3")] class TestAssignPartNumbers: @@ -72,6 +72,23 @@ class TestAssignPartNumbers: (Path("Chapter 10.mp3"), "03"), ] + def test_nested_folders_are_numbered_in_folder_order(self): + files = [ + Path("06_Side 6/002.mp3"), + Path("01_Side 1/001.mp3"), + Path("00_Introduction/001_About.mp3"), + Path("06_Side 6/001.mp3"), + Path("01_Side 1/002.mp3"), + ] + + assert assign_part_numbers(files) == [ + (Path("00_Introduction/001_About.mp3"), "01"), + (Path("01_Side 1/001.mp3"), "02"), + (Path("01_Side 1/002.mp3"), "03"), + (Path("06_Side 6/001.mp3"), "04"), + (Path("06_Side 6/002.mp3"), "05"), + ] + def test_custom_zero_padding(self): files = [Path("a.mp3"), Path("b.mp3")] assert assign_part_numbers(files, zero_pad_width=3) == [ @@ -190,7 +207,10 @@ class TestEdgeCases: def test_identical_filenames_different_dirs(self): files = [Path("/dir2/track.mp3"), Path("/dir1/track.mp3")] result = assign_part_numbers(files) - assert len(result) == 2 + assert result == [ + (Path("/dir1/track.mp3"), "01"), + (Path("/dir2/track.mp3"), "02"), + ] def test_unicode_filenames(self): files = [Path("日本語タイトル 02.mp3"), Path("日本語タイトル 01.mp3")]