diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d18adb..bdfeb10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/myx_classes.py b/myx_classes.py index 9c84a66..a16a38e 100644 --- a/myx_classes.py +++ b/myx_classes.py @@ -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) @@ -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): @@ -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 @@ -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())}") diff --git a/tests/test_mam_unsnatched.py b/tests/test_mam_unsnatched.py index 0fe7a3c..7b84443 100644 --- a/tests/test_mam_unsnatched.py +++ b/tests/test_mam_unsnatched.py @@ -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")]) @@ -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"),