mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-14 10:38:42 +00:00
- **Open Library (#1451): keep the work unless the search matched a translation.** Open Library shows an English edition whenever one matches the query. So "Mistborn" returned the 2023 "Mistborn" reissue (2023) instead of "The Final Empire" (2001), and "Children of Dune" returned "Children of Dune (Dune Chronicles, Book 3)" (1987). The matched edition's title, ISBNs, cover, language and year now win only when the edition is in another language and the query doesn't name the work's own title. Checked against live Open Library: "Children of Dune" and "Mistborn" give the v1.4.0 results again, and "Der Weg der Könige" and "Kinder des Nebels" still return the German editions. - **Google Books (#1434): the ISBN cache decorator is back on `search_by_isbn`.** It had moved to the new `search_paginated`. Failed searches were cached as empty pages, and ISBN lookups lost their cache. - **Notifications: Test notification shows the plugin's failure reason again.** Apprise 2 delivers on its own worker threads, even for a single service when that service has a deadline. `_AppriseLogCapture` filtered records by thread id and dropped all of them, so the UI only said "json: delivery failed". Apprise runs those workers in a copy of the caller's context, so the capture now filters on a context variable. That still keeps concurrent dispatches apart. `AppriseAsset(async_mode=False)` would not fix this, because the sequential path also uses the worker pool when a service has a deadline. - **Activity: `activity_update` no longer sends the raw task id to non-admins.** The owner's `user_<id>` room got the raw task id. For a request-linked download that id can hold a private tracker's passkey. It now goes through `viewer_download_id`, the same mapping `/api/status` and `download_progress` use. The admins room still gets the raw id. - **SearchBar (#1476): Escape and an outside click close the homepage's inline options panel again,** as they did in v1.4.0. - **genDebug.sh: packages are listed through `importlib.metadata`,** since #1404 removed pip from the image. The script also now redacts: - `PROWLARR_MAM_ID` - `user:pass@` credentials in URLs (proxies), in both the environment and the config files - notification route URLs, which carry their service's token (Telegram, Discord and others) - `make python-checks`, `make python-test`, `make frontend-checks` and `make frontend-test` pass on top of current `main`. - New tests cover: - Open Library edition selection - Google Books caching - log capture against a real Apprise 2 delivery to a local server that answers 500, plus a concurrent dispatch on another thread - the `activity_update` payload sent to each room - genDebug redaction and the package listing Each one fails on the old code. - I checked the SearchBar fix in the running app, because the frontend has no DOM test setup: Escape closes the panel, an outside click closes it, and a click inside keeps it open. - I ran the genDebug redaction under GNU sed (Debian image) and BSD sed. The package listing ran in the pip-less image.
209 lines
6.5 KiB
Python
209 lines
6.5 KiB
Python
import requests
|
|
|
|
from shelfmark.core.cache import get_metadata_cache
|
|
from shelfmark.metadata_providers import MetadataSearchOptions
|
|
from shelfmark.metadata_providers.googlebooks import GoogleBooksProvider
|
|
|
|
|
|
class _GoogleBooksResponse:
|
|
def __init__(self, payload):
|
|
self._payload = payload
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
|
|
class _FlakyGoogleBooksSession:
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
def get(self, *args, **kwargs):
|
|
self.calls += 1
|
|
if self.calls == 1:
|
|
raise requests.Timeout
|
|
return _GoogleBooksResponse(
|
|
{
|
|
"items": [
|
|
{
|
|
"id": "volume-1",
|
|
"volumeInfo": {
|
|
"title": "Recovered Book",
|
|
"authors": ["Alice Author"],
|
|
},
|
|
}
|
|
]
|
|
}
|
|
)
|
|
|
|
|
|
class _RecordingGoogleBooksSession:
|
|
def __init__(self):
|
|
self.params = []
|
|
|
|
def get(self, *args, **kwargs):
|
|
self.params.append(dict(kwargs["params"]))
|
|
return _GoogleBooksResponse({"items": []})
|
|
|
|
|
|
def test_googlebooks_search_does_not_cache_request_failures():
|
|
get_metadata_cache().clear()
|
|
provider = GoogleBooksProvider(api_key="test-key")
|
|
session = _FlakyGoogleBooksSession()
|
|
provider.session = session
|
|
options = MetadataSearchOptions(query="Recovered Book")
|
|
|
|
assert provider.search(options) == []
|
|
|
|
result = provider.search(options)
|
|
|
|
assert session.calls == 2
|
|
assert [book.title for book in result] == ["Recovered Book"]
|
|
|
|
|
|
def test_googlebooks_paginated_search_does_not_cache_request_failures():
|
|
get_metadata_cache().clear()
|
|
provider = GoogleBooksProvider(api_key="test-key")
|
|
session = _FlakyGoogleBooksSession()
|
|
provider.session = session
|
|
options = MetadataSearchOptions(query="Recovered Book")
|
|
|
|
assert provider.search_paginated(options).books == []
|
|
|
|
result = provider.search_paginated(options)
|
|
|
|
assert session.calls == 2
|
|
assert [book.title for book in result.books] == ["Recovered Book"]
|
|
|
|
|
|
def test_googlebooks_isbn_lookup_is_cached():
|
|
get_metadata_cache().clear()
|
|
provider = GoogleBooksProvider(api_key="test-key")
|
|
session = _FlakyGoogleBooksSession()
|
|
session.calls = 1 # skip the simulated timeout
|
|
provider.session = session
|
|
|
|
first = provider.search_by_isbn("978-0-00-000000-2")
|
|
second = provider.search_by_isbn("978-0-00-000000-2")
|
|
|
|
assert first is not None
|
|
assert second == first
|
|
assert session.calls == 2
|
|
|
|
|
|
def test_googlebooks_pages_tile_when_limit_exceeds_api_maximum():
|
|
get_metadata_cache().clear()
|
|
provider = GoogleBooksProvider(api_key="test-key")
|
|
session = _RecordingGoogleBooksSession()
|
|
provider.session = session
|
|
|
|
for page in (1, 2):
|
|
provider.search(MetadataSearchOptions(query="Dune", limit=50, page=page))
|
|
|
|
first, second = session.params
|
|
assert first["maxResults"] == 40
|
|
assert first["startIndex"] == 0
|
|
assert second["startIndex"] == first["startIndex"] + first["maxResults"]
|
|
|
|
|
|
def test_googlebooks_page_stride_follows_limit_below_api_maximum():
|
|
get_metadata_cache().clear()
|
|
provider = GoogleBooksProvider(api_key="test-key")
|
|
session = _RecordingGoogleBooksSession()
|
|
provider.session = session
|
|
|
|
provider.search(MetadataSearchOptions(query="Dune", limit=25, page=3))
|
|
|
|
assert session.params[0]["maxResults"] == 25
|
|
assert session.params[0]["startIndex"] == 50
|
|
|
|
|
|
class TestGoogleBooksParseVolume:
|
|
def test_parse_volume_returns_metadata_for_valid_payload(self):
|
|
provider = GoogleBooksProvider(api_key="test-key")
|
|
|
|
result = provider._parse_volume(
|
|
{
|
|
"id": "volume-1",
|
|
"volumeInfo": {
|
|
"title": "Test Book",
|
|
"authors": ["Alice Author"],
|
|
"industryIdentifiers": [
|
|
{"type": "ISBN_10", "identifier": "1234567890"},
|
|
{"type": "ISBN_13", "identifier": "9781234567897"},
|
|
],
|
|
"imageLinks": {
|
|
"thumbnail": "http://example.com/cover.jpg&edge=curl",
|
|
},
|
|
"publisher": "Test Publisher",
|
|
"publishedDate": "2024-03-01",
|
|
"language": "en",
|
|
"categories": ["Fiction", "Fantasy"],
|
|
"description": "A book.",
|
|
"infoLink": "https://example.com/books/volume-1",
|
|
"averageRating": 4.2,
|
|
"ratingsCount": 1200,
|
|
},
|
|
}
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.provider_id == "volume-1"
|
|
assert result.title == "Test Book"
|
|
assert result.authors == ["Alice Author"]
|
|
assert result.isbn_10 == "1234567890"
|
|
assert result.isbn_13 == "9781234567897"
|
|
assert result.cover_url == "https://example.com/cover.jpg"
|
|
assert result.publish_year == 2024
|
|
assert result.display_fields[0].value == "4.2 (1,200)"
|
|
|
|
def test_parse_volume_returns_none_for_malformed_rating_payload(self):
|
|
provider = GoogleBooksProvider(api_key="test-key")
|
|
|
|
result = provider._parse_volume(
|
|
{
|
|
"id": "volume-2",
|
|
"volumeInfo": {
|
|
"title": "Broken Book",
|
|
"averageRating": "not-a-number",
|
|
},
|
|
}
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
class _FullPageSession:
|
|
def __init__(self, count):
|
|
self.count = count
|
|
|
|
def get(self, *args, **kwargs):
|
|
items = [
|
|
{"id": f"v{i}", "volumeInfo": {"title": f"Book {i}", "authors": ["Alice Author"]}}
|
|
for i in range(self.count)
|
|
]
|
|
return _GoogleBooksResponse({"items": items})
|
|
|
|
|
|
def test_googlebooks_reports_more_pages_when_limit_exceeds_api_maximum():
|
|
get_metadata_cache().clear()
|
|
provider = GoogleBooksProvider(api_key="test-key")
|
|
provider.session = _FullPageSession(40)
|
|
|
|
result = provider.search_paginated(MetadataSearchOptions(query="Dune", limit=100))
|
|
|
|
assert len(result.books) == 40
|
|
assert result.has_more is True
|
|
|
|
|
|
def test_googlebooks_reports_no_more_pages_after_a_short_page():
|
|
get_metadata_cache().clear()
|
|
provider = GoogleBooksProvider(api_key="test-key")
|
|
provider.session = _FullPageSession(12)
|
|
|
|
result = provider.search_paginated(MetadataSearchOptions(query="Dune", limit=100))
|
|
|
|
assert result.has_more is False
|