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

## Unreleased

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

## 3.0.4 - 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 or bitrate folder under those: `cd1/MP3/`, `cd1/MP3/64k/`) | {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, bitrate or kind folder under those: `cd1/MP3/`, `cd1/MP3/64k/`, `cd1/Audiobook/`) | {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
3 changes: 2 additions & 1 deletion booktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,8 @@ def noteRunTotals(cfg, logfile, books, matched):

def bookGroupingKey(bf, multibook=False):
"""The dict key for a scanned file: filename when multibook or the file sits at the source root,
otherwise the release folder (walking past cd/disc/part parents so discs of one book stay together)."""
otherwise the release folder (walking past cd/disc/part parents and codec/bitrate/kind wrappers
so discs of one book stay together)."""
if multibook or bf.hasNoParentFolder():
return bf.getFileName()
return myx_names.groupingName(bf.fullPath, bf.sourcePath, bf.getParentFolder())
Expand Down
6 changes: 3 additions & 3 deletions myx_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,9 +374,9 @@ def getConfigTargetPath(self, cfg, book):
else:
narrator=""

#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.
#is this a MultiCd file? Walk past codec/bitrate/kind wrappers under a disc
#(cd1/MP3/01.mp3, cd1/MP3/64k/01.mp3, cd1/Audiobook/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
26 changes: 16 additions & 10 deletions myx_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,16 @@
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)
# mixed-torrent kind folders that sit under the release: Title/Audiobook/, Title/Unabridged/
KIND_FOLDER = re.compile(
r"^(audio ?books?|unabridged|abridged|dramati[sz]ed|graphic ?audio)$",
re.IGNORECASE)


def isWrapperFolder(name):
"""Codec or bitrate folder that is never the release (`MP3/`, `64k/`, `128 kbps/`)."""
"""Codec, bitrate or kind folder that is never the release (`MP3/`, `64k/`, `Audiobook/`)."""
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 KIND_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 +282,11 @@ 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
kind folders (`Audiobook/`, `Unabridged/`). Two discs of one release become one book; two releases
that both use `cd1/`, `MP3/`, `64k/` or `Audiobook/` 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,19 +298,20 @@ 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/kind folder (Title/MP3/, Title/64k/, Title/Audiobook/,
# 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.
"""The disc folder a media file sits in, walking past codec/bitrate/kind 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 kind
folder that is not under a disc (`Title/MP3/`, `Title/64k/`, `Title/Audiobook/`) 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,15 @@ 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")
# a kind folder is not the release (two mixed torrents that both use Audiobook/ or Unabridged/)
self.assertEqual(N.groupingName(f"{src}/The Guest/Audiobook/book.m4b", src, "Audiobook"), "The Guest")
self.assertEqual(N.groupingName(f"{src}/Along Came a Spider/Audiobook/book.m4b", src, "Audiobook"),
"Along Came a Spider")
self.assertEqual(N.groupingName(f"{src}/The Guest/Unabridged/book.m4b", src, "Unabridged"), "The Guest")
self.assertEqual(N.groupingName(f"{src}/The Guest/AUDIOBOOKS/book.m4b", src, "AUDIOBOOKS"), "The Guest")
self.assertEqual(N.groupingName(f"{src}/The Guest/Audio Books/book.m4b", src, "Audio Books"), "The Guest")
self.assertEqual(N.groupingName(f"{src}/The Guest/Graphic Audio/book.m4b", src, "Graphic Audio"), "The Guest")
self.assertEqual(N.groupingName(f"{src}/The Guest/cd1/Audiobook/d1.mp3", src, "Audiobook"), "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 +282,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_audiobook_are_not_the_same_book(self):
import booktree
guest = self._bf("The Guest/Audiobook/book.m4b")
spider = self._bf("Along Came a Spider/Audiobook/book.m4b")
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/Unabridged/book.m4b")), "The Guest")
self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/cd1/Audiobook/d1.mp3")), "The Guest")
self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/cd2/Audiobook/d2.mp3")), "The Guest")
self.assertEqual(booktree.bookGroupingKey(self._bf("Patterson/The Guest/Audiobook/book.m4b")), "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 +415,26 @@ 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_kind_folder_under_a_disc_uses_the_disc_not_audiobook(self):
# grouping walks past cd1/Audiobook/; 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/Audiobook", "cd2/Audiobook", "Disc 1/Unabridged"):
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",
})
# Title/Audiobook/ is a kind folder, not a disc: no extra subfolder
bf = myx_classes.BookFile("Title/Audiobook/01.mp3", "/dl/Title/Audiobook/01.mp3", "/dl", "/lib")
self.assertEqual(bf.getConfigTargetPath(cfg, book), "/lib/Author/Title")



class InSeriesNoPartTest(unittest.TestCase):
Expand Down
Loading