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

## Unreleased

### Fixed
- A MAM search hit with `author_info` null (or already a JSON object, or unparsable) aborted `getMAMBook`
for every later book. The same run-killing TypeError/KeyError happened in `product2Book` when Audible sent
`authors`/`narrators`/`category_ladders` as null or an entry without `name`. Those fields are skipped now,
the way `series_info` / `series.sequence` already were.

## 3.0.4 - 2026-09-22

### Fixed
Expand Down
22 changes: 16 additions & 6 deletions myx_audible.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,17 @@ def product2Book(product):
if 'subtitle' in product: book.subtitle=str(product["subtitle"])
if 'publisher_summary' in product: book.description=str(product["publisher_summary"])
if 'runtime_length_min' in product: book.length=product["runtime_length_min"]
if 'authors' in product:
for author in product["authors"]:
if 'authors' in product:
# null, a bare string, or an entry without `name` used to TypeError/KeyError in _rankAudible
# and abort the run; skip the bad entry the same way series already skips a missing title
for author in product["authors"] or []:
if not isinstance(author, dict) or not author.get("name"):
continue
book.authors.append(myx_classes.Contributor(str(author["name"])))
if 'narrators' in product:
for narrator in product["narrators"]:
if 'narrators' in product:
for narrator in product["narrators"] or []:
if not isinstance(narrator, dict) or not narrator.get("name"):
continue
book.narrators.append(myx_classes.Contributor(str(narrator["name"])))
if 'publisher_name' in product: book.publisher=str(product["publisher_name"])
if 'publication_datetime' in product: book.publishYear=str(product["publication_datetime"])
Expand All @@ -150,9 +156,13 @@ def product2Book(product):
book.series.append(myx_classes.Series(str(s["title"]), s.get("sequence")))
if 'language' in product: book.language=str(product ["language"])
if 'category_ladders' in product:
for cl in product["category_ladders"]:
for i, item in enumerate(cl["ladder"]):
for cl in product["category_ladders"] or []:
if not isinstance(cl, dict):
continue
for i, item in enumerate(cl.get("ladder") or []):
#the first one is genre, the rest are tags
if not isinstance(item, dict) or not item.get("name"):
continue
if (i==0):
book.genres.append(item["name"])
else:
Expand Down
77 changes: 48 additions & 29 deletions myx_mam.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,24 @@ def searchMAM(cfg, titleFilename, authors, extension, refresh=False):

return None

def _mamJsonObject(value):
"""MAM stores author_info / narrator_info / series_info as a JSON object string. Some answers send
the object already decoded, or null / a number / a bare string. Return a dict, or None to skip.
Never raise: one bad field used to abort getMAMBook for every later book."""
if value is None or value == "":
return None
if isinstance(value, dict):
parsed = value
elif isinstance(value, str):
try:
parsed = json.loads(value)
except (TypeError, ValueError):
return None
else:
return None
return parsed if isinstance(parsed, dict) else None


def getMAMBook(cfg, titleFilename="", authors="", extension="", refresh=False):
books=[]
mamBook=searchMAM(cfg, titleFilename, authors, extension, refresh=refresh)
Expand All @@ -195,36 +213,37 @@ def getMAMBook(cfg, titleFilename="", authors="", extension="", refresh=False):
book.asin=str(b["asin"])
if 'title' in b:
book.title=str(b["title"])
if 'author_info'in b:
#format {id:author, id:author}
if len(b["author_info"]):
authors = json.loads(b["author_info"])
for author in authors.values():
book.authors.append(myx_classes.Contributor(str(author)))
if 'narrator_info'in b:
#format {id:narrator, id:narrator}
if ((not b["narrator_info"] is None) and len(b["narrator_info"])):
narrators = json.loads(b["narrator_info"])
for narrator in narrators.values():
book.narrators.append(myx_classes.Contributor(str(narrator)))
if 'series_info'in b:
# format {id:author, id:author}. null / already-decoded object / bad JSON used to TypeError
# here (narrator_info and series_info were guarded for null in #24; author_info was not)
author_info = _mamJsonObject(b.get("author_info"))
if author_info:
for author in author_info.values():
if author in (None, ""):
continue
book.authors.append(myx_classes.Contributor(str(author)))
narrator_info = _mamJsonObject(b.get("narrator_info"))
if narrator_info:
for narrator in narrator_info.values():
if narrator in (None, ""):
continue
book.narrators.append(myx_classes.Contributor(str(narrator)))
series_info = _mamJsonObject(b.get("series_info"))
if series_info:
#format {"35598": ["Kat Dubois", "5"]}
if ((not b["series_info"] is None) and len(b["series_info"])):
series_info = json.loads(b["series_info"])
for series in series_info.values():
# a value that is not a list (null, a number, a bare string that list() would split into
# letters) or one with no name is not a series entry: skip it rather than abort the run
if not isinstance(series, (list, tuple)):
continue
s=list(series)
if not s or s[0] in (None, ""):
continue
seriesName = str(s[0])
seriesName = seriesName.replace("'", "'")
# a series without a part is ["Name"] or ["Name", null]; s[1] used to IndexError
# and abort the run, or become "None" and file as "Series #None - Title"
part = s[1] if len(s) > 1 else ""
book.series.append(myx_classes.Series(seriesName, part))
for series in series_info.values():
# a value that is not a list (null, a number, a bare string that list() would split into
# letters) or one with no name is not a series entry: skip it rather than abort the run
if not isinstance(series, (list, tuple)):
continue
s=list(series)
if not s or s[0] in (None, ""):
continue
seriesName = str(s[0])
seriesName = seriesName.replace("'", "'")
# a series without a part is ["Name"] or ["Name", null]; s[1] used to IndexError
# and abort the run, or become "None" and file as "Series #None - Title"
part = s[1] if len(s) > 1 else ""
book.series.append(myx_classes.Series(seriesName, part))
if 'lang_code' in b:
book.language=myx_utilities.getLanguage((b["lang_code"]))
if 'my_snatched' in b:
Expand Down
24 changes: 24 additions & 0 deletions tests/test_mam_throttle.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,30 @@ def test_series_info_without_a_part_does_not_abort_the_run(self):
("Tripwire", [("Jack Reacher", "3")]),
])

def test_author_info_null_or_already_decoded_does_not_abort_the_run(self):
# series_info and narrator_info were guarded for null in #24; author_info still did len(None)
# or json.loads(dict) and aborted getMAMBook for every later book. Radio / uncredited torrents
# send null; some answers already have the object decoded.
with tempfile.TemporaryDirectory() as td:
cfg = FakeConfig(td)
FakeSession.answer = _Resp(200, "x", {"data": [
{"id": 1, "title": "Radio Hour", "my_snatched": 1, "author_info": None},
{"id": 2, "title": "Killing Floor", "my_snatched": 1, "author_info": {"1": "Lee Child"}},
{"id": 3, "title": "Die Trying", "my_snatched": 1, "author_info": '{"1": "Lee Child", "2": null}'},
{"id": 4, "title": "Tripwire", "my_snatched": 1, "author_info": 5},
{"id": 5, "title": "The Visitor", "my_snatched": 1, "author_info": "not-json",
"narrator_info": None, "series_info": {"9": ["Jack Reacher", "4"]}},
], "total": 5})
with contextlib.redirect_stdout(io.StringIO()):
books = myx_mam.getMAMBook(cfg, titleFilename="T.m4b", extension='"m4b"')
self.assertEqual([(b.title, [a.name for a in b.authors], [s.name for s in b.series]) for b in books], [
("Radio Hour", [], []),
("Killing Floor", ["Lee Child"], []),
("Die Trying", ["Lee Child"], []),
("Tripwire", [], []),
("The Visitor", [], ["Jack Reacher"]),
])

def test_unsnatched_answer_is_cached_but_filtered(self):
with tempfile.TemporaryDirectory() as td:
cfg = FakeConfig(td)
Expand Down
31 changes: 31 additions & 0 deletions tests/test_matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -766,3 +766,34 @@ def test_ranking_does_not_crash_when_a_hit_omits_sequence(self):
self.assertIsNotNone(best)
self.assertEqual(best.asin, "B0SERIES001")
self.assertEqual([(s.name, s.part) for s in best.series], [("Jack Reacher", "")])

def test_product_with_null_authors_or_ladders_does_not_abort_ranking(self):
# series.sequence was hardened in #24; authors: null, an author without `name`, or
# category_ladders: null still TypeError/KeyError'd in product2Book and killed the run
import myx_audible
p = product("B0AUTHOR01", "Killing Floor", ["Lee Child"], 600)
p["authors"] = None
p["narrators"] = None
p["category_ladders"] = None
book = myx_audible.product2Book(p)
self.assertEqual(book.title, "Killing Floor")
self.assertEqual(book.authors, [])
p = product("B0AUTHOR02", "Die Trying", ["Lee Child"], 600)
p["authors"] = [{"asin": "B00AUTHOR"}, {"name": "Lee Child"}]
p["category_ladders"] = [{"ladder": None}, {"ladder": [{"name": "Mystery"}, {"name": "Thriller"}]}]
book = myx_audible.product2Book(p)
self.assertEqual([a.name for a in book.authors], ["Lee Child"])
self.assertEqual(book.genres, ["Mystery"])
self.assertEqual(book.tags, ["Thriller"])
with tempfile.TemporaryDirectory() as td:
cfg = FakeConfig(td)
hit = product("B0AUTHOR01", "Killing Floor", ["Lee Child"], 600)
hit["authors"] = None
hit["category_ladders"] = None
client = FakeAudible(search=[hit])
mb = mambook("Killing Floor - Lee Child.m4b",
id3_book("Killing Floor", ["Lee Child"], 600 * 60))
best, _ = run(mb, client, cfg)
# no authors on the hit: the title gate still accepts it
self.assertIsNotNone(best)
self.assertEqual(best.asin, "B0AUTHOR01")
Loading