Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 7 additions & 3 deletions myx_audible.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down
8 changes: 7 additions & 1 deletion myx_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"
Expand Down
11 changes: 10 additions & 1 deletion myx_mam.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions tests/test_mam_throttle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions tests/test_matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")])
8 changes: 8 additions & 0 deletions tests/test_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"}),
Expand Down
Loading