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

## Unreleased

## 3.0.2 - 2026-09-22

### Fixed
- A file tagged with an ASIN that Audible has no product for (a withdrawn or duplicate listing answered with the
skeleton `{asin, asset_details, is_vvab}`), or one that points at a different book, was left unmatched: the
per-ASIN lookup ignores the title/author parameters and nothing else was tried. Such a file is now searched by
its title and author like a tagless one, through the same title/author and duration gates
(`Tagged ASIN X gave no usable Audible match; searching by title and author instead`). Pinned ASINs already
fell back this way. Seen 2026-09-22 with *This Book Made Me Think of You* tagged `B0G2TK17DS` (dead on every
marketplace); the fallback finds `B0FBHZK5V7`, duration difference 0 min. A live tag still resolves on the
first call with no extra query. Applies to a `[bracketed]` ASIN in the release name as well. The JSON run log
records such a match as `attempt: asin-fallback:<rung>`.
- Interactive mode auto-accepted a skeleton per-ASIN answer as the lone result and would have filed the book
under an empty title; skeletons are now dropped before the choice is offered.

## 3.0.1 - 2026-09-19

### Fixed
Expand Down
6 changes: 6 additions & 0 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,12 @@ match the next one runs, bounded and cached like any other query: the swapped re
exactly as upstream performed it with the file's own tags, so a wrong parse can add a match but never lose one.
The MAM ranking uses the same parsed values; the MAM query string is unchanged.

An ASIN in the tags (or in `[brackets]` in the release name) is looked up directly first. When Audible has no
product for it (a withdrawn or duplicate listing is answered with a bare `{asin, asset_details, is_vvab}` record)
or the product fails the title/author gate, the file is searched by title and author as if it carried no ASIN,
with the same gates and duration check; the log says `Tagged ASIN X gave no usable Audible match; searching by
title and author instead`. A live tag is accepted on the first call and costs no extra query.


## Caching, MAM traffic, `--refresh`

Expand Down
1 change: 1 addition & 0 deletions docs/FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Scripts built on upstream booktree keep working:
| 6 | `Series # - Title` folders: the log writes `seriesparts` as `Name part`, the log reader splits on `#`. Fixed: the reader pairs `seriesparts` with `series`, decimal parts are logged as `17.5`, and a series entry without a part is filed with `target_path/in_series_no_part` (default `Series - Title`) | #27 |
| 6b | Multi-disc releases (`cd1/`, `Disc 01/`) are grouped per disc folder, so each disc is matched on its own, the runtime evidence is one disc long (the wrong edition can win), and `book` logs as `cd1..cdN`. Two releases that both use `cd1/` were also merged into one book. Fixed: grouping walks past disc parents to the release folder | #26 |
| 8 | Everything around a run lived in per-host wrapper scripts (ntfy summary, Audiobookshelf scan, inode de-dupe, an ASIN fixer that rewrote `fix.csv`). Added as config, off by default: `notify`, `abs`, `dedupe_roots`, `--pin RELEASE=ASIN` with `--remember` to keep the correction in the hints file (see "Correcting a match" in CONFIG.md) | — |
| 9 | A tagged ASIN Audible has no product for (skeleton answer) or that names another book ends the Audible search: the per-ASIN endpoint ignores title/author and no plain search followed. Fixed in 3.0.2: fall back to the title/author search through the same gates, as pinned ASINs already did (see CONFIG.md, release-name parsing) | — |
| 7 | mousehole cookie integration. Fixed: `mousehole_state_file` / `MOUSEHOLE_STATE_FILE`, reads mousehole's v2 (`cookie`) and legacy (`currentCookie`) state files (PR #24 read only the legacy key) | #24 |

## Regression replay
Expand Down
96 changes: 70 additions & 26 deletions myx_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,16 @@ def _rankAudible(self, books, book, keys, cfg, hintCandidates=False, requireTitl
print(f"Found {len(self.audibleMatches)} Audible match(es)\n\n")

if interactive:
#display choices to user to pick from
#display choices to user to pick from; a skeleton per-ASIN answer is not a choice (it would be
#auto-accepted as the lone result and file the book under an empty title)
usable = []
for product in books:
abook = myx_audible.product2Book(product)
if abook.title:
usable.append(product)
else:
print(f"\tIgnoring Audible result {abook.asin} without a title (incomplete catalog entry)")
books = usable
count = len(books)
if (count == 1):
self.bestAudibleMatch=myx_audible.product2Book(books[0])
Expand Down Expand Up @@ -783,7 +792,45 @@ def _rankAudible(self, books, book, keys, cfg, hintCandidates=False, requireTitl
self.bestAudibleMatch=abook
return self.bestAudibleMatch

def _audibleAttempts(self, book, cfg, searchAsin):
def _runAudibleAttempts(self, client, book, cfg, searchAsin, language, noAsin=False):
"""Run the attempt ladder for `book` (with `searchAsin` on the attempts that carry an ASIN; `noAsin` strips
the release-name ASIN too) until one attempt yields a match. Sets bestAudibleMatch / matchAttempt; returns
(products of the last attempt made, the ASIN any attempt was directed at or "", whether Audible answered
that ASIN lookup: False when the request failed, so the caller can tell a dead ASIN from an outage)."""
add_narrators = bool(cfg.get("Config/flags/add_narrators"))
fixid3 = bool(cfg.get("Config/flags/fixid3"))
hintedTitle = bool(self.hint and self.hint.get("title"))
books = []
attempts = self._audibleAttempts(book, cfg, searchAsin, noAsin=noAsin)
usedAsin = next((sAsin for _, _, sAsin, _ in attempts if sAsin), "")
asinAnswered = False
for label, sBook, sAsin, requireTitle in attempts:
if label == "swapped":
print(f"No match; retrying with the release name read the other way round: title:{sBook.title!r} authors:{[a.name for a in sBook.authors]}")
elif label == "parsed-authors":
print(f"No match; retrying with the authors from the release name: {[a.name for a in sBook.authors]}")
elif label == "title-only":
print("No match; retrying the Audible search with the title only")
elif label == "legacy" and len(attempts) > 1:
print("No match; retrying with the file's own tags as before")
if label == "legacy" and ((len(sBook.title) == 0) or (fixid3)) and not hintedTitle:
#upstream: derive a title from the folder name when the tag is empty (mutates the tag title, as upstream did)
sBook.title = myx_utilities.getAltTitle (self.name, sBook, cfg)
keys = self._audibleSearchKeys(sBook, cfg)
if add_narrators:
books=myx_audible.getAudibleBook (client, cfg, asin=sAsin, title=keys["title"], authors=keys["authors"], narrators=keys["narrators"], keywords=keys["keywords"], language=language, refresh=self.refresh)
else:
books=myx_audible.getAudibleBook (client, cfg, asin=sAsin, title=keys["title"], authors=keys["authors"], keywords=keys["keywords"], language=language, refresh=self.refresh)
if sAsin and books:
asinAnswered = True # a skeleton is an answer; an exception in getAudibleBook yields []
#title-only: the title is verified by the gate and no author is known to score with, so a runtime
#within tolerance is accepted on its own (pickBest requireRate=False)
if self._rankAudible(books, sBook, keys, cfg, requireTitle=requireTitle, runtimeAlone=(label == "title-only")) is not None:
self.matchAttempt = label
break
return books, usedAsin, asinAnswered

def _audibleAttempts(self, book, cfg, searchAsin, noAsin=False):
"""The ordered search attempts for a book: (label, Book to search with, asin, requireTitle).

With usable tags (or with parsing disabled) there is exactly one attempt, upstream's. When the release
Expand All @@ -799,7 +846,7 @@ def _audibleAttempts(self, book, cfg, searchAsin):
if not (hintedTitle or hintedAuthors or interactive):
parsedBook, parsedApplied = self.applyParsedName(book, cfg)
if parsedApplied:
pAsin = parsedBook.asin if ("asin" in parsedApplied and not searchAsin) else searchAsin
pAsin = "" if noAsin else (parsedBook.asin if ("asin" in parsedApplied and not searchAsin) else searchAsin)
# title OR authors from the release name: the author-only gate would accept that
# author's other books (a leftover "James Patterson - The Guest" folder with a usable
# id3 title "The Guest" used to file Patterson's Along Came a Spider)
Expand Down Expand Up @@ -856,6 +903,9 @@ def getAudibleBooks(self, client, book, cfg):
searchAsin = ""

hintCandidates = list(self.hint.get("candidates", [])) if self.hint else []
#_rankAudible returns bestAudibleMatch as it stands: a value left by an earlier search on this book would make
#the first rung of this one look like a match. Every caller reaches here to search afresh.
self.bestAudibleMatch = None
if (book is not None):
book = self.searchBookFromHint(book)
language=book.language
Expand All @@ -872,29 +922,23 @@ def getAudibleBooks(self, client, book, cfg):
if self._rankAudible(books, book, keys, cfg, hintCandidates=True) is not None:
self.matchAttempt = "candidates"
else:
attempts = self._audibleAttempts(book, cfg, searchAsin)
for label, sBook, sAsin, requireTitle in attempts:
if label == "swapped":
print(f"No match; retrying with the release name read the other way round: title:{sBook.title!r} authors:{[a.name for a in sBook.authors]}")
elif label == "parsed-authors":
print(f"No match; retrying with the authors from the release name: {[a.name for a in sBook.authors]}")
elif label == "title-only":
print("No match; retrying the Audible search with the title only")
elif label == "legacy" and len(attempts) > 1:
print("No match; retrying with the file's own tags as before")
if label == "legacy" and ((len(sBook.title) == 0) or (fixid3)) and not hintedTitle:
#upstream: derive a title from the folder name when the tag is empty (mutates the tag title, as upstream did)
sBook.title = myx_utilities.getAltTitle (self.name, sBook, cfg)
keys = self._audibleSearchKeys(sBook, cfg)
if add_narrators:
books=myx_audible.getAudibleBook (client, cfg, asin=sAsin, title=keys["title"], authors=keys["authors"], narrators=keys["narrators"], keywords=keys["keywords"], language=language, refresh=self.refresh)
else:
books=myx_audible.getAudibleBook (client, cfg, asin=sAsin, title=keys["title"], authors=keys["authors"], keywords=keys["keywords"], language=language, refresh=self.refresh)
#title-only: the title is verified by the gate and no author is known to score with, so a runtime
#within tolerance is accepted on its own (pickBest requireRate=False)
if self._rankAudible(books, sBook, keys, cfg, requireTitle=requireTitle, runtimeAlone=(label == "title-only")) is not None:
self.matchAttempt = label
break
origTitle = book.title # the legacy rung may overwrite it (getAltTitle); the rerun starts clean
books, usedAsin, asinAnswered = self._runAudibleAttempts(client, book, cfg, searchAsin, language)
if self.bestAudibleMatch is None and usedAsin and not asinAnswered:
#the lookup itself failed (outage, timeout): the tag may well be right, and a search now could file
#another edition of the same title for good. Leave the book for the next run, as before.
print(f"Tagged ASIN {usedAsin} could not be looked up; leaving the book unmatched for the next run")
elif self.bestAudibleMatch is None and usedAsin:
#The tag (or the [bracketed] release name) named an ASIN Audible has no usable product for (a
#skeleton {asin, asset_details, is_vvab} answer: a withdrawn or duplicate listing) or one that fails
#the title/author gate. The per-ASIN endpoint ignores the title/author parameters, so nothing else
#was tried: search as for a file without an ASIN, through the same gates. Pinned ASINs already fall
#back this way (acceptPinnedAsin).
print(f"Tagged ASIN {usedAsin} gave no usable Audible match; searching by title and author instead")
book.title = origTitle
books, _, _ = self._runAudibleAttempts(client, book, cfg, "", language, noAsin=True)
if self.bestAudibleMatch is not None:
self.matchAttempt = f"asin-fallback:{self.matchAttempt}"
#end if

if (books is not None):
Expand Down
Loading
Loading