fix(metadata): find audiobook series through hardcover when audible leaves them untagged
Build image / image (push) Successful in 51s
Build image / image (push) Successful in 51s
This commit is contained in:
1 parent
c8d4d239ec
commit
91ad7ff98b
4 files changed
+72
-16
No files matched your search
+2
-1
@@ -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;
|
||||
|
||||
@@ -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")]
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user