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(
|
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;
|
||||||
|
|||||||
@@ -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")]
|
||||||
@@ -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}")
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user