fix(metadata): find audiobook series through hardcover when audible leaves them untagged
Build image / image (push) Successful in 51s

This commit is contained in:
Steppenstreuner committed 2026-09-25 16:02:43 +02:00
1 parent c8d4d239ec
commit 91ad7ff98b
4 files changed
+72 -16

No files matched your search

+2 -1
View File
@@ -360,7 +360,8 @@ async function openSeriesDialog(result, type = "audiobook", language = "") {
seriesEpisodes = await api( seriesEpisodes = await api(
`/api/search/series?series_id=${encodeURIComponent(result.series_id)}` + `/api/search/series?series_id=${encodeURIComponent(result.series_id)}` +
`&title=${encodeURIComponent(result.series || result.title)}` + `&title=${encodeURIComponent(result.series || result.title)}` +
`&media_type=${type}&language=${encodeURIComponent(language)}` `&media_type=${type}&language=${encodeURIComponent(language)}` +
`&source=${encodeURIComponent(result.source || "")}`
); );
} catch (err) { } catch (err) {
$("#series-info").textContent = err.message; $("#series-info").textContent = err.message;
+39
View File
@@ -82,3 +82,42 @@ def test_a_broken_fallback_never_breaks_the_search(monkeypatch):
monkeypatch.setattr(metadata.musicbrainz, "search", boom) monkeypatch.setattr(metadata.musicbrainz, "search", boom)
results = asyncio.run(metadata.audiobook_search("Toteninsel")) results = asyncio.run(metadata.audiobook_search("Toteninsel"))
assert [r.title for r in results] == ["Da"] assert [r.title for r in results] == ["Da"]
def test_audiobook_search_appends_hardcover_series_as_audiobooks(monkeypatch):
from wordarr.metadata.base import MetadataResult
async def fake_audible(query, language=""):
return [MetadataResult(media_type="audiobook", title="Die letzte Witwe")]
async def no_hits(query, language=""):
return []
async def fake_hardcover(query, language=""):
return [MetadataResult(media_type="ebook", source="hardcover",
title="Die verstummte Frau", series="Will Trent",
series_id="3670", volume=10)]
monkeypatch.setattr(metadata.audible, "search", fake_audible)
monkeypatch.setattr(metadata.musicbrainz, "search", no_hits)
monkeypatch.setattr(metadata.hardcover, "search", fake_hardcover)
results = asyncio.run(metadata.audiobook_search("Karin Slaughter", "german"))
assert [r.source for r in results] == ["audible", "hardcover"]
assert results[1].media_type == "audiobook"
def test_audiobook_series_goes_to_the_provider_of_the_hit(monkeypatch):
from wordarr.metadata.base import MetadataResult
async def fake_series_books(series_id, language=""):
return [MetadataResult(media_type="ebook", source="hardcover",
title="Triptych", volume=1)]
async def audible_must_not_run(series_id, title):
raise AssertionError("a hardcover id is no audible series asin")
monkeypatch.setattr(metadata.hardcover, "series_books", fake_series_books)
monkeypatch.setattr(metadata.audible, "series_episodes", audible_must_not_run)
lookup = metadata.SERIES_PROVIDERS["audiobook"]
books = asyncio.run(lookup("3670", "Will Trent", "german", "hardcover"))
assert [(b.title, b.media_type) for b in books] == [("Triptych", "audiobook")]
+4 -3
View File
@@ -19,15 +19,16 @@ async def search(media_type: str, q: str, language: str = "", fallback: bool = T
@router.get("/series", response_model=list[MetadataResult]) @router.get("/series", response_model=list[MetadataResult])
async def series(series_id: str, title: str = "", media_type: str = "audiobook", async def series(series_id: str, title: str = "", media_type: str = "audiobook",
language: str = ""): language: str = "", source: str = ""):
"""All parts of a series, by number. Audible has no series endpoint, so """All parts of a series, by number. Audible has no series endpoint, so
audiobooks are a keyword crawl that may miss episodes; Hardcover is exact.""" audiobooks are a keyword crawl that may miss episodes; Hardcover is exact.
The source picks the provider, a series id only means something to its own."""
lookup = SERIES_PROVIDERS.get(media_type) lookup = SERIES_PROVIDERS.get(media_type)
if not lookup: if not lookup:
raise HTTPException(400, f"no series lookup for media_type: {media_type}") raise HTTPException(400, f"no series lookup for media_type: {media_type}")
if not series_id: if not series_id:
raise HTTPException(400, "series_id required") raise HTTPException(400, "series_id required")
try: try:
return await lookup(series_id, title, language) return await lookup(series_id, title, language, source)
except Exception as exc: except Exception as exc:
raise HTTPException(502, f"metadata provider error: {exc}") raise HTTPException(502, f"metadata provider error: {exc}")
+27 -12
View File
@@ -58,38 +58,53 @@ async def ebook_search(query: str, language: str = "",
return _merge(groups, _ebook_key) return _merge(groups, _ebook_key)
def _as_audiobook(hits: list[MetadataResult]) -> list[MetadataResult]:
return [r.model_copy(update={"media_type": "audiobook"}) for r in hits]
async def audiobook_search(query: str, language: str = "", async def audiobook_search(query: str, language: str = "",
fallback: bool = True) -> list[MetadataResult]: fallback: bool = True) -> list[MetadataResult]:
"""Audible first, MusicBrainz alongside it. """Audible first, MusicBrainz and Hardcover alongside it.
Audible answers almost any query with *something*, so "no good hit" cannot be Audible answers almost any query with *something*, so "no good hit" cannot be
detected reliably - measuring it showed the fallback would never fire. Both detected reliably - measuring it showed the fallback would never fire. All
are therefore asked in parallel and the MusicBrainz hits are appended, marked are therefore asked in parallel and the extra hits are appended, marked with
with their source. Callers that search automatically (the import dialog when their source. Hardcover is there for its series: Audible leaves most volumes
it opens) pass fallback=False to spare the rate limited service. of a series untagged. Callers that search automatically (the import dialog
when it opens) pass fallback=False to spare the rate limited services.
""" """
if not fallback: if not fallback:
return await audible.search(query, language) return await audible.search(query, language)
hits, extra = await asyncio.gather( hits, *extras = await asyncio.gather(
audible.search(query, language), audible.search(query, language),
musicbrainz.search(query, language), musicbrainz.search(query, language),
hardcover.search(query, language),
return_exceptions=True, return_exceptions=True,
) )
if isinstance(hits, BaseException): if isinstance(hits, BaseException):
raise hits raise hits
if isinstance(extra, BaseException): # a slow fallback must not break search
log.warning("musicbrainz lookup failed for %r: %s", query, extra)
return hits
seen = {(r.title.strip().lower(), r.volume) for r in hits} seen = {(r.title.strip().lower(), r.volume) for r in hits}
return hits + [r for r in extra if (r.title.strip().lower(), r.volume) not in seen] for module, extra in zip((musicbrainz, hardcover), extras):
if isinstance(extra, BaseException): # a slow fallback must not break search
log.warning("%s lookup failed for %r: %s", module.__name__, query, extra)
continue
for r in _as_audiobook(extra):
if (key := (r.title.strip().lower(), r.volume)) not in seen:
seen.add(key)
hits.append(r)
return hits
async def _audiobook_series(series_id: str, title: str, language: str = ""): async def _audiobook_series(series_id: str, title: str, language: str = "",
source: str = ""):
if source == "hardcover":
return _as_audiobook(await hardcover.series_books(series_id, language))
return await audible.series_episodes(series_id, title) return await audible.series_episodes(series_id, title)
async def _ebook_series(series_id: str, title: str = "", language: str = ""): async def _ebook_series(series_id: str, title: str = "", language: str = "",
source: str = ""):
return await hardcover.series_books(series_id, language) return await hardcover.series_books(series_id, language)