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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions myx_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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(
Expand All @@ -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 = []
Expand Down
46 changes: 46 additions & 0 deletions tests/test_matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading