From 776ff9112adf8d4a70dd90218d9295cbc28588e7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 14:14:54 +0000 Subject: [PATCH] mam/audible: null author_info or authors no longer abort the run getMAMBook still did len() and json.loads on author_info, so a snatched hit with author_info null (radio / uncredited) or an already-decoded object TypeError'd and skipped every later book. product2Book had the same gap for authors/narrators/category_ladders after series.sequence was hardened. Skip those fields the same way. Co-authored-by: Mancolt --- CHANGELOG.md | 6 +++ myx_audible.py | 22 ++++++++--- myx_mam.py | 77 ++++++++++++++++++++++++-------------- tests/test_mam_throttle.py | 24 ++++++++++++ tests/test_matching.py | 31 +++++++++++++++ 5 files changed, 125 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4097689..4389f16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/myx_audible.py b/myx_audible.py index e6cba44..6915319 100644 --- a/myx_audible.py +++ b/myx_audible.py @@ -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"]) @@ -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: diff --git a/myx_mam.py b/myx_mam.py index 218010c..9768864 100644 --- a/myx_mam.py +++ b/myx_mam.py @@ -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) @@ -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: diff --git a/tests/test_mam_throttle.py b/tests/test_mam_throttle.py index 9e4a032..2b7ab5c 100644 --- a/tests/test_mam_throttle.py +++ b/tests/test_mam_throttle.py @@ -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) diff --git a/tests/test_matching.py b/tests/test_matching.py index f7bce77..11df57d 100644 --- a/tests/test_matching.py +++ b/tests/test_matching.py @@ -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")