diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d18adb..13989a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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 +- A file whose `AUDIBLE_ASIN` tag pointed at a different book by the same author was filed under that + other title. The per-ASIN lookup ignores title, and `_rankAudible` skipped the title gate + (`requireTitle` is False on the legacy attempt), so the author-only gate plus `matchrate` 60 accepted + the wrong product (token_sort 77 for *The Girl on the Train* tagged as *Into the Water*; also with a + junk `AudioTrack 01` title after the parsed folder title had already rejected the ASIN). #23's + fallback already handled a dead ASIN or a different author; a usable id3 or parsed title now has + to match before a tagged ASIN is accepted, and the existing title/author search runs when it does not. + ## 3.0.6 - 2026-10-07 ### Added diff --git a/myx_classes.py b/myx_classes.py index 9c84a66..ad875a7 100644 --- a/myx_classes.py +++ b/myx_classes.py @@ -830,7 +830,8 @@ def _audibleSearchKeys(self, book, cfg): def _rankAudible(self, books, book, keys, cfg, hintCandidates=False, requireTitle=False, runtimeAlone=False): """Upstream's interactive / best-match selection over Audible products. Sets audibleMatches and bestAudibleMatch. requireTitle: a result must match the title (an author match alone is not enough), - used when the title or authors came from the parsed release name rather than from tags.""" + used when the title or authors came from the parsed release name, or when a tagged ASIN is + looked up against a usable id3 title.""" minMatchRate = int(cfg.get("Config/matchrate")) verbose = bool(cfg.get("Config/flags/verbose")) add_narrators = bool(cfg.get("Config/flags/add_narrators")) @@ -994,6 +995,10 @@ def _audibleAttempts(self, book, cfg, searchAsin, noAsin=False): # author's other books (a leftover "James Patterson - The Guest" folder with a usable # id3 title "The Guest" used to file Patterson's Along Came a Spider) requireTitle = "title" in parsedApplied or "authors" in parsedApplied + # a per-ASIN lookup ignores title on the wire; with a usable title the author-only gate + # would accept that author's other books (Girl on the Train tagged Into the Water) + if pAsin and not myx_names.isJunkTitle(parsedBook.title): + requireTitle = True attempts.append(("parsed", parsedBook, pAsin, requireTitle)) parsedAuthors = (self.parsedName or {}).get("authors") or [] if parsedAuthors and "authors" not in parsedApplied and not pAsin and not myx_names.authorsOverlap( @@ -1012,7 +1017,14 @@ def _audibleAttempts(self, book, cfg, searchAsin, noAsin=False): soloBook.authors = [] attempts.append(("title-only", soloBook, "", True)) #upstream's search, exactly as before; its getAltTitle step runs when this attempt is reached (see caller) - attempts.append(("legacy", book, searchAsin, False)) + #except: a tagged ASIN plus a usable title must still pass the title gate (#23's fallback + #for "a different book" only ran when the author gate failed). If the parsed rung already + #looked this ASIN up against the release-name title, do not retry it on the junk id3 title + #under the author-only gate (AudioTrack 01 + leftover Into the Water ASIN); asin-fallback + #searches by title/author instead. + if not (searchAsin and "title" in parsedApplied): + legacyNeedsTitle = bool(searchAsin) and book is not None and not myx_names.isJunkTitle(book.title) + attempts.append(("legacy", book, searchAsin, legacyNeedsTitle)) #drop attempts that would repeat an identical query with a stricter gate (e.g. title-only then legacy when #the tag title is good and the artist tag empty): same result set, cannot succeed where the earlier one failed seen = set(); unique = [] diff --git a/tests/test_matching.py b/tests/test_matching.py index f7bce77..d52660f 100644 --- a/tests/test_matching.py +++ b/tests/test_matching.py @@ -202,6 +202,52 @@ def test_live_tagged_asin_is_accepted_without_a_search(self): self.assertNotIn("gave no usable Audible match", out) self.assertFalse(any(u.endswith("/catalog/products") for u, _ in client.calls)) + def test_tagged_asin_of_another_book_by_the_same_author_falls_back(self): + # leftover AUDIBLE_ASIN from another title by the same author: the per-ASIN product exists, + # the author gate passes, and token_sort of the comparison strings is 77 (>= matchrate 60), + # so #23's fallback never ran and the file was filed as the other book + other = product("B0WATER000", "Into the Water", ["Paula Hawkins"], 672) + real = product("B0TRAIN000", "The Girl on the Train", ["Paula Hawkins"], 660) + with tempfile.TemporaryDirectory() as td: + cfg = FakeConfig(td) + client = FakeAudible(by_asin={"B0WATER000": other}, search=[real]) + mb = mambook("Paula Hawkins - The Girl on the Train", + id3_book("The Girl on the Train", ["Paula Hawkins"], 660 * 60, asin="B0WATER000")) + best, out = run(mb, client, cfg) + self.assertIn("Tagged ASIN B0WATER000 gave no usable Audible match", out) + self.assertEqual(best.asin, "B0TRAIN000") + self.assertEqual(best.title, "The Girl on the Train") + self.assertTrue(mb.matchAttempt.startswith("asin-fallback:"), mb.matchAttempt) + + def test_live_tagged_asin_still_accepted_when_id3_title_has_unabridged(self): + # a correct tag whose Audible title is the id3 title without "(Unabridged)" must not fall back + with tempfile.TemporaryDirectory() as td: + cfg = FakeConfig(td) + client = FakeAudible(by_asin={"B0FBHZK5V7": self.real}, search=[]) + mb = mambook("This Book Made Me Think of You - Libby Page.m4b", + id3_book("This Book Made Me Think of You (Unabridged)", ["Libby Page"], 626.8 * 60, + asin="B0FBHZK5V7")) + best, out = run(mb, client, cfg) + self.assertEqual(best.asin, "B0FBHZK5V7") + self.assertNotIn("gave no usable Audible match", out) + self.assertFalse(any(u.endswith("/catalog/products") for u, _ in client.calls)) + + def test_junk_id3_title_does_not_let_a_leftover_asin_beat_the_parsed_title(self): + # ripper tags: AudioTrack 01, artist ok, leftover ASIN of another book by that author. + # The parsed folder title rejects the ASIN; legacy must not retry it against the junk + # id3 title (author-only + token_sort 61 would accept Into the Water). + other = product("B0WATER000", "Into the Water", ["Paula Hawkins"], 672) + real = product("B0TRAIN000", "The Girl on the Train", ["Paula Hawkins"], 660) + with tempfile.TemporaryDirectory() as td: + cfg = FakeConfig(td) + client = FakeAudible(by_asin={"B0WATER000": other}, search=[real]) + mb = mambook("Paula Hawkins - The Girl on the Train", + id3_book("AudioTrack 01", ["Paula Hawkins"], 660 * 60, asin="B0WATER000")) + best, out = run(mb, client, cfg) + self.assertEqual(best.asin, "B0TRAIN000") + self.assertEqual(best.title, "The Girl on the Train") + self.assertNotEqual(best.asin, "B0WATER000") + class SkeletonResultTest(unittest.TestCase): def test_skeleton_search_result_is_skipped_not_compared_against_empty_strings(self):