diff --git a/CHANGELOG.md b/CHANGELOG.md index c52abb9..44587f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ first release is 3.0.0 because exit codes and the cookie store changed in ways a way it already walks past `MP3/` and `cd1/`. `cd1/MP3/64k/01.mp3` and `cd2/MP3/64k/01.mp3` also get distinct disc subfolders; they used to collide in a flat `Author/Title/` folder and the second disc was skipped. +- An Audible series entry without a `sequence` (or MAM `series_info` with only the series name) aborted the + whole run in `product2Book` / `getMAMBook`. Unnumbered series entries now keep the series name and use + `in_series_no_part`; they no longer file as `Series #None - Title`. ## 3.0.2 - 2026-09-22 diff --git a/myx_audible.py b/myx_audible.py index 1f02610..e6cba44 100644 --- a/myx_audible.py +++ b/myx_audible.py @@ -141,9 +141,13 @@ def product2Book(product): if 'publisher_name' in product: book.publisher=str(product["publisher_name"]) if 'publication_datetime' in product: book.publishYear=str(product["publication_datetime"]) if 'issue_date' in product: book.releaseDate=str(product["issue_date"]) - if 'series' in product: - for s in product["series"]: - book.series.append(myx_classes.Series(str(s["title"]), str(s["sequence"]))) + if 'series' in product: + # sequence is optional: companion books and unnumbered series entries omit it or send null. + # Requiring the key crashed the whole run in _rankAudible; str(None) filed as "#None". + for s in product["series"] or []: + if not isinstance(s, dict) or not s.get("title"): + continue + 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"]: diff --git a/myx_classes.py b/myx_classes.py index 1fbc017..e39548f 100644 --- a/myx_classes.py +++ b/myx_classes.py @@ -37,7 +37,13 @@ class Series: name:str="" part:str="" separator:str="" - + + def __post_init__(self): + # Audible omits `sequence` or sends null for an unnumbered series entry; MAM may send the part as an + # int. Either must be an empty/string part so getSeriesPart and in_series_no_part do not crash or + # file the book as "Series #None - Title". + self.part = "" if self.part is None else str(self.part) + def getSeriesPart(self): if (len(self.part.strip()) > 0): return f"{self.name} {self.separator}{str(self.part)}" diff --git a/myx_mam.py b/myx_mam.py index c9b1f9d..218010c 100644 --- a/myx_mam.py +++ b/myx_mam.py @@ -212,10 +212,19 @@ def getMAMBook(cfg, titleFilename="", authors="", extension="", refresh=False): 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("'", "'") - book.series.append(myx_classes.Series(seriesName, s[1])) + # 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 cf19961..9e4a032 100644 --- a/tests/test_mam_throttle.py +++ b/tests/test_mam_throttle.py @@ -104,6 +104,36 @@ def test_empty_answer_is_cached_and_not_requeried(self): self.assertEqual(r2, []) # served from the (empty) cache entry self.assertEqual(posts_after_first, posts_after_second, "second identical search must not hit MAM") + def test_series_info_without_a_part_does_not_abort_the_run(self): + # MAM sends {"id": ["Name", "5"]} normally; an unnumbered series is ["Name"] or ["Name", null]. + # s[1] used to IndexError (or become "None") and abort getMAMBook for every later book. + with tempfile.TemporaryDirectory() as td: + cfg = FakeConfig(td) + FakeSession.answer = _Resp(200, "x", {"data": [ + {"id": 1, "title": "Three More Novellas", "my_snatched": 1, + "author_info": '{"1": "Lee Child"}', + "series_info": '{"9": ["Jack Reacher"]}'}, + {"id": 2, "title": "Killing Floor", "my_snatched": 1, + "author_info": '{"1": "Lee Child"}', + "series_info": '{"9": ["Jack Reacher", null]}'}, + {"id": 3, "title": "Die Trying", "my_snatched": 1, + "author_info": '{"1": "Lee Child"}', + "series_info": '{"9": ["Jack Reacher", 2]}'}, + # entries that are not a series at all: null, a number, a bare string (list() would split it + # into letters), a null name; each is skipped, the book keeps its other series + {"id": 4, "title": "Tripwire", "my_snatched": 1, + "author_info": '{"1": "Lee Child"}', + "series_info": '{"9": null, "10": 5, "11": "Jack Reacher", "12": [null, "3"], "13": ["Jack Reacher", "3"]}'}, + ], "total": 4}) + with contextlib.redirect_stdout(io.StringIO()): + books = myx_mam.getMAMBook(cfg, titleFilename="T.m4b", extension='"m4b"') + self.assertEqual([(b.title, [(s.name, s.part) for s in b.series]) for b in books], [ + ("Three More Novellas", [("Jack Reacher", "")]), + ("Killing Floor", [("Jack Reacher", "")]), + ("Die Trying", [("Jack Reacher", "2")]), + ("Tripwire", [("Jack Reacher", "3")]), + ]) + 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 0f8dceb..f7bce77 100644 --- a/tests/test_matching.py +++ b/tests/test_matching.py @@ -734,3 +734,35 @@ def test_identical_queries_are_not_repeated(self): self.assertIsNone(best) queries = [(c[1].get("title"), c[1].get("author")) for c in client.calls] self.assertEqual(len(queries), len(set(queries)), queries) + + +class UnnumberedSeriesTest(unittest.TestCase): + """Audible series.sequence is optional; requiring it aborted the run in _rankAudible.""" + + def test_product_with_series_but_no_sequence_is_usable(self): + import myx_audible + p = product("B0SERIES001", "Three More Novellas", ["Lee Child"], 240, + series=[{"title": "Jack Reacher"}]) + book = myx_audible.product2Book(p) + self.assertEqual([(s.name, s.part) for s in book.series], [("Jack Reacher", "")]) + p = product("B0SERIES002", "Killing Floor", ["Lee Child"], 600, + series=[{"title": "Jack Reacher", "sequence": None}]) + book = myx_audible.product2Book(p) + self.assertEqual([(s.name, s.part) for s in book.series], [("Jack Reacher", "")]) + p = product("B0SERIES003", "Die Trying", ["Lee Child"], 600, + series=[{"title": "Jack Reacher", "sequence": 2}]) + book = myx_audible.product2Book(p) + self.assertEqual([(s.name, s.part) for s in book.series], [("Jack Reacher", "2")]) + + def test_ranking_does_not_crash_when_a_hit_omits_sequence(self): + with tempfile.TemporaryDirectory() as td: + cfg = FakeConfig(td) + hit = product("B0SERIES001", "Three More Novellas", ["Lee Child"], 240, + series=[{"title": "Jack Reacher"}]) + client = FakeAudible(search=[hit]) + mb = mambook("Three More Novellas - Lee Child.m4b", + id3_book("Three More Novellas", ["Lee Child"], 240 * 60)) + best, _ = run(mb, client, cfg) + self.assertIsNotNone(best) + self.assertEqual(best.asin, "B0SERIES001") + self.assertEqual([(s.name, s.part) for s in best.series], [("Jack Reacher", "")]) diff --git a/tests/test_names.py b/tests/test_names.py index 99afe4d..d84f561 100644 --- a/tests/test_names.py +++ b/tests/test_names.py @@ -408,8 +408,16 @@ def test_series_without_a_part_uses_the_no_part_template(self): # upstream #27: "Jack Reacher # - Three More Novellas" self.assertEqual(self.target(""), "/lib/Lee Child/Jack Reacher/Jack Reacher - Three More Novellas") self.assertEqual(self.target(" "), "/lib/Lee Child/Jack Reacher/Jack Reacher - Three More Novellas") + self.assertEqual(self.target(None), "/lib/Lee Child/Jack Reacher/Jack Reacher - Three More Novellas") self.assertEqual(self.target("23.5"), "/lib/Lee Child/Jack Reacher/Jack Reacher #23.5 - Three More Novellas") self.assertEqual(self.target("3"), "/lib/Lee Child/Jack Reacher/Jack Reacher #3 - Three More Novellas") + self.assertEqual(self.target(5), "/lib/Lee Child/Jack Reacher/Jack Reacher #5 - Three More Novellas") + + def test_series_part_none_or_int_does_not_crash_getSeriesPart(self): + import myx_classes + self.assertEqual(myx_classes.Series("Jack Reacher", None).getSeriesPart(), "Jack Reacher") + self.assertEqual(myx_classes.Series("Jack Reacher", 5).getSeriesPart(), "Jack Reacher 5") + self.assertEqual(myx_classes.Series("Jack Reacher", 5).part, "5") def test_template_is_configurable_and_the_old_layout_can_be_kept(self): self.assertEqual(self.target("", **{"Config/target_path/in_series_no_part": "{author}/{series}/{title}"}),