mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 20:30:31 +01:00
## 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>
234 lines
8.4 KiB
Python
234 lines
8.4 KiB
Python
"""Tests for natural sort and sequential part number assignment."""
|
|
|
|
from pathlib import Path
|
|
|
|
from shelfmark.core.naming import assign_part_numbers, natural_sort_key
|
|
|
|
|
|
class TestNaturalSortKey:
|
|
def test_simple_numbers(self):
|
|
files = ["Part 2.mp3", "Part 10.mp3", "Part 1.mp3"]
|
|
assert sorted(files, key=natural_sort_key) == ["Part 1.mp3", "Part 2.mp3", "Part 10.mp3"]
|
|
|
|
def test_leading_zeros(self):
|
|
files = ["Track 01.mp3", "Track 10.mp3", "Track 02.mp3"]
|
|
assert sorted(files, key=natural_sort_key) == [
|
|
"Track 01.mp3",
|
|
"Track 02.mp3",
|
|
"Track 10.mp3",
|
|
]
|
|
|
|
def test_case_insensitive(self):
|
|
files = ["PART 2.mp3", "part 1.mp3", "Part 3.mp3"]
|
|
assert sorted(files, key=natural_sort_key) == ["part 1.mp3", "PART 2.mp3", "Part 3.mp3"]
|
|
|
|
def test_multiple_numbers_in_filename(self):
|
|
files = ["CD2_Track10.mp3", "CD1_Track2.mp3", "CD1_Track10.mp3", "CD2_Track1.mp3"]
|
|
assert sorted(files, key=natural_sort_key) == [
|
|
"CD1_Track2.mp3",
|
|
"CD1_Track10.mp3",
|
|
"CD2_Track1.mp3",
|
|
"CD2_Track10.mp3",
|
|
]
|
|
|
|
def test_no_numbers(self):
|
|
files = ["charlie.mp3", "alpha.mp3", "bravo.mp3"]
|
|
assert sorted(files, key=natural_sort_key) == ["alpha.mp3", "bravo.mp3", "charlie.mp3"]
|
|
|
|
def test_path_objects(self):
|
|
files = [Path("file10.mp3"), Path("file2.mp3"), Path("file1.mp3")]
|
|
assert [f.name for f in sorted(files, key=natural_sort_key)] == [
|
|
"file1.mp3",
|
|
"file2.mp3",
|
|
"file10.mp3",
|
|
]
|
|
|
|
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 == [Path("/a/dir/file2.mp3"), Path("/z/dir/file1.mp3")]
|
|
|
|
|
|
class TestAssignPartNumbers:
|
|
def test_empty_list(self):
|
|
assert assign_part_numbers([]) == []
|
|
|
|
def test_single_file(self):
|
|
assert assign_part_numbers([Path("book.mp3")]) == [(Path("book.mp3"), "01")]
|
|
|
|
def test_multiple_files_sorted(self):
|
|
files = [Path("Part 3.mp3"), Path("Part 1.mp3"), Path("Part 2.mp3")]
|
|
assert assign_part_numbers(files) == [
|
|
(Path("Part 1.mp3"), "01"),
|
|
(Path("Part 2.mp3"), "02"),
|
|
(Path("Part 3.mp3"), "03"),
|
|
]
|
|
|
|
def test_natural_sort_applied(self):
|
|
files = [Path("Chapter 10.mp3"), Path("Chapter 2.mp3"), Path("Chapter 1.mp3")]
|
|
assert assign_part_numbers(files) == [
|
|
(Path("Chapter 1.mp3"), "01"),
|
|
(Path("Chapter 2.mp3"), "02"),
|
|
(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) == [
|
|
(Path("a.mp3"), "001"),
|
|
(Path("b.mp3"), "002"),
|
|
]
|
|
|
|
def test_many_files_padding(self):
|
|
files = [Path(f"track_{i}.mp3") for i in range(100, 0, -1)]
|
|
result = assign_part_numbers(files, zero_pad_width=3)
|
|
assert result[0] == (Path("track_1.mp3"), "001")
|
|
assert result[-1] == (Path("track_100.mp3"), "100")
|
|
|
|
|
|
class TestRealWorldScenarios:
|
|
def test_standard_part_naming(self):
|
|
files = [
|
|
Path("The Way of Kings - Part 02.mp3"),
|
|
Path("The Way of Kings - Part 01.mp3"),
|
|
Path("The Way of Kings - Part 10.mp3"),
|
|
Path("The Way of Kings - Part 03.mp3"),
|
|
]
|
|
result = assign_part_numbers(files)
|
|
assert [r[0].name for r in result] == [
|
|
"The Way of Kings - Part 01.mp3",
|
|
"The Way of Kings - Part 02.mp3",
|
|
"The Way of Kings - Part 03.mp3",
|
|
"The Way of Kings - Part 10.mp3",
|
|
]
|
|
|
|
def test_cd_track_naming(self):
|
|
files = [
|
|
Path("CD02_Track01.mp3"),
|
|
Path("CD01_Track02.mp3"),
|
|
Path("CD01_Track01.mp3"),
|
|
Path("CD02_Track02.mp3"),
|
|
]
|
|
result = assign_part_numbers(files)
|
|
assert [r[0].name for r in result] == [
|
|
"CD01_Track01.mp3",
|
|
"CD01_Track02.mp3",
|
|
"CD02_Track01.mp3",
|
|
"CD02_Track02.mp3",
|
|
]
|
|
|
|
def test_disc_track_naming(self):
|
|
files = [
|
|
Path("Disc 1 - Track 10.mp3"),
|
|
Path("Disc 1 - Track 2.mp3"),
|
|
Path("Disc 2 - Track 1.mp3"),
|
|
]
|
|
result = assign_part_numbers(files)
|
|
assert [r[0].name for r in result] == [
|
|
"Disc 1 - Track 2.mp3",
|
|
"Disc 1 - Track 10.mp3",
|
|
"Disc 2 - Track 1.mp3",
|
|
]
|
|
|
|
def test_simple_numbered_files(self):
|
|
files = [Path("02 Chapter Two.mp3"), Path("01 Chapter One.mp3"), Path("10 Chapter Ten.mp3")]
|
|
result = assign_part_numbers(files)
|
|
assert [r[0].name for r in result] == [
|
|
"01 Chapter One.mp3",
|
|
"02 Chapter Two.mp3",
|
|
"10 Chapter Ten.mp3",
|
|
]
|
|
|
|
def test_bracketed_numbers(self):
|
|
files = [
|
|
Path("Book Title [03].mp3"),
|
|
Path("Book Title [01].mp3"),
|
|
Path("Book Title [02].mp3"),
|
|
]
|
|
result = assign_part_numbers(files)
|
|
assert [r[0].name for r in result] == [
|
|
"Book Title [01].mp3",
|
|
"Book Title [02].mp3",
|
|
"Book Title [03].mp3",
|
|
]
|
|
|
|
|
|
class TestNoFalsePositives:
|
|
"""Titles with numbers (451, 1984, etc.) don't cause issues with sequential assignment."""
|
|
|
|
def test_fahrenheit_451(self):
|
|
files = [Path("Fahrenheit 451 - Part 2.mp3"), Path("Fahrenheit 451 - Part 1.mp3")]
|
|
result = assign_part_numbers(files)
|
|
assert result[0] == (Path("Fahrenheit 451 - Part 1.mp3"), "01")
|
|
assert result[1] == (Path("Fahrenheit 451 - Part 2.mp3"), "02")
|
|
|
|
def test_1984(self):
|
|
files = [
|
|
Path("1984 - Chapter 03.mp3"),
|
|
Path("1984 - Chapter 01.mp3"),
|
|
Path("1984 - Chapter 02.mp3"),
|
|
]
|
|
result = assign_part_numbers(files)
|
|
assert [r[0].name for r in result] == [
|
|
"1984 - Chapter 01.mp3",
|
|
"1984 - Chapter 02.mp3",
|
|
"1984 - Chapter 03.mp3",
|
|
]
|
|
|
|
def test_catch_22(self):
|
|
files = [Path("Catch-22 Part 2.mp3"), Path("Catch-22 Part 1.mp3")]
|
|
result = assign_part_numbers(files)
|
|
assert result[0] == (Path("Catch-22 Part 1.mp3"), "01")
|
|
|
|
def test_2001_a_space_odyssey(self):
|
|
files = [Path("2001 A Space Odyssey - 02.mp3"), Path("2001 A Space Odyssey - 01.mp3")]
|
|
result = assign_part_numbers(files)
|
|
assert result[0][0].name == "2001 A Space Odyssey - 01.mp3"
|
|
|
|
|
|
class TestEdgeCases:
|
|
def test_identical_filenames_different_dirs(self):
|
|
files = [Path("/dir2/track.mp3"), Path("/dir1/track.mp3")]
|
|
result = assign_part_numbers(files)
|
|
assert result == [
|
|
(Path("/dir1/track.mp3"), "01"),
|
|
(Path("/dir2/track.mp3"), "02"),
|
|
]
|
|
|
|
def test_unicode_filenames(self):
|
|
files = [Path("日本語タイトル 02.mp3"), Path("日本語タイトル 01.mp3")]
|
|
result = assign_part_numbers(files)
|
|
assert result[0][0].name == "日本語タイトル 01.mp3"
|
|
|
|
def test_very_large_numbers(self):
|
|
files = [Path("track_1000.mp3"), Path("track_100.mp3"), Path("track_10.mp3")]
|
|
result = assign_part_numbers(files)
|
|
assert [r[0].name for r in result] == ["track_10.mp3", "track_100.mp3", "track_1000.mp3"]
|
|
|
|
def test_mixed_extensions(self):
|
|
files = [Path("track_2.m4b"), Path("track_1.mp3"), Path("track_3.flac")]
|
|
result = assign_part_numbers(files)
|
|
assert [r[0].name for r in result] == ["track_1.mp3", "track_2.m4b", "track_3.flac"]
|
|
|
|
def test_no_numbers_alphabetical(self):
|
|
files = [Path("zebra.mp3"), Path("apple.mp3"), Path("mango.mp3")]
|
|
result = assign_part_numbers(files)
|
|
assert [r[0].name for r in result] == ["apple.mp3", "mango.mp3", "zebra.mp3"]
|