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
- Default `mam-audible` passed `bestMAMMatch` into the Audible search even when MAM found nothing.
`getAudibleBooks(None)` never searches, so a file with usable id3 tags stayed unmatched (unless a
pin supplied an ASIN). A MAM miss now falls back to the file's tags, the same way `metadata=audible`
already did. Replay already used this fallback.

## 3.0.4 - 2026-09-22

### Fixed
Expand Down
5 changes: 4 additions & 1 deletion booktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,10 @@ def buildTreeFromHybridSources(path, mediaPath, files, logfile, cfg):
id3BestMatch = None
mamBestMatch = None
if (metadata == "mam-audible") and ((not multibook) and (not myx_utilities.isMultiBookCollection(book[b].files[0].file))):
mamBestMatch = book[b].getAudibleBooks(httpx, book[b].bestMAMMatch, cfg)
#MAM metadata is preferred (id3 is often empty), but getAudibleBooks(None) never
#searches: when MAM found nothing, use the file's tags like metadata=audible
src = book[b].bestMAMMatch if book[b].bestMAMMatch is not None else book[b].ffprobeBook
mamBestMatch = book[b].getAudibleBooks(httpx, src, cfg)
else:
#This is not a foreign book, do an Audible Search using id3 values first
id3BestMatch = book[b].getAudibleBooks(httpx, book[b].ffprobeBook, cfg)
Expand Down
63 changes: 63 additions & 0 deletions tests/test_matching.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""MAMBook.getAudibleBooks with a canned, offline Audible: pinned ASINs, hinted candidates, duration ranking."""
import ast
import contextlib
import csv
import io
import os
import tempfile
import unittest
from unittest.mock import patch

import booktree
import myx_classes
import myx_hints
from tests.support import FakeAudible, FakeConfig, product, write_json
Expand Down Expand Up @@ -766,3 +769,63 @@ 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", "")])


class MamAudibleFallbackTest(unittest.TestCase):
"""Default metadata=mam-audible passes bestMAMMatch into getAudibleBooks. When MAM found
nothing that used to be None, and getAudibleBooks(None) never searches — a file with good
id3 tags stayed unmatched. Fall back to the file's tags like metadata=audible."""

def test_getAudibleBooks_with_none_and_no_pin_never_searches(self):
with tempfile.TemporaryDirectory() as td:
cfg = FakeConfig(td)
client = FakeAudible(search=[product("B0CC3NZ34S", "The Coworker", ["Freida McFadden"], 492)])
mb = mambook("The Coworker", id3_book("The Coworker", ["Freida McFadden"], 492 * 60))
with contextlib.redirect_stdout(io.StringIO()):
best = mb.getAudibleBooks(client, None, cfg)
self.assertIsNone(best)
self.assertEqual(client.calls, [])

def test_hybrid_mam_audible_matches_from_id3_when_mam_misses(self):
# one folder deep so isMultiBookCollection is false and we take the mam-audible branch
with tempfile.TemporaryDirectory() as td:
src, media = os.path.join(td, "src"), os.path.join(td, "media")
os.makedirs(os.path.join(src, "The Coworker"))
os.makedirs(media)
source_file = os.path.join(src, "The Coworker", "book.m4b")
with open(source_file, "wb") as fh:
fh.write(b"\x00" * 16)

def fake_ffprobe(self, parent):
book = id3_book("The Coworker", ["Freida McFadden"], 492 * 60)
self.ffprobeBook = book
return book

def fake_mam(self, cfg, bookFile):
self.mamMatches = []
return None

cfg = FakeConfig(td, **{"Config/metadata": "mam-audible", "Config/flags/no_cache": 1})
client = FakeAudible(search=[product("B0CC3NZ34S", "The Coworker", ["Freida McFadden"], 492)])
saved = booktree.httpx
booktree.httpx = client
logfile = os.path.join(td, "booktree_log_test.csv")
out = io.StringIO()
try:
with contextlib.redirect_stdout(out), \
patch.object(myx_classes.BookFile, "ffprobe", fake_ffprobe), \
patch.object(myx_classes.MAMBook, "getMAMBooks", fake_mam):
booktree.buildTreeFromHybridSources(src, media, ["**/*.m4b"], logfile, cfg)
finally:
booktree.httpx = saved

with open(logfile, newline="", encoding="utf-8") as fh:
rows = list(csv.DictReader(fh))
text = out.getvalue()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["isMatched"], "True")
self.assertEqual(rows[0]["metadatasource"], "audible")
self.assertEqual(rows[0]["adb-asin"], "B0CC3NZ34S")
self.assertEqual(rows[0]["adb-title"], "The Coworker")
self.assertTrue(any(u.endswith("/catalog/products") for u, _ in client.calls), client.calls)
self.assertIn("Creating Hardlinks for 1 matched books", text)
Loading