diff --git a/CHANGELOG.md b/CHANGELOG.md index 4097689..19e1ff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ first release is 3.0.0 because exit codes and the cookie store changed in ways a ## Unreleased +### Fixed +- MAM ranking accepted a different snatched book by the same author when the id3 title and artist were + already usable: search is by filename (`01.mp3`, `file.m4b`), and the author-only gate skipped the title + check unless the release name had to supply title or authors. `The Guest` tagged as James Patterson was + filed as *Along Came a Spider* (token_sort 67, above `matchrate` 60). Ranking now requires a title match + whenever the id3/parsed title is not junk, and applies `Config/matchrate` the same way Audible already did. + ## 3.0.4 - 2026-09-22 ### Fixed diff --git a/myx_classes.py b/myx_classes.py index f3fae2f..263bf8a 100644 --- a/myx_classes.py +++ b/myx_classes.py @@ -1092,9 +1092,13 @@ def getMAMBooks(self, cfg, bookFile:BookFile): extension = f'"{bookFile.getExtension()}"' #for RANKING (never for the MAM query string) use the parsed release name where the id3 tags are junk rankBook, parsedApplied = self.applyParsedName(self.ffprobeBook, cfg) if (self.ffprobeBook is not None and not interactive) else (None, []) - # when the parse supplied title or authors, the title gate must see the search title - # (id3 or parsed), not the file basename — otherwise `file.m4b` rejects a correct MAM hit - rankTitle = rankBook.title if ("title" in parsedApplied or "authors" in parsedApplied) else title + # MAM is searched by filename, so a usable id3/parsed title must still gate the snatched hits. + # Using the file basename here used to skip the title gate whenever tags were already good + # (author-only), and filed Along Came a Spider as The Guest. + if rankBook is not None and not myx_names.isJunkTitle(rankBook.title): + rankTitle = rankBook.title + else: + rankTitle = title # Search using book key and authors (using or search in case the metadata is bad) print(f"Searching MAM for\n\tTitleFilename: {title}\n\tauthors:{authors}") @@ -1144,9 +1148,13 @@ def getMAMBooks(self, cfg, bookFile:BookFile): else: bestMatchRate=0 + minMatchRate = int(cfg.get("Config/matchrate")) #find the best match print(f"Finding the best MAM match out of {len(books)} results") targetBook = '|'.join([book.title, book.getAuthors(), book.getSeriesParts()]) + # a usable title (id3 or parsed) must pass the title gate: MAM search is filename-based, + # so author-only used to accept that author's other snatched books (token_sort 67 >= 60) + requireTitle = rankBook is None or not myx_names.isJunkTitle(rankBook.title) for abook in books: #if this book is snatched, include in the match @@ -1155,7 +1163,7 @@ def getMAMBooks(self, cfg, bookFile:BookFile): #otherwise, if maybe this title is close enough #print (f"{abook.title} by {abook.authors}...") authorOK = bool(len(book.authors) and myx_utilities.isThisMyAuthorsBook(book.authors, abook, cfg)) - if authorOK and "title" not in parsedApplied and "authors" not in parsedApplied: + if authorOK and not requireTitle: mamBook = '|'.join([abook.getAuthors(), abook.getCleanTitle(), abook.getSeriesParts()]) if add_narrators: mamBook = '|'.join([mamBook, abook.getNarrators()]) @@ -1173,7 +1181,7 @@ def getMAMBooks(self, cfg, bookFile:BookFile): print(f"\tMatch Rate: {matchRate}\n\tSearch: {targetBook}\n\tResult: {mamBook}\n\tBest Match Rate: {bestMatchRate}\n") - if (matchRate[fuzzy_match] > bestMatchRate): + if (matchRate[fuzzy_match] > bestMatchRate) and (matchRate[fuzzy_match] >= minMatchRate): bestMatchRate=matchRate[fuzzy_match] self.bestMAMMatch=abook else: diff --git a/tests/test_matching.py b/tests/test_matching.py index f7bce77..91035eb 100644 --- a/tests/test_matching.py +++ b/tests/test_matching.py @@ -612,6 +612,25 @@ def test_different_mam_title_by_the_parsed_author_is_rejected(self): self.assertIsNone(best) self.assertIn("This book doesn't have a matching title or author", out) + def test_good_id3_author_and_title_still_reject_a_different_book_by_that_author(self): + # usable id3 title AND artist: parse does not apply, so the author-only gate used to accept + # Patterson's Along Came a Spider as The Guest (token_sort 67 >= matchrate 60). MAM search is + # the file basename (`file.m4b` / `01.mp3`), which is how the wrong snatched hit is returned. + best, out = self._run("The Guest", + id3_book("The Guest", ["James Patterson"], 400 * 60), + [_mam_book("Along Came a Spider", ["James Patterson"])]) + self.assertIsNone(best) + self.assertIn("This book doesn't have a matching title or author", out) + self.assertNotIn("Using parsed release name", out) + + def test_good_id3_author_and_title_still_accept_the_matching_title(self): + best, out = self._run("The Guest", + id3_book("The Guest", ["James Patterson"], 400 * 60), + [_mam_book("The Guest", ["James Patterson"])]) + self.assertIsNotNone(best) + self.assertEqual(best.title, "The Guest") + self.assertNotIn("Using parsed release name", out) + class NarratorInArtistTagTest(unittest.TestCase): def secrets(self):