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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ first release is 3.0.0 because exit codes and the cookie store changed in ways a

## Unreleased

### Fixed
- MAM ranking (and interactive pick) sat under `Config/flags/verbose`, so a run with verbose off left
`bestMAMMatch` unset even when MAM returned snatched hits. `metadata=mam` then filed nothing;
`mam-audible` skipped MAM and foreign-language detection. Ranking now runs the same way as Audible's.

## 3.0.4 - 2026-09-22

### Fixed
Expand Down
119 changes: 60 additions & 59 deletions myx_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1115,67 +1115,68 @@ def getMAMBooks(self, cfg, bookFile:BookFile):
if (verbose):
print(f"Found {len(self.mamMatches)} MAM match(es)\n\n")

if interactive:
#display choices to user to pick from
count = len(books)
if (count == 1):
self.bestMAMMatch=books[0]

elif (count > 1):
booksFound=[]
choices=[]
for abook in books:
booksFound.append(abook)

#display
print(f"[{len(booksFound)}] {abook.title}({abook.releaseDate}) by {abook.getAuthors()}, ASIN: {abook.asin}, Language: {abook.language}")
choices.append (len(booksFound))

#add none
print(f"[0] None of the above")
choices.append (0)

choice = myx_utilities.promptChoice (f"Pick a match [0-{len(booksFound)}]: ", choices)
if choice != 0:
if verbose: print(f"You've selected [{choice}] {booksFound[choice-1].title}({booksFound[choice-1].releaseDate}) by {booksFound[choice-1].getAuthors()}, ASIN: {booksFound[choice-1].asin}, Language: {booksFound[choice-1].language}")
self.bestMAMMatch=booksFound[choice-1]
# same shape as _rankAudible: verbose only gates the count line, not ranking
if interactive:
#display choices to user to pick from
count = len(books)
if (count == 1):
self.bestMAMMatch=books[0]

elif (count > 1):
booksFound=[]
choices=[]
for abook in books:
booksFound.append(abook)

#display
print(f"[{len(booksFound)}] {abook.title}({abook.releaseDate}) by {abook.getAuthors()}, ASIN: {abook.asin}, Language: {abook.language}")
choices.append (len(booksFound))

#add none
print(f"[0] None of the above")
choices.append (0)

choice = myx_utilities.promptChoice (f"Pick a match [0-{len(booksFound)}]: ", choices)
if choice != 0:
if verbose: print(f"You've selected [{choice}] {booksFound[choice-1].title}({booksFound[choice-1].releaseDate}) by {booksFound[choice-1].getAuthors()}, ASIN: {booksFound[choice-1].asin}, Language: {booksFound[choice-1].language}")
self.bestMAMMatch=booksFound[choice-1]
else:
self.bestMAMMatch=None

else:
bestMatchRate=0
#find the best match
print(f"Finding the best MAM match out of {len(books)} results")
targetBook = '|'.join([book.title, book.getAuthors(), book.getSeriesParts()])

for abook in books:
#if this book is snatched, include in the match
if abook.snatched:
#the author is known, check if this book is this authors book
#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:
mamBook = '|'.join([abook.getAuthors(), abook.getCleanTitle(), abook.getSeriesParts()])
if add_narrators:
mamBook = '|'.join([mamBook, abook.getNarrators()])
elif myx_utilities.isThisMyBookTitle(rankTitle, abook, cfg):
mamBook = '|'.join([abook.getAuthors(), abook.getCleanTitle(), abook.getSeriesParts()])
if add_narrators:
mamBook = '|'.join([mamBook, abook.getNarrators()])
else:
self.bestMAMMatch=None
print (f"This book doesn't have a matching title or author, checking the next book...")
continue

else:
bestMatchRate=0
#find the best match
print(f"Finding the best MAM match out of {len(books)} results")
targetBook = '|'.join([book.title, book.getAuthors(), book.getSeriesParts()])

for abook in books:
#if this book is snatched, include in the match
if abook.snatched:
#the author is known, check if this book is this authors book
#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:
mamBook = '|'.join([abook.getAuthors(), abook.getCleanTitle(), abook.getSeriesParts()])
if add_narrators:
mamBook = '|'.join([mamBook, abook.getNarrators()])
elif myx_utilities.isThisMyBookTitle(rankTitle, abook, cfg):
mamBook = '|'.join([abook.getAuthors(), abook.getCleanTitle(), abook.getSeriesParts()])
if add_narrators:
mamBook = '|'.join([mamBook, abook.getNarrators()])
else:
print (f"This book doesn't have a matching title or author, checking the next book...")
continue

#include this book in the comparison
matchRate=myx_utilities.fuzzymatch(targetBook, mamBook)
abook.matchRate=matchRate[fuzzy_match]

print(f"\tMatch Rate: {matchRate}\n\tSearch: {targetBook}\n\tResult: {mamBook}\n\tBest Match Rate: {bestMatchRate}\n")

if (matchRate[fuzzy_match] > bestMatchRate):
bestMatchRate=matchRate[fuzzy_match]
self.bestMAMMatch=abook
#include this book in the comparison
matchRate=myx_utilities.fuzzymatch(targetBook, mamBook)
abook.matchRate=matchRate[fuzzy_match]

print(f"\tMatch Rate: {matchRate}\n\tSearch: {targetBook}\n\tResult: {mamBook}\n\tBest Match Rate: {bestMatchRate}\n")

if (matchRate[fuzzy_match] > bestMatchRate):
bestMatchRate=matchRate[fuzzy_match]
self.bestMAMMatch=abook
else:
#no metadata, get the first match?
if len(self.mamMatches):
Expand Down
36 changes: 36 additions & 0 deletions tests/test_matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,42 @@ def test_different_mam_title_by_the_parsed_author_is_rejected(self):
self.assertIn("This book doesn't have a matching title or author", out)


class MamRankingWithoutVerboseTest(unittest.TestCase):
"""MAM ranking must run when Config/flags/verbose is off (it used to sit under that print)."""

def _run(self, name, id3, hits, verbose=0):
with tempfile.TemporaryDirectory() as td:
cfg = FakeConfig(td, **{"Config/flags/verbose": verbose})
mb = mambook(name, id3)
out = io.StringIO()
with contextlib.redirect_stdout(out), patch("myx_mam.getMAMBook", return_value=hits):
best = mb.getMAMBooks(cfg, mb.files[0])
return mb, best, out.getvalue()

def test_snatched_hit_is_accepted_when_verbose_is_off(self):
mb, best, out = self._run(
"Lee Child - Night School",
id3_book("Night School", ["Lee Child"], 400 * 60),
[_mam_book("Night School", ["Lee Child"])],
)
self.assertIsNotNone(best)
self.assertEqual(best.title, "Night School")
self.assertTrue(mb.matchFound())
self.assertIn("Finding the best MAM match", out)
self.assertNotIn("Found 1 MAM match(es)", out)

def test_wrong_title_is_still_rejected_when_verbose_is_off(self):
# unknown artist -> authors come from the release name, so the title gate applies
mb, best, out = self._run(
"Lee Child - The Enemy",
id3_book("The Enemy", ["unknown artist"], 400 * 60),
[_mam_book("Killing Floor", ["Lee Child"])],
)
self.assertIsNone(best)
self.assertFalse(mb.matchFound())
self.assertIn("This book doesn't have a matching title or author", out)


class NarratorInArtistTagTest(unittest.TestCase):
def secrets(self):
return [product("B0BYRNE000", "The Secret", ["Rhonda Byrne"], 264),
Expand Down
Loading