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
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 releases that both use a codec folder (`Title/MP3/`, `Title/M4B/`) were grouped as one book
named `MP3` and hardlinked to the first match. Grouping now walks past codec folders the same way
it already walks past `cd1/`. `cd1/MP3/01.mp3` and `cd2/MP3/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.0 - 2026-09-18

First release of the fork. Every item of the roadmap in docs/FORK.md is included; outputs stay compatible with
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`) | {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 folder under those: `cd1/MP3/`) | {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
7 changes: 6 additions & 1 deletion myx_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,8 +368,13 @@ def getConfigTargetPath(self, cfg, book):
else:
narrator=""

#is this a MultiCd file?
#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
if (not myx_utilities.isMultiCD(disc)):
disc = ""

Expand Down
13 changes: 6 additions & 7 deletions myx_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,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. Two discs of one release become one book; two releases that both use `cd1/` stay separate.
Author/Title layouts still key on the immediate (non-disc) parent. A format folder under a disc
(`cd1/MP3/`) is skipped the same way. Falls back to `fallback` for a loose file or a path outside
the source."""
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."""
try:
rel = os.path.relpath(fullPath, sourcePath) if sourcePath and fullPath else ""
except ValueError:
Expand All @@ -283,9 +283,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 folder sitting under it (cd1/MP3/), are not the release
parent_is_disc = i > 0 and bool(DISC_FOLDER.match(parts[i - 1].strip()))
if DISC_FOLDER.match(name) or (parent_is_disc and FORMAT_FOLDER.match(name)):
# 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):
continue
return parts[i]
return parts[0]
Expand Down
35 changes: 35 additions & 0 deletions tests/test_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ def test_grouping_name_walks_past_disc_folders_and_keeps_releases_apart(self):
self.assertEqual(N.groupingName(f"{src}/The Guest/part 2/track.mp3", src, "part 2"), "The Guest")
self.assertEqual(N.groupingName(f"{src}/The Guest/cd1/MP3/d1.mp3", src, "MP3"), "The Guest")
self.assertEqual(N.groupingName(f"{src}/Along Came a Spider/cd1/MP3/d1.mp3", src, "MP3"), "Along Came a Spider")
# a codec folder that is not under a disc is still not the release (two books that both use MP3/)
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 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 @@ -238,6 +242,16 @@ def test_two_releases_with_cd1_are_not_the_same_book(self):
self.assertNotEqual(booktree.bookGroupingKey(guest), booktree.bookGroupingKey(spider))
self.assertEqual(booktree.bookGroupingKey(self._bf("The Guest/cd2/d2.m4b")), "The Guest")

def test_two_releases_with_mp3_are_not_the_same_book(self):
import booktree
guest = self._bf("The Guest/MP3/d1.mp3")
spider = self._bf("Along Came a Spider/MP3/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/cd1/MP3/d1.mp3")), "The Guest")
self.assertEqual(booktree.bookGroupingKey(self._bf("Patterson/The Guest/MP3/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 @@ -315,6 +329,27 @@ def test_grouped_discs_get_distinct_target_folders(self):
self.assertEqual(len(targets), 4)
self.assertIn("/lib/Author/Title/Title Disk 1", targets)

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



class InSeriesNoPartTest(unittest.TestCase):
Expand Down
Loading