From de4f06dd74c1e41c7629e884e61a6030d1efdc8d Mon Sep 17 00:00:00 2001 From: Mancolt Date: Tue, 22 Sep 2026 09:37:38 -0400 Subject: [PATCH] Audible: fall back to a title/author search when a tagged ASIN is dead or names another book A file tagged with an ASIN Audible has no product for (skeleton {asin, asset_details, is_vvab}: a withdrawn or duplicate listing), or one that fails the title/author gate, was left unmatched: the per-ASIN endpoint ignores the title/author parameters and nothing else was tried. Seen 2026-09-22 with "This Book Made Me Think of You" tagged B0G2TK17DS (dead on every marketplace); the ABR request was B0FBHZK5V7. - getAudibleBooks: the attempt ladder now lives in _runAudibleAttempts, which reports the ASIN it was directed at and whether Audible answered that lookup. No match + answered ASIN -> rerun the ladder with the ASIN blanked (noAsin also strips a [bracketed] release-name ASIN), starting from the file's original tag title (the legacy rung's getAltTitle mutation must not leak into the search). Same gates and duration check as a tagless file. A lookup that *failed* (outage, timeout) does not fall back: the tag may be right and a search could file another edition for good; the book stays unmatched for the next run, as before. - matchAttempt is prefixed asin-fallback: so the JSON run log can tell such a match apart. - Interactive mode dropped: a skeleton answer was auto-accepted as the lone result and would have filed the book under an empty title. - bestAudibleMatch is reset at the start of a search; _rankAudible returns it as it stands, so a stale value from an earlier search could have masked a miss. Tests: 9 new cases (dead tag, live-but-wrong tag, gates still apply, live tag costs no extra query, attempt label, title restore, bracket ASIN, interactive skeleton, failed lookup, stale match). Live --dry-run on the real file: fallback finds B0FBHZK5V7, rate 84, duration difference 0 min. Replay of the 1602-book corpus against main: 3 matches gained (Takedown -> B01FEAW1SE), none lost, no target-path or OPF change. Review findings addressed: correctness (title leak, bracket ASIN, interactive skeleton, stale match, attempt label, replay) and security (fail-open on a transient Audible error). --- CHANGELOG.md | 15 +++++ CONFIG.md | 6 ++ docs/FORK.md | 1 + myx_classes.py | 96 +++++++++++++++++++++--------- tests/test_matching.py | 130 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b40454..afc4a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ first release is 3.0.0 because exit codes and the cookie store changed in ways a ## Unreleased +## 3.0.2 - 2026-09-22 + +### Fixed +- A file tagged with an ASIN that Audible has no product for (a withdrawn or duplicate listing answered with the + skeleton `{asin, asset_details, is_vvab}`), or one that points at a different book, was left unmatched: the + per-ASIN lookup ignores the title/author parameters and nothing else was tried. Such a file is now searched by + its title and author like a tagless one, through the same title/author and duration gates + (`Tagged ASIN X gave no usable Audible match; searching by title and author instead`). Pinned ASINs already + fell back this way. Seen 2026-09-22 with *This Book Made Me Think of You* tagged `B0G2TK17DS` (dead on every + marketplace); the fallback finds `B0FBHZK5V7`, duration difference 0 min. A live tag still resolves on the + first call with no extra query. Applies to a `[bracketed]` ASIN in the release name as well. The JSON run log + records such a match as `attempt: asin-fallback:`. +- Interactive mode auto-accepted a skeleton per-ASIN answer as the lone result and would have filed the book + under an empty title; skeletons are now dropped before the choice is offered. + ## 3.0.1 - 2026-09-19 ### Fixed diff --git a/CONFIG.md b/CONFIG.md index 0dc1c11..a4fa994 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -292,6 +292,12 @@ match the next one runs, bounded and cached like any other query: the swapped re exactly as upstream performed it with the file's own tags, so a wrong parse can add a match but never lose one. The MAM ranking uses the same parsed values; the MAM query string is unchanged. +An ASIN in the tags (or in `[brackets]` in the release name) is looked up directly first. When Audible has no +product for it (a withdrawn or duplicate listing is answered with a bare `{asin, asset_details, is_vvab}` record) +or the product fails the title/author gate, the file is searched by title and author as if it carried no ASIN, +with the same gates and duration check; the log says `Tagged ASIN X gave no usable Audible match; searching by +title and author instead`. A live tag is accepted on the first call and costs no extra query. + ## Caching, MAM traffic, `--refresh` diff --git a/docs/FORK.md b/docs/FORK.md index 5a24534..1753bf5 100644 --- a/docs/FORK.md +++ b/docs/FORK.md @@ -32,6 +32,7 @@ Scripts built on upstream booktree keep working: | 6 | `Series # - Title` folders: the log writes `seriesparts` as `Name part`, the log reader splits on `#`. Fixed: the reader pairs `seriesparts` with `series`, decimal parts are logged as `17.5`, and a series entry without a part is filed with `target_path/in_series_no_part` (default `Series - Title`) | #27 | | 6b | Multi-disc releases (`cd1/`, `Disc 01/`) are grouped per disc folder, so each disc is matched on its own, the runtime evidence is one disc long (the wrong edition can win), and `book` logs as `cd1..cdN`. Two releases that both use `cd1/` were also merged into one book. Fixed: grouping walks past disc parents to the release folder | #26 | | 8 | Everything around a run lived in per-host wrapper scripts (ntfy summary, Audiobookshelf scan, inode de-dupe, an ASIN fixer that rewrote `fix.csv`). Added as config, off by default: `notify`, `abs`, `dedupe_roots`, `--pin RELEASE=ASIN` with `--remember` to keep the correction in the hints file (see "Correcting a match" in CONFIG.md) | — | +| 9 | A tagged ASIN Audible has no product for (skeleton answer) or that names another book ends the Audible search: the per-ASIN endpoint ignores title/author and no plain search followed. Fixed in 3.0.2: fall back to the title/author search through the same gates, as pinned ASINs already did (see CONFIG.md, release-name parsing) | — | | 7 | mousehole cookie integration. Fixed: `mousehole_state_file` / `MOUSEHOLE_STATE_FILE`, reads mousehole's v2 (`cookie`) and legacy (`currentCookie`) state files (PR #24 read only the legacy key) | #24 | ## Regression replay diff --git a/myx_classes.py b/myx_classes.py index b4d3cfb..3e46bed 100644 --- a/myx_classes.py +++ b/myx_classes.py @@ -702,7 +702,16 @@ def _rankAudible(self, books, book, keys, cfg, hintCandidates=False, requireTitl print(f"Found {len(self.audibleMatches)} Audible match(es)\n\n") if interactive: - #display choices to user to pick from + #display choices to user to pick from; a skeleton per-ASIN answer is not a choice (it would be + #auto-accepted as the lone result and file the book under an empty title) + usable = [] + for product in books: + abook = myx_audible.product2Book(product) + if abook.title: + usable.append(product) + else: + print(f"\tIgnoring Audible result {abook.asin} without a title (incomplete catalog entry)") + books = usable count = len(books) if (count == 1): self.bestAudibleMatch=myx_audible.product2Book(books[0]) @@ -783,7 +792,45 @@ def _rankAudible(self, books, book, keys, cfg, hintCandidates=False, requireTitl self.bestAudibleMatch=abook return self.bestAudibleMatch - def _audibleAttempts(self, book, cfg, searchAsin): + def _runAudibleAttempts(self, client, book, cfg, searchAsin, language, noAsin=False): + """Run the attempt ladder for `book` (with `searchAsin` on the attempts that carry an ASIN; `noAsin` strips + the release-name ASIN too) until one attempt yields a match. Sets bestAudibleMatch / matchAttempt; returns + (products of the last attempt made, the ASIN any attempt was directed at or "", whether Audible answered + that ASIN lookup: False when the request failed, so the caller can tell a dead ASIN from an outage).""" + add_narrators = bool(cfg.get("Config/flags/add_narrators")) + fixid3 = bool(cfg.get("Config/flags/fixid3")) + hintedTitle = bool(self.hint and self.hint.get("title")) + books = [] + attempts = self._audibleAttempts(book, cfg, searchAsin, noAsin=noAsin) + usedAsin = next((sAsin for _, _, sAsin, _ in attempts if sAsin), "") + asinAnswered = False + for label, sBook, sAsin, requireTitle in attempts: + if label == "swapped": + print(f"No match; retrying with the release name read the other way round: title:{sBook.title!r} authors:{[a.name for a in sBook.authors]}") + elif label == "parsed-authors": + print(f"No match; retrying with the authors from the release name: {[a.name for a in sBook.authors]}") + elif label == "title-only": + print("No match; retrying the Audible search with the title only") + elif label == "legacy" and len(attempts) > 1: + print("No match; retrying with the file's own tags as before") + if label == "legacy" and ((len(sBook.title) == 0) or (fixid3)) and not hintedTitle: + #upstream: derive a title from the folder name when the tag is empty (mutates the tag title, as upstream did) + sBook.title = myx_utilities.getAltTitle (self.name, sBook, cfg) + keys = self._audibleSearchKeys(sBook, cfg) + if add_narrators: + books=myx_audible.getAudibleBook (client, cfg, asin=sAsin, title=keys["title"], authors=keys["authors"], narrators=keys["narrators"], keywords=keys["keywords"], language=language, refresh=self.refresh) + else: + books=myx_audible.getAudibleBook (client, cfg, asin=sAsin, title=keys["title"], authors=keys["authors"], keywords=keys["keywords"], language=language, refresh=self.refresh) + if sAsin and books: + asinAnswered = True # a skeleton is an answer; an exception in getAudibleBook yields [] + #title-only: the title is verified by the gate and no author is known to score with, so a runtime + #within tolerance is accepted on its own (pickBest requireRate=False) + if self._rankAudible(books, sBook, keys, cfg, requireTitle=requireTitle, runtimeAlone=(label == "title-only")) is not None: + self.matchAttempt = label + break + return books, usedAsin, asinAnswered + + def _audibleAttempts(self, book, cfg, searchAsin, noAsin=False): """The ordered search attempts for a book: (label, Book to search with, asin, requireTitle). With usable tags (or with parsing disabled) there is exactly one attempt, upstream's. When the release @@ -799,7 +846,7 @@ def _audibleAttempts(self, book, cfg, searchAsin): if not (hintedTitle or hintedAuthors or interactive): parsedBook, parsedApplied = self.applyParsedName(book, cfg) if parsedApplied: - pAsin = parsedBook.asin if ("asin" in parsedApplied and not searchAsin) else searchAsin + pAsin = "" if noAsin else (parsedBook.asin if ("asin" in parsedApplied and not searchAsin) else searchAsin) # title OR authors from the release name: the author-only gate would accept that # 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) @@ -856,6 +903,9 @@ def getAudibleBooks(self, client, book, cfg): searchAsin = "" hintCandidates = list(self.hint.get("candidates", [])) if self.hint else [] + #_rankAudible returns bestAudibleMatch as it stands: a value left by an earlier search on this book would make + #the first rung of this one look like a match. Every caller reaches here to search afresh. + self.bestAudibleMatch = None if (book is not None): book = self.searchBookFromHint(book) language=book.language @@ -872,29 +922,23 @@ def getAudibleBooks(self, client, book, cfg): if self._rankAudible(books, book, keys, cfg, hintCandidates=True) is not None: self.matchAttempt = "candidates" else: - attempts = self._audibleAttempts(book, cfg, searchAsin) - for label, sBook, sAsin, requireTitle in attempts: - if label == "swapped": - print(f"No match; retrying with the release name read the other way round: title:{sBook.title!r} authors:{[a.name for a in sBook.authors]}") - elif label == "parsed-authors": - print(f"No match; retrying with the authors from the release name: {[a.name for a in sBook.authors]}") - elif label == "title-only": - print("No match; retrying the Audible search with the title only") - elif label == "legacy" and len(attempts) > 1: - print("No match; retrying with the file's own tags as before") - if label == "legacy" and ((len(sBook.title) == 0) or (fixid3)) and not hintedTitle: - #upstream: derive a title from the folder name when the tag is empty (mutates the tag title, as upstream did) - sBook.title = myx_utilities.getAltTitle (self.name, sBook, cfg) - keys = self._audibleSearchKeys(sBook, cfg) - if add_narrators: - books=myx_audible.getAudibleBook (client, cfg, asin=sAsin, title=keys["title"], authors=keys["authors"], narrators=keys["narrators"], keywords=keys["keywords"], language=language, refresh=self.refresh) - else: - books=myx_audible.getAudibleBook (client, cfg, asin=sAsin, title=keys["title"], authors=keys["authors"], keywords=keys["keywords"], language=language, refresh=self.refresh) - #title-only: the title is verified by the gate and no author is known to score with, so a runtime - #within tolerance is accepted on its own (pickBest requireRate=False) - if self._rankAudible(books, sBook, keys, cfg, requireTitle=requireTitle, runtimeAlone=(label == "title-only")) is not None: - self.matchAttempt = label - break + origTitle = book.title # the legacy rung may overwrite it (getAltTitle); the rerun starts clean + books, usedAsin, asinAnswered = self._runAudibleAttempts(client, book, cfg, searchAsin, language) + if self.bestAudibleMatch is None and usedAsin and not asinAnswered: + #the lookup itself failed (outage, timeout): the tag may well be right, and a search now could file + #another edition of the same title for good. Leave the book for the next run, as before. + print(f"Tagged ASIN {usedAsin} could not be looked up; leaving the book unmatched for the next run") + elif self.bestAudibleMatch is None and usedAsin: + #The tag (or the [bracketed] release name) named an ASIN Audible has no usable product for (a + #skeleton {asin, asset_details, is_vvab} answer: a withdrawn or duplicate listing) or one that fails + #the title/author gate. The per-ASIN endpoint ignores the title/author parameters, so nothing else + #was tried: search as for a file without an ASIN, through the same gates. Pinned ASINs already fall + #back this way (acceptPinnedAsin). + print(f"Tagged ASIN {usedAsin} gave no usable Audible match; searching by title and author instead") + book.title = origTitle + books, _, _ = self._runAudibleAttempts(client, book, cfg, "", language, noAsin=True) + if self.bestAudibleMatch is not None: + self.matchAttempt = f"asin-fallback:{self.matchAttempt}" #end if if (books is not None): diff --git a/tests/test_matching.py b/tests/test_matching.py index 1eacb59..0f8dceb 100644 --- a/tests/test_matching.py +++ b/tests/test_matching.py @@ -73,6 +73,136 @@ def test_skeleton_answer_for_pinned_asin_falls_back_to_normal_search(self): self.assertEqual(best.asin, "B0GVLGC2X8") # found by the ordinary search instead +class TaggedAsinFallbackTest(unittest.TestCase): + """A file whose tags carry an ASIN Audible has no product for (2026-09-22: "This Book Made Me Think of You" tagged + B0G2TK17DS, a skeleton on every marketplace) must still be found by its title and author, as a tagless file is.""" + + def setUp(self): + self.real = product("B0FBHZK5V7", "This Book Made Me Think of You", ["Libby Page"], 626) + self.id3 = id3_book("This Book Made Me Think of You", ["Libby Page"], 626.8 * 60, asin="B0G2TK17DS") + + def test_dead_tagged_asin_falls_back_to_title_and_author_search(self): + with tempfile.TemporaryDirectory() as td: + cfg = FakeConfig(td) + client = FakeAudible(by_asin={}, search=[self.real]) + mb = mambook("This Book Made Me Think of You - Libby Page.m4b", self.id3) + best, out = run(mb, client, cfg) + self.assertIn("Ignoring Audible result B0G2TK17DS without a title (incomplete catalog entry)", out) + self.assertIn("Tagged ASIN B0G2TK17DS gave no usable Audible match; searching by title and author instead", out) + self.assertIsNotNone(best) + self.assertEqual(best.asin, "B0FBHZK5V7") + # the dead ASIN is looked up once; the retry is a catalog search, never the same per-ASIN URL again + per_asin = [u for u, _ in client.calls if u.endswith("/B0G2TK17DS")] + self.assertEqual(len(per_asin), 1) + self.assertTrue(any(u.endswith("/catalog/products") and p.get("asin") == "" for u, p in client.calls)) + + def test_tagged_asin_pointing_at_another_book_falls_back_too(self): + # a live but wrong tag: the product exists, fails the title/author gate, and the search finds the real one + other = product("B0OTHER000", "The Memory Keeper of Kyiv", ["Erin Litteken"], 700) + with tempfile.TemporaryDirectory() as td: + cfg = FakeConfig(td) + client = FakeAudible(by_asin={"B0OTHER000": other}, search=[self.real]) + mb = mambook("This Book Made Me Think of You - Libby Page.m4b", + id3_book("This Book Made Me Think of You", ["Libby Page"], 626.8 * 60, asin="B0OTHER000")) + best, out = run(mb, client, cfg) + self.assertIn("Tagged ASIN B0OTHER000 gave no usable Audible match", out) + self.assertEqual(best.asin, "B0FBHZK5V7") + + def test_fallback_still_applies_the_usual_gates(self): + # the search returns a different book of the wrong length: the fallback must not accept it + wrong = product("B0WRONG000", "A Different Book", ["Someone Else"], 300) + with tempfile.TemporaryDirectory() as td: + cfg = FakeConfig(td) + client = FakeAudible(by_asin={}, search=[wrong]) + mb = mambook("This Book Made Me Think of You - Libby Page.m4b", self.id3) + best, out = run(mb, client, cfg) + self.assertIn("Tagged ASIN B0G2TK17DS gave no usable Audible match", out) + self.assertIsNone(best) + + def test_fallback_is_recorded_in_the_attempt_label(self): + with tempfile.TemporaryDirectory() as td: + cfg = FakeConfig(td) + mb = mambook("This Book Made Me Think of You - Libby Page.m4b", self.id3) + best, _ = run(mb, FakeAudible(by_asin={}, search=[self.real]), cfg) + self.assertEqual(best.asin, "B0FBHZK5V7") + self.assertTrue(mb.matchAttempt.startswith("asin-fallback:"), mb.matchAttempt) + + def test_rerun_starts_from_the_original_tag_title_not_the_alt_title(self): + # empty tag title + dead ASIN: the first run's legacy rung derives an alt title from the release name and + # writes it into the tag Book (upstream behaviour); the fallback must search with the parsed title, not that + with tempfile.TemporaryDirectory() as td: + cfg = FakeConfig(td) + client = FakeAudible(by_asin={}, search=[self.real]) + mb = mambook("Libby Page - This Book Made Me Think of You", id3_book("", ["unknown artist"], 626.8 * 60, asin="B0G2TK17DS")) + best, out = run(mb, client, cfg) + self.assertEqual(best.asin, "B0FBHZK5V7") + searches = [p for u, p in client.calls if u.endswith("/catalog/products")] + self.assertTrue(searches) + self.assertEqual(searches[0]["title"], "This Book Made Me Think of You") + + def test_dead_bracket_asin_in_the_release_name_falls_back_too(self): + # no ASIN tag; the release name carries one in brackets and the tags are junk, so the parse supplies it + with tempfile.TemporaryDirectory() as td: + cfg = FakeConfig(td) + client = FakeAudible(by_asin={}, search=[self.real]) + mb = mambook("Libby Page - This Book Made Me Think of You [B0G2TK17DS]", + id3_book("AudioTrack 01", ["unknown artist"], 626.8 * 60)) + best, out = run(mb, client, cfg) + self.assertIn("Tagged ASIN B0G2TK17DS gave no usable Audible match", out) + self.assertEqual(best.asin, "B0FBHZK5V7") + self.assertFalse(any(p.get("asin") for u, p in client.calls if u.endswith("/catalog/products"))) + + def test_interactive_mode_does_not_auto_accept_a_skeleton(self): + with tempfile.TemporaryDirectory() as td: + cfg = FakeConfig(td, **{"Config/flags/interactive": 1}) + client = FakeAudible(by_asin={}, search=[self.real]) + mb = mambook("This Book Made Me Think of You - Libby Page.m4b", self.id3) + best, out = run(mb, client, cfg) + self.assertIn("Ignoring Audible result B0G2TK17DS without a title", out) + self.assertIn("Tagged ASIN B0G2TK17DS gave no usable Audible match", out) + self.assertEqual(best.asin, "B0FBHZK5V7") # lone real search result, auto-picked as before + + def test_a_failed_lookup_does_not_fall_back(self): + # Audible unreachable for the per-ASIN call: the tag may be right, so no search that could file another + # edition; the book stays unmatched for the next run, as before the fallback existed + class Flaky(FakeAudible): + def get(self, url, params=None): + self.calls.append((url, dict(params or {}))) + if not url.endswith("/catalog/products"): + raise ConnectionError("audible down") + return super().get(url, params) + other_edition = product("B0OTHER000", "This Book Made Me Think of You", ["Libby Page"], 540) + with tempfile.TemporaryDirectory() as td: + cfg = FakeConfig(td) + client = Flaky(by_asin={}, search=[other_edition]) + mb = mambook("This Book Made Me Think of You - Libby Page.m4b", self.id3) + best, out = run(mb, client, cfg) + self.assertIsNone(best) + self.assertIn("Tagged ASIN B0G2TK17DS could not be looked up; leaving the book unmatched for the next run", out) + self.assertFalse(any(u.endswith("/catalog/products") for u, _ in client.calls)) + + def test_a_stale_match_from_an_earlier_search_does_not_mask_a_miss(self): + with tempfile.TemporaryDirectory() as td: + cfg = FakeConfig(td) + client = FakeAudible(by_asin={}, search=[]) + mb = mambook("This Book Made Me Think of You - Libby Page.m4b", self.id3) + mb.bestAudibleMatch = myx_classes.Book(asin="B0STALE000", title="Stale") + best, out = run(mb, client, cfg) + self.assertIsNone(best) + + def test_live_tagged_asin_is_accepted_without_a_search(self): + # the common case is untouched: a good tag resolves on the first call and no catalog search is made + 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", ["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)) + + class SkeletonResultTest(unittest.TestCase): def test_skeleton_search_result_is_skipped_not_compared_against_empty_strings(self): # upstream issue #25: "Checking if or matches my book ..." then a rejection