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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ 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
- `mam/accept_unsnatched`: a series volume whose title does not write "Book N" (the number lives only in
id3 `SERIES`/`PART`, or in a parsed folder such as `Author - Title, Book 2`) was accepted against another
volume of the same title with a different MAM `series_info` part. `samePosition` now compares those parts,
and pickUnsnatched applies the parsed folder part when the tags have none.

## 3.0.6 - 2026-10-07

### Added
Expand Down
19 changes: 16 additions & 3 deletions myx_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ def samePosition(ours, theirs):
"""Series position check for an unsnatched MAM row (pickUnsnatched), between two Books: a "Book N" written in one
title must be matched by the other's title or, failing that, by one of its series parts; when the other side has
no position at all, the row is refused ("Mother of Learning, Book 2" is not a row "Mother of Learning" that MAM
files as part 3, nor one with no part)."""
files as part 3, nor one with no part). When neither title writes "Book N" but both books have a series part,
those parts must overlap: "Mother of Learning" filed as part 1 is not a row MAM lists as part 2."""
def positions(b):
return set(seriesPosition(b.title)), {_num(s.part) for s in b.series if _num(s.part)}
ot, op = positions(ours)
Expand All @@ -142,7 +143,12 @@ def positions(b):
return bool(ot & tp)
if tt and not ot:
return bool(tt & op)
return True # both titles carry positions (sameMamTitle compared them) or neither does
if ot and tt:
return True # both titles carry positions (sameMamTitle compared them)
# neither title writes "Book N": series parts must agree when both sides have them
if op and tp:
return bool(op & tp)
return True


def _oneLine(text):
Expand Down Expand Up @@ -1351,6 +1357,13 @@ def pickUnsnatched(self, cfg, book, bookFile, pool):
if book is None:
return []
parsed = self.getParsedName(book, cfg) or {}
# applyParsedName copies title/authors, not series/part. A folder "Author - Title, Book 2" with a
# junk id3 title becomes title "Title" and part "2"; without the part, samePosition cannot tell
# MAM's part-1 row from the part-2 one (neither title writes "Book N").
check = book
if (parsed.get("part") or parsed.get("series")) and not any(_num(s.part) for s in book.series):
check = copy.copy(book)
check.series = list(book.series) + [Series(parsed.get("series") or "", parsed.get("part") or "")]
title = _clip(book.title)
# a title that only repeats the release name is junk, unless the release name is just the title
junk = myx_names.isJunkTitle(title) or (myx_names.isJunkTitle(title, parsed.get("source") or self.name) and
Expand All @@ -1366,7 +1379,7 @@ def pickUnsnatched(self, cfg, book, bookFile, pool):
# need not reach stdout
passed = [abook for abook, types in rows.values()
if ext and ext in types and sameMamTitle(title, abook.title, [x.name for x in abook.series])
and samePosition(book, abook) and myx_utilities.isThisMyAuthorsBook(book.authors, abook, cfg)]
and samePosition(check, abook) and myx_utilities.isThisMyAuthorsBook(book.authors, abook, cfg)]
if len(passed) == 1:
print(f"No snatched MAM match; using the only unsnatched one that passes the checks: "
f"{_oneLine(passed[0].title)} by {_oneLine(passed[0].getAuthors())}")
Expand Down
45 changes: 45 additions & 0 deletions tests/test_mam_unsnatched.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,40 @@ def pick(part):
self.assertEqual(pick("2").title, "Mother of Learning")
self.assertEqual(pick("2.0").title, "Mother of Learning")

def test_series_parts_must_agree_when_neither_title_writes_book_n(self):
# id3 title is the series name; the volume lives only in SERIES/PART (or the parsed folder).
# samePosition used to return True whenever both titles lacked "Book N", so part 1 was filed as part 2.
def pick(ours_part, theirs_part, name="Mother of Learning", title="Mother of Learning", authors=("Domagoj Kurmaic",)):
self.td = tempfile.TemporaryDirectory()
self.addCleanup(self.td.cleanup)
series = ('{"1": ["Mother of Learning", "' + theirs_part + '"]}') if theirs_part else ""
mb = release(title=title, authors=authors, name=name, file="Mother of Learning.m4b",
series=[("Mother of Learning", ours_part)] if ours_part else ())
best, _, _ = self.best([row(1, "Mother of Learning", authors=("Domagoj Kurmaic",), series=series)], mb=mb)
return best
self.assertIsNone(pick("1", "2"))
self.assertIsNone(pick("2", "1"))
self.assertEqual(pick("1", "1").title, "Mother of Learning")
self.assertEqual(pick("2", "2.0").title, "Mother of Learning")
# a row with no series part is still accepted when our title also has no Book N (cannot refuse the
# release's own torrent if MAM omitted series_info)
self.assertEqual(pick("1", None).title, "Mother of Learning")

def test_parsed_folder_part_is_used_when_id3_has_no_series_part(self):
# junk tags + "Author - Title, Book 2": applyParsedName sets title/authors, not series. The folder
# part must still refuse MAM's part-1 row.
def pick(theirs_part):
self.td = tempfile.TemporaryDirectory()
self.addCleanup(self.td.cleanup)
series = ('{"1": ["Mother of Learning", "' + theirs_part + '"]}') if theirs_part else ""
mb = release(title="AudioTrack 01", authors=("unknown artist",),
name="Domagoj Kurmaic - Mother of Learning, Book 2", file="Mother of Learning.m4b")
best, _, _ = self.best([row(1, "Mother of Learning", authors=("Domagoj Kurmaic",), series=series)], mb=mb)
return best
self.assertIsNone(pick("1"))
self.assertIsNone(pick("3"))
self.assertEqual(pick("2").title, "Mother of Learning")

def test_control_characters_in_a_row_never_reach_stdout(self):
bad = row(1, "Some Other Book\n\ttitle: INJECTED\n\tasin: B000FAKE")
best, out, _ = self.best([bad, row(2, "Before Sh\x1be Knew Him")])
Expand Down Expand Up @@ -347,6 +381,17 @@ def test_sibling_volumes_parts_and_box_sets_are_not_the_same_title(self):
self.assertTrue(same("Leviathan Wakes", "Leviathan Wakes: The Expanse, Book 1", ["The Expanse"]))
self.assertFalse(same("Leviathan Wakes", "Leviathan Wakes: The Expanse, Book 1"))
self.assertFalse(same("Mistborn", "Mistborn: Secret History", ["Mistborn"]))
# series parts: neither title writes "Book N", so only the parts distinguish the volumes
ours = myx_classes.Book(title="Mother of Learning")
ours.series = [myx_classes.Series("Mother of Learning", "1")]
other = myx_classes.Book(title="Mother of Learning")
other.series = [myx_classes.Series("Mother of Learning", "2")]
same_part = myx_classes.Book(title="Mother of Learning")
same_part.series = [myx_classes.Series("Mother of Learning", "1")]
no_part = myx_classes.Book(title="Mother of Learning")
self.assertFalse(myx_classes.samePosition(ours, other))
self.assertTrue(myx_classes.samePosition(ours, same_part))
self.assertTrue(myx_classes.samePosition(ours, no_part)) # row without series_info is not refused
for ours, theirs in [("Shogun", "Shōgun"), ("Ender’s Game", "Ender's Game"),
("Across the Fence: Expanded Edition: The Secret War in Vietnam (Unabridged)",
"Across the Fence: Expanded Edition - The Secret War in Vietnam"),
Expand Down
Loading