Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ first release is 3.0.0 because exit codes and the cookie store changed in ways a

## Unreleased

### Fixed
- Two releases that both use a bitrate folder (`Title/64k/`, `Title/MP3/128kbps/`) were grouped as one
book named `64k` and hardlinked to the first match. Grouping now walks past bitrate folders the same
way it already walks past `MP3/` and `cd1/`. `cd1/MP3/64k/01.mp3` and `cd2/MP3/64k/01.mp3` also get
distinct disc subfolders; they used to collide in a flat `Author/Title/` folder and the second disc
was skipped.

## 3.0.2 - 2026-09-22

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 folder under those: `cd1/MP3/`) | {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 or bitrate folder under those: `cd1/MP3/`, `cd1/MP3/64k/`) | {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 | | [".", ":", "_", "[", "]", "{", "}", ",", ";", "(", ")"] |
Expand Down
11 changes: 4 additions & 7 deletions myx_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,13 +368,10 @@ def getConfigTargetPath(self, cfg, book):
else:
narrator=""

#is this a MultiCd file? Walk past a codec folder under a disc (cd1/MP3/01.mp3) so
#two grouped discs with the same track names are not filed into one flat folder.
disc = self.getParentFolder()
if disc and myx_names.FORMAT_FOLDER.match(disc.strip()):
grand = os.path.basename(os.path.dirname(os.path.dirname(self.file or "")))
if grand and myx_utilities.isMultiCD(grand):
disc = grand
#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.
disc = myx_names.discFolderFromPath(self.file) or self.getParentFolder()
if (not myx_utilities.isMultiCD(disc)):
disc = ""

Expand Down
36 changes: 30 additions & 6 deletions myx_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@
TITLE_TAIL = re.compile(r"[\s:,]+an?\s+(?:novel|thriller|mystery|memoir|novella)$", re.IGNORECASE)
DISC_FOLDER = re.compile(r"^(cd|disc|disk|part)\s*\d+$", re.IGNORECASE)
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)


def isWrapperFolder(name):
"""Codec or bitrate folder that is never the release (`MP3/`, `64k/`, `128 kbps/`)."""
name = (name or "").strip()
return bool(FORMAT_FOLDER.match(name) or BITRATE_FOLDER.match(name))


# series parts run 1..99 (with an optional .5); "Fahrenheit 451" / "Apollo 13"-style titles are not series
SERIES_NUM = re.compile(r"^(?P<series>[^\d,]+?)\s+(?:#\s*|book\s+|vol\.?\s*|volume\s+)?(?P<part>\d{1,2}(?:\.\d)?)$", re.IGNORECASE)
BOOK_N_TAIL = re.compile(r"^(?P<rest>.+?)[,\s]+(?:book|vol\.?|volume)\s+(?P<part>\d{1,3}(?:\.\d)?)$", re.IGNORECASE)
Expand Down Expand Up @@ -268,10 +278,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 and codec folders (`MP3/`, `M4B/`, `cd1/MP3/`). Two discs of one release become one book;
two releases that both use `cd1/` or both use `MP3/` stay separate. Author/Title layouts still key
on the immediate (non-disc, non-codec) parent. Falls back to `fallback` for a loose file or a path
outside the source."""
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."""
try:
rel = os.path.relpath(fullPath, sourcePath) if sourcePath and fullPath else ""
except ValueError:
Expand All @@ -283,13 +293,27 @@ 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 folder (Title/MP3/ or cd1/MP3/), are not the release
if DISC_FOLDER.match(name) or FORMAT_FOLDER.match(name):
# cd1/ itself, and a codec/bitrate folder (Title/MP3/, Title/64k/, cd1/MP3/64k/), are not the release
if DISC_FOLDER.match(name) or isWrapperFolder(name):
continue
return parts[i]
return parts[0]


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."""
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):
continue
return name
return ""


def releaseNameForBook(files, sourcePath, name):
"""The best name to parse for a book: the first path component under the source path (the release folder,
or the loose file name). Falls back to `name` (booktree's grouping key)."""
Expand Down
42 changes: 42 additions & 0 deletions tests/test_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,13 @@ def test_grouping_name_walks_past_disc_folders_and_keeps_releases_apart(self):
self.assertEqual(N.groupingName(f"{src}/The Guest/MP3/d1.mp3", src, "MP3"), "The Guest")
self.assertEqual(N.groupingName(f"{src}/Along Came a Spider/MP3/d1.mp3", src, "MP3"), "Along Came a Spider")
self.assertEqual(N.groupingName(f"{src}/The Guest/M4B/book.m4b", src, "M4B"), "The Guest")
# a bitrate folder is not the release (two books that both use 64k/ or MP3/64k/)
self.assertEqual(N.groupingName(f"{src}/The Guest/64k/d1.mp3", src, "64k"), "The Guest")
self.assertEqual(N.groupingName(f"{src}/Along Came a Spider/64k/d1.mp3", src, "64k"), "Along Came a Spider")
self.assertEqual(N.groupingName(f"{src}/The Guest/128kbps/d1.mp3", src, "128kbps"), "The Guest")
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 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 @@ -252,6 +259,18 @@ def test_two_releases_with_mp3_are_not_the_same_book(self):
self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/cd1/MP3/d1.mp3")), "The Guest")
self.assertEqual(booktree.bookGroupingKey(self._bf("Patterson/The Guest/MP3/book.m4b")), "The Guest")

def test_two_releases_with_64k_are_not_the_same_book(self):
import booktree
guest = self._bf("The Guest/64k/d1.mp3")
spider = self._bf("Along Came a Spider/64k/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/MP3/64k/d1.mp3")), "The Guest")
self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/cd1/MP3/64k/d1.mp3")), "The Guest")
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_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 @@ -350,6 +369,29 @@ def test_codec_folder_under_a_disc_uses_the_disc_not_mp3(self):
bf = myx_classes.BookFile("Title/MP3/01.mp3", "/dl/Title/MP3/01.mp3", "/dl", "/lib")
self.assertEqual(bf.getConfigTargetPath(cfg, book), "/lib/Author/Title")

def test_bitrate_folder_under_a_disc_uses_the_disc_not_64k(self):
# grouping walks past cd1/MP3/64k/; 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/MP3/64k", "cd2/MP3/64k", "Disc 1/128kbps", "disk 2/64 kbps"):
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/64k/ is a bitrate folder, not a disc: no extra subfolder
bf = myx_classes.BookFile("Title/64k/01.mp3", "/dl/Title/64k/01.mp3", "/dl", "/lib")
self.assertEqual(bf.getConfigTargetPath(cfg, book), "/lib/Author/Title")
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")



class InSeriesNoPartTest(unittest.TestCase):
Expand Down
Loading