diff --git a/static/app.js b/static/app.js index ffd80d8..a333679 100644 --- a/static/app.js +++ b/static/app.js @@ -360,7 +360,8 @@ async function openSeriesDialog(result, type = "audiobook", language = "") { seriesEpisodes = await api( `/api/search/series?series_id=${encodeURIComponent(result.series_id)}` + `&title=${encodeURIComponent(result.series || result.title)}` + - `&media_type=${type}&language=${encodeURIComponent(language)}` + `&media_type=${type}&language=${encodeURIComponent(language)}` + + `&source=${encodeURIComponent(result.source || "")}` ); } catch (err) { $("#series-info").textContent = err.message; diff --git a/tests/test_musicbrainz.py b/tests/test_musicbrainz.py index bca52d1..059a74e 100644 --- a/tests/test_musicbrainz.py +++ b/tests/test_musicbrainz.py @@ -82,3 +82,42 @@ def test_a_broken_fallback_never_breaks_the_search(monkeypatch): monkeypatch.setattr(metadata.musicbrainz, "search", boom) results = asyncio.run(metadata.audiobook_search("Toteninsel")) 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")] diff --git a/wordarr/api/search.py b/wordarr/api/search.py index bfb33b9..5d20f8a 100644 --- a/wordarr/api/search.py +++ b/wordarr/api/search.py @@ -19,15 +19,16 @@ async def search(media_type: str, q: str, language: str = "", fallback: bool = T @router.get("/series", response_model=list[MetadataResult]) 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 - 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) if not lookup: raise HTTPException(400, f"no series lookup for media_type: {media_type}") if not series_id: raise HTTPException(400, "series_id required") try: - return await lookup(series_id, title, language) + return await lookup(series_id, title, language, source) except Exception as exc: raise HTTPException(502, f"metadata provider error: {exc}") diff --git a/wordarr/metadata/__init__.py b/wordarr/metadata/__init__.py index 153fa6d..e785fd0 100644 --- a/wordarr/metadata/__init__.py +++ b/wordarr/metadata/__init__.py @@ -58,38 +58,53 @@ async def ebook_search(query: str, language: str = "", 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 = "", 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 - detected reliably - measuring it showed the fallback would never fire. Both - are therefore asked in parallel and the MusicBrainz hits are appended, marked - with their source. Callers that search automatically (the import dialog when - it opens) pass fallback=False to spare the rate limited service. + detected reliably - measuring it showed the fallback would never fire. All + are therefore asked in parallel and the extra hits are appended, marked with + their source. Hardcover is there for its series: Audible leaves most volumes + 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: return await audible.search(query, language) - hits, extra = await asyncio.gather( + hits, *extras = await asyncio.gather( audible.search(query, language), musicbrainz.search(query, language), + hardcover.search(query, language), return_exceptions=True, ) if isinstance(hits, BaseException): 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} - 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) -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)