Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 12 additions & 9 deletions myx_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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]
Expand All @@ -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):
Expand Down
41 changes: 41 additions & 0 deletions tests/test_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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):
Expand Down
Loading