From 756bdec19bd52edd7f527cb7a64b362a9da8de13 Mon Sep 17 00:00:00 2001 From: HeihoffJ <31669146+HeihoffJ@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:08:45 +0200 Subject: [PATCH] fix(metadata): show the translation a search matched on Open Library and Hardcover (#1451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Searching a translated title on Open Library or Hardcover returned the original-language work. "Der Weg der Könige" came back as "The Way of Kings" (2010), and that English title then went into the release search and the download name. Both providers keep one record per work, titled after the original, and list translations underneath it. #483 already searches releases per language with Hardcover's localized titles, which fixed the release side of #360 when the matching language is selected. This PR fixes the search result itself, so the book a user picks is the translation they searched for, and it covers Open Library, the default provider, which has no titles_by_language. - Open Library: request the matched edition (editions.*) and prefer its title, ISBNs, cover, language and publication year when its title differs from the work's. - Hardcover: use the alternative title named in the hit's highlights (alternative_titles, best index first), without the original's subtitle. A hit whose title also matched keeps the work title. - /api/releases: when the modal sends a title that differs from the book record's, it becomes search_title. Before, Hardcover's search_title from the original won, so picking "Kinder des Nebels" still searched "The Final Empire" first. - Release cache: keyed on title and language as well as the provider ID. All translations share that ID, so opening "The Way of Kings" after "Der Weg der Könige" showed the German releases until a reload. Known limitations: neither provider has descriptions per edition, so the description stays the work's (usually English). Hardcover's search hits carry no year or language for alternative titles, so those stay the work's; Open Library's do not. Google Books is unaffected, since it already returns one volume per edition. Tests: Open Library edition parsing, Hardcover alternative-title matching, the /api/releases title override, and the release cache, each written to fail first. The Python suite passes apart from the 9 test_entrypoint_permissions.py tests, which fail the same way on an unmodified main on macOS. The frontend typecheck, lint, format check and unit tests pass. Checked on a live instance with Open Library, Hardcover and Google Books. Refs #360, #483 Disclaimer: This was developed with the help of AI but reviewed and tested as well as deployed on my server by a real human. --------- Co-authored-by: Claude Opus 5.5 --- shelfmark/main.py | 6 + shelfmark/metadata_providers/hardcover.py | 49 +++++++- shelfmark/metadata_providers/openlibrary.py | 60 ++++++++- .../releaseModal/useReleaseSearchSession.ts | 23 +++- src/frontend/src/tests/releaseCache.test.ts | 53 ++++++++ src/frontend/src/utils/releaseCache.ts | 42 ++++--- .../core/test_releases_api_title_override.py | 101 +++++++++++++++ .../test_hardcover_alternative_title.py | 82 ++++++++++++ tests/metadata/test_openlibrary_parse.py | 118 ++++++++++++++++++ 9 files changed, 504 insertions(+), 30 deletions(-) create mode 100644 src/frontend/src/tests/releaseCache.test.ts create mode 100644 tests/core/test_releases_api_title_override.py create mode 100644 tests/metadata/test_hardcover_alternative_title.py create mode 100644 tests/metadata/test_openlibrary_parse.py diff --git a/shelfmark/main.py b/shelfmark/main.py index fd74c7a4..15330302 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -3197,6 +3197,12 @@ def api_releases() -> Response | tuple[Response, int]: # filtered authors (primary authors only, excluding translators/narrators), # which gives better release search results than the unfiltered search data if title_param: + if title_param != book.title: + # A different title is one the search result carried and the book + # record does not, typically a translation's ("Kinder des Nebels" for + # "Mistborn: The Final Empire"). The provider's search_title derives + # from the original and would otherwise win the release search. + book.search_title = title_param book.title = title_param # Determine which release sources to search diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py index acfa6b66..9a817793 100644 --- a/shelfmark/metadata_providers/hardcover.py +++ b/shelfmark/metadata_providers/hardcover.py @@ -773,6 +773,33 @@ def _unwrap_hit_document(hit: Any) -> dict[str, Any] | None: return item if isinstance(item, dict) else None +def _matched_alternative_title(hit: Any, item: dict[str, Any]) -> str | None: + """Return the alternative title a search hit matched on, unless its title matched too. + + Hardcover keeps one book per work, titled after the original, and lists its + translations' titles in `alternative_titles`. A search for "Der Weg der Könige" + matches only that alternative title of "The Way of Kings, Part 1"; the hit's + `highlights` name the field and the matching entries, best first. + """ + if not isinstance(hit, dict): + return None + highlights = hit.get("highlights") + if not isinstance(highlights, list): + return None + fields = {h.get("field"): h for h in highlights if isinstance(h, dict)} + if "title" in fields: + return None + indices = (fields.get("alternative_titles") or {}).get("indices") or [] + alternative_titles = item.get("alternative_titles") + if not indices or not isinstance(alternative_titles, list): + return None + index = indices[0] + if not isinstance(index, int) or not 0 <= index < len(alternative_titles): + return None + matched = alternative_titles[index] + return matched if isinstance(matched, str) and matched.strip() else None + + def _search_tokens(value: str) -> list[str]: """Tokenize search text for lightweight prefix matching.""" return re.findall(r"[a-z0-9']+", value.casefold()) @@ -2556,7 +2583,9 @@ class HardcoverProvider(MetadataProvider): release_year = item.get("release_year") if release_year is not None and release_year > current_year: continue - book = self._parse_search_result(item) + book = self._parse_search_result( + item, matched_title=_matched_alternative_title(hit, item) + ) if book: books.append(book) @@ -2882,8 +2911,15 @@ class HardcoverProvider(MetadataProvider): _sort_fallback_until = time.monotonic() + SORT_FALLBACK_TTL return retry - def _parse_search_result(self, item: dict) -> BookMetadata | None: - """Parse a search result item into BookMetadata.""" + def _parse_search_result( + self, item: dict, matched_title: str | None = None + ) -> BookMetadata | None: + """Parse a search result item into BookMetadata. + + ``matched_title`` is the alternative (usually translated) title the search + matched; when given, it replaces the work's original title, so a search for a + translation by name stays in that translation's language. + """ try: book_id = item.get("id") or item.get("document", {}).get("id") title = item.get("title") or item.get("document", {}).get("title") @@ -2944,13 +2980,18 @@ class HardcoverProvider(MetadataProvider): # Extract subtitle if available in search results subtitle = item.get("subtitle") + search_title = _compute_search_title(title, subtitle) + + if matched_title and matched_title != title: + # The subtitle belongs to the original title, not to the translation. + title, subtitle, search_title = matched_title, None, matched_title return BookMetadata( provider="hardcover", provider_id=str(book_id), title=title, subtitle=subtitle, - search_title=_compute_search_title(title, subtitle), + search_title=search_title, search_author=search_author, provider_display_name="Hardcover", authors=authors, diff --git a/shelfmark/metadata_providers/openlibrary.py b/shelfmark/metadata_providers/openlibrary.py index 3417bd4c..6ee2925e 100644 --- a/shelfmark/metadata_providers/openlibrary.py +++ b/shelfmark/metadata_providers/openlibrary.py @@ -159,7 +159,12 @@ class OpenLibraryProvider(MetadataProvider): params: dict[str, Any] = { "limit": options.limit, "page": options.page, - "fields": "key,title,author_name,first_publish_year,cover_i,isbn,publisher,language,subject,ratings_average,ratings_count", + "fields": ( + "key,title,author_name,first_publish_year,cover_i,isbn,publisher," + "language,subject,ratings_average,ratings_count," + "editions,editions.key,editions.title,editions.language," + "editions.isbn,editions.cover_i,editions.publish_year" + ), } # Field-first search: use custom field values when provided @@ -348,7 +353,6 @@ class OpenLibraryProvider(MetadataProvider): # Get cover URL cover_id = doc.get("cover_i") - cover_url = f"{COVERS_BASE_URL}/b/id/{cover_id}-L.jpg" if cover_id else None # Get publishers (take first one) publishers = doc.get("publisher", []) @@ -362,6 +366,53 @@ class OpenLibraryProvider(MetadataProvider): subjects = doc.get("subject", []) genres = subjects[:5] if subjects else [] + # `title`/`isbn`/`cover_i`/`language` above describe the *work*, which + # Open Library reports with one canonical title - usually the original- + # language edition - regardless of which translation's text actually + # matched the search query (e.g. querying "Der Weg der Könige" matches + # the German edition of "The Way of Kings", but the work's title field + # still says "The Way of Kings"). The nested `editions` block, requested + # above, is populated with the specific edition whose own fields + # satisfied the query, so prefer its title/isbn/cover/language when one + # is present and differs from the work - otherwise both the result shown + # to the user and the release search query (which uses `search_title` or + # else falls back to `title`, see search_plan._pick_search_title) end up + # in the wrong language even though the user searched for a specific + # translation by name. + title = doc["title"] + search_title = None + publish_year = doc.get("first_publish_year") + matched_edition = next(iter(doc.get("editions", {}).get("docs", [])), None) + if matched_edition and matched_edition.get("title"): + edition_title = matched_edition["title"] + if edition_title != title: + search_title = edition_title + title = edition_title + + edition_isbns = matched_edition.get("isbn", []) + edition_isbn_10 = next( + (i for i in edition_isbns if len(i) == ISBN_10_LENGTH), None + ) + edition_isbn_13 = next( + (i for i in edition_isbns if len(i) == ISBN_13_LENGTH), None + ) + isbn_10 = edition_isbn_10 or isbn_10 + isbn_13 = edition_isbn_13 or isbn_13 + + cover_id = matched_edition.get("cover_i") or cover_id + + edition_languages = matched_edition.get("language") or [] + if edition_languages: + language = edition_languages[0] + + # A translation is published later than the work it translates, + # e.g. 2015 for "Der Weg der Könige" against 2010 for the work. + edition_years = matched_edition.get("publish_year") or [] + if edition_years: + publish_year = min(edition_years) + + cover_url = f"{COVERS_BASE_URL}/b/id/{cover_id}-L.jpg" if cover_id else None + # Build display fields from Open Library-specific data display_fields = [] @@ -377,14 +428,15 @@ class OpenLibraryProvider(MetadataProvider): return BookMetadata( provider="openlibrary", provider_id=work_id, - title=doc["title"], + title=title, + search_title=search_title, provider_display_name="Open Library", authors=authors, isbn_10=isbn_10, isbn_13=isbn_13, cover_url=cover_url, publisher=publisher, - publish_year=doc.get("first_publish_year"), + publish_year=publish_year, language=language, genres=genres, source_url=f"{OPENLIBRARY_BASE_URL}/works/{work_id}", diff --git a/src/frontend/src/hooks/releaseModal/useReleaseSearchSession.ts b/src/frontend/src/hooks/releaseModal/useReleaseSearchSession.ts index 5ff2ac9d..6111065c 100644 --- a/src/frontend/src/hooks/releaseModal/useReleaseSearchSession.ts +++ b/src/frontend/src/hooks/releaseModal/useReleaseSearchSession.ts @@ -202,6 +202,18 @@ export function useReleaseSearchSession( [], ); + // Editions of one work can share `provider_id`; the cache tells them apart by title + // and language (see releaseCache). + const cacheBook = useMemo( + () => ({ + provider: book.provider, + provider_id: book.provider_id, + title: book.title, + language: book.language, + }), + [book.language, book.provider, book.provider_id, book.title], + ); + const fetchReleaseResults = useCallback( async ( tabName: string, @@ -245,7 +257,7 @@ export function useReleaseSearchSession( useFilters && supportsIndexerFilter && indexerFilter.length > 0 ? indexerFilter : undefined; if (!expandSearch) { - const cached = getCachedReleases(provider, bookId, tabName, contentType); + const cached = getCachedReleases(cacheBook, tabName, contentType); if (cached) { setReleasesBySource((prev) => ({ ...prev, [tabName]: cached })); initializeIndexerFilterForTab(tabName, cached); @@ -293,7 +305,7 @@ export function useReleaseSearchSession( }; }); } else { - setCachedReleases(provider, bookId, tabName, contentType, response); + setCachedReleases(cacheBook, tabName, contentType, response); setReleasesBySource((prev) => ({ ...prev, [tabName]: response })); } @@ -312,6 +324,7 @@ export function useReleaseSearchSession( book.provider_id, book.title, bookLanguages, + cacheBook, clearSearchStatusForTab, contentType, defaultLanguages, @@ -494,7 +507,7 @@ export function useReleaseSearchSession( const supportsIndexerFilter = releasesBySource[activeTab]?.column_config?.supported_filters?.includes('indexer') ?? false; - invalidateCachedReleases(book.provider, book.provider_id, activeTab, contentType); + invalidateCachedReleases(cacheBook, activeTab, contentType); setExpandedBySource((prev) => { const next = { ...prev }; delete next[activeTab]; @@ -520,6 +533,7 @@ export function useReleaseSearchSession( activeTab, book.provider, book.provider_id, + cacheBook, contentType, fetchReleaseResults, releasesBySource, @@ -536,7 +550,7 @@ export function useReleaseSearchSession( } for (const tab of allTabs) { - invalidateCachedReleases(book.provider, book.provider_id, tab.name, contentType); + invalidateCachedReleases(cacheBook, tab.name, contentType); } setExpandedBySource({}); @@ -552,6 +566,7 @@ export function useReleaseSearchSession( allTabs, book.provider, book.provider_id, + cacheBook, contentType, fetchReleaseResults, manualQuery, diff --git a/src/frontend/src/tests/releaseCache.test.ts b/src/frontend/src/tests/releaseCache.test.ts new file mode 100644 index 00000000..5241437d --- /dev/null +++ b/src/frontend/src/tests/releaseCache.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; + +import type { ReleasesResponse } from '../types/index'; +import { + getCachedReleases, + invalidateCachedReleases, + setCachedReleases, +} from '../utils/releaseCache'; + +// Open Library reports every translation of a book under one work ID, so the +// German and the English edition of the same book share `provider_id`. +const germanEdition = { + provider: 'openlibrary', + provider_id: 'OL15358691W', + title: 'Der Weg der Könige', + language: 'ger', +}; + +const englishEdition = { + provider: 'openlibrary', + provider_id: 'OL15358691W', + title: 'The Way of Kings', + language: 'eng', +}; + +const response = (title: string): ReleasesResponse => ({ + releases: [], + book: { provider: 'openlibrary', provider_id: 'OL15358691W', title }, + sources_searched: ['prowlarr'], +}); + +describe('releaseCache', () => { + it('keeps editions of the same work apart', () => { + setCachedReleases(germanEdition, 'prowlarr', 'ebook', response('Der Weg der Könige')); + + expect(getCachedReleases(englishEdition, 'prowlarr', 'ebook')).toBeNull(); + expect(getCachedReleases(germanEdition, 'prowlarr', 'ebook')).toEqual( + response('Der Weg der Könige'), + ); + }); + + it('invalidates only the edition it was given', () => { + setCachedReleases(germanEdition, 'prowlarr', 'audiobook', response('Der Weg der Könige')); + setCachedReleases(englishEdition, 'prowlarr', 'audiobook', response('The Way of Kings')); + + invalidateCachedReleases(germanEdition, 'prowlarr', 'audiobook'); + + expect(getCachedReleases(germanEdition, 'prowlarr', 'audiobook')).toBeNull(); + expect(getCachedReleases(englishEdition, 'prowlarr', 'audiobook')).toEqual( + response('The Way of Kings'), + ); + }); +}); diff --git a/src/frontend/src/utils/releaseCache.ts b/src/frontend/src/utils/releaseCache.ts index 50627061..0ccc5161 100644 --- a/src/frontend/src/utils/releaseCache.ts +++ b/src/frontend/src/utils/releaseCache.ts @@ -1,17 +1,26 @@ -import type { ReleasesResponse } from '../types'; +import type { Book, ReleasesResponse } from '../types'; + +/** The book fields that identify one edition's release search. */ +export type ReleaseCacheBook = Pick; // Module-level cache for release search results -// Key format: `${provider}:${provider_id}:${source}:${contentType}` +// Key format: `${provider}:${provider_id}:${language}:${title}:${source}:${contentType}` // This persists across modal open/close cycles const releaseCache = new Map(); -function getCacheKey( - provider: string, - providerId: string, - source: string, - contentType: string, -): string { - return `${provider}:${providerId}:${source}:${contentType}`; +// A provider can report every edition of a book under one ID - Open Library uses the +// work ID for "The Way of Kings" and its German translation "Der Weg der Könige" alike - +// and each edition searches for releases by its own title and language. Keying on the +// ID alone would serve one edition's cached releases to the other. +function getCacheKey(book: ReleaseCacheBook, source: string, contentType: string): string { + return [ + book.provider, + book.provider_id, + book.language ?? '', + book.title, + source, + contentType, + ].join(':'); } // Default cache TTL (5 minutes) - sources can override via column_config.cache_ttl_seconds @@ -19,12 +28,11 @@ const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000; const cacheTimestamps = new Map(); export function getCachedReleases( - provider: string, - providerId: string, + book: ReleaseCacheBook, source: string, contentType: string, ): ReleasesResponse | null { - const key = getCacheKey(provider, providerId, source, contentType); + const key = getCacheKey(book, source, contentType); const timestamp = cacheTimestamps.get(key); const cached = releaseCache.get(key); @@ -49,24 +57,22 @@ export function getCachedReleases( } export function setCachedReleases( - provider: string, - providerId: string, + book: ReleaseCacheBook, source: string, contentType: string, data: ReleasesResponse, ): void { - const key = getCacheKey(provider, providerId, source, contentType); + const key = getCacheKey(book, source, contentType); releaseCache.set(key, data); cacheTimestamps.set(key, Date.now()); } export function invalidateCachedReleases( - provider: string, - providerId: string, + book: ReleaseCacheBook, source: string, contentType: string, ): void { - const key = getCacheKey(provider, providerId, source, contentType); + const key = getCacheKey(book, source, contentType); releaseCache.delete(key); cacheTimestamps.delete(key); } diff --git a/tests/core/test_releases_api_title_override.py b/tests/core/test_releases_api_title_override.py new file mode 100644 index 00000000..3f5ba0a3 --- /dev/null +++ b/tests/core/test_releases_api_title_override.py @@ -0,0 +1,101 @@ +"""GET /api/releases searches for the title the user picked, not the provider's original. + +A search result can carry a translated title the provider's own book record does not +use: Hardcover lists "Kinder des Nebels" only as an alternative title of "Mistborn: +The Final Empire", and its `get_book()` derives `search_title="The Final Empire"` +from the original. The release modal sends the title it shows; that title has to win +over the provider's `search_title`, or a German search looks for English releases. +""" + +from __future__ import annotations + +import importlib +from unittest.mock import patch + +import pytest + +from shelfmark.metadata_providers import BookMetadata + + +@pytest.fixture(scope="module") +def main_module(): + with patch("shelfmark.download.orchestrator.start"): + import shelfmark.main as main + + importlib.reload(main) + return main + + +@pytest.fixture +def client(main_module): + return main_module.app.test_client() + + +def _authenticate(client) -> None: + with client.session_transaction() as sess: + sess["user_id"] = "alice" + sess["is_admin"] = False + sess["db_user_id"] = 7 + + +class _Provider: + def get_book(self, book_id: str) -> BookMetadata: + return BookMetadata( + provider="hardcover", + provider_id=book_id, + provider_display_name="Hardcover", + title="Mistborn: The Final Empire", + search_title="The Final Empire", + authors=["Brandon Sanderson"], + ) + + +def _searched_titles(client, main_module, title: str) -> list[str]: + """Return the title variants the release source was asked to search for.""" + searched: list[str] = [] + + class _Source: + def search(self, book, plan, *, expand_search=False, content_type="ebook"): + searched.extend(variant.title for variant in plan.title_variants) + return [] + + def get_column_config(self): + from shelfmark.release_sources import _default_column_config + + return _default_column_config() + + with ( + patch.object(main_module, "get_auth_mode", return_value="none"), + patch("shelfmark.metadata_providers.is_provider_registered", return_value=True), + patch("shelfmark.metadata_providers.get_provider_kwargs", return_value={}), + patch("shelfmark.metadata_providers.get_provider", return_value=_Provider()), + patch( + "shelfmark.release_sources.list_available_sources", + return_value=[{"name": "direct_download", "enabled": True}], + ), + patch("shelfmark.release_sources.get_source", return_value=_Source()), + patch("shelfmark.release_sources.source_results_are_releases", return_value=False), + ): + resp = client.get( + "/api/releases", + query_string={"provider": "hardcover", "book_id": "369692", "title": title}, + ) + assert resp.status_code == 200 + return searched + + +def test_searches_the_translated_title_the_user_picked(client, main_module): + _authenticate(client) + + searched = _searched_titles(client, main_module, "Kinder des Nebels") + + assert searched[0] == "Kinder des Nebels" + assert "The Final Empire" not in searched + + +def test_keeps_the_provider_search_title_for_the_original_title(client, main_module): + _authenticate(client) + + searched = _searched_titles(client, main_module, "Mistborn: The Final Empire") + + assert searched[0] == "The Final Empire" diff --git a/tests/metadata/test_hardcover_alternative_title.py b/tests/metadata/test_hardcover_alternative_title.py new file mode 100644 index 00000000..eafd6178 --- /dev/null +++ b/tests/metadata/test_hardcover_alternative_title.py @@ -0,0 +1,82 @@ +from typing import Any + +import pytest + +from shelfmark.core.cache import get_metadata_cache +from shelfmark.metadata_providers import MetadataSearchOptions +from shelfmark.metadata_providers.hardcover import HardcoverProvider + +# Hardcover indexes one book per work and keeps its translations' titles in +# `alternative_titles`. The hit's `highlights` say which field - and which entry of +# an array field - matched the query. Trimmed from a live response for +# "Der Weg der Könige". +WAY_OF_KINGS = { + "id": 978066, + "title": "The Way of Kings, Part 1", + "subtitle": "The Stormlight Archive Book One", + "alternative_titles": [ + "Der Weg der Könige", + "The Way of Kings Part One", + "La Voie des rois, tome 1", + ], + "author_names": ["Brandon Sanderson"], + "release_year": 2010, +} + + +def _hit(*highlighted: tuple[str, list[int]]) -> dict[str, Any]: + return { + "document": WAY_OF_KINGS, + "highlights": [{"field": field, "indices": indices} for field, indices in highlighted], + } + + +def _search(monkeypatch, query: str, hit: dict[str, Any]): + provider = HardcoverProvider(api_key="test-token") + monkeypatch.setattr( + provider, + "_execute_query", + lambda _query, _variables: {"search": {"results": {"hits": [hit], "found": 1}}}, + ) + books = provider.search(MetadataSearchOptions(query=query, limit=5)) + assert len(books) == 1 + return books[0] + + +@pytest.fixture(autouse=True) +def _fresh_search_cache(): + """Each test stubs a different response for the same book; don't reuse results.""" + get_metadata_cache().clear() + yield + get_metadata_cache().clear() + + +class TestHardcoverMatchedAlternativeTitle: + def test_uses_the_translated_title_that_matched(self, monkeypatch): + """Searching a translation by name should surface that translation's title. + + The work's own title is the English original. Showing it - and handing it to + the release search and the download - would put the user's German search + back into English. + """ + book = _search(monkeypatch, "Der Weg der Könige", _hit(("alternative_titles", [0]))) + + assert book.title == "Der Weg der Könige" + assert book.search_title == "Der Weg der Könige" + # The subtitle belongs to the English title; it is not the translation's. + assert book.subtitle is None + + def test_keeps_the_work_title_when_the_title_itself_matched(self, monkeypatch): + book = _search( + monkeypatch, + "The Way of Kings Part 1", + _hit(("title", []), ("alternative_titles", [1])), + ) + + assert book.title == "The Way of Kings, Part 1" + assert book.subtitle == "The Stormlight Archive Book One" + + def test_keeps_the_work_title_without_highlights(self, monkeypatch): + book = _search(monkeypatch, "Sanderson", {"document": WAY_OF_KINGS}) + + assert book.title == "The Way of Kings, Part 1" diff --git a/tests/metadata/test_openlibrary_parse.py b/tests/metadata/test_openlibrary_parse.py new file mode 100644 index 00000000..177397dd --- /dev/null +++ b/tests/metadata/test_openlibrary_parse.py @@ -0,0 +1,118 @@ +from shelfmark.metadata_providers.openlibrary import OpenLibraryProvider + + +class TestOpenLibraryParseSearchDoc: + def test_prefers_matched_edition_over_work_title(self): + """Searching a translated title shouldn't surface the work's original title. + + Open Library groups every edition (including translations) under one work + record, and reports the work's own `title` - usually the original-language + edition - regardless of which translation's text actually matched the query. + Querying "Der Weg der Könige" matches the German edition of Brandon + Sanderson's "The Way of Kings" by title, but the work-level title still says + "The Way of Kings". The nested `editions` block carries the edition whose own + fields satisfied the query, so that edition's title/isbn/cover/language + should win - otherwise both the displayed result and the downstream release + search (which uses `search_title`, see search_plan._pick_search_title) stay + in English even though the user searched for the German edition by name. + """ + provider = OpenLibraryProvider() + + result = provider._parse_search_doc( + { + "key": "/works/OL15358691W", + "title": "The Way of Kings", + "author_name": ["Brandon Sanderson"], + "isbn": ["9780765326355", "0765326353"], + "cover_i": 14658316, + "language": ["eng"], + "editions": { + "docs": [ + { + "key": "/books/OL32771812M", + "title": "Der Weg der Könige", + "cover_i": 14658425, + "language": ["ger"], + "isbn": ["9783453317109", "3453317106"], + "publish_year": [2015], + } + ] + }, + "first_publish_year": 2010, + } + ) + + assert result is not None + assert result.title == "Der Weg der Könige" + assert result.publish_year == 2015 + assert result.search_title == "Der Weg der Könige" + assert result.language == "ger" + assert result.isbn_13 == "9783453317109" + assert result.isbn_10 == "3453317106" + assert result.cover_url == "https://covers.openlibrary.org/b/id/14658425-L.jpg" + + def test_keeps_work_year_when_matched_edition_has_none(self): + provider = OpenLibraryProvider() + + result = provider._parse_search_doc( + { + "key": "/works/OL15358691W", + "title": "The Way of Kings", + "author_name": ["Brandon Sanderson"], + "first_publish_year": 2010, + "editions": { + "docs": [{"key": "/books/OL32771812M", "title": "Der Weg der Könige"}] + }, + } + ) + + assert result is not None + assert result.title == "Der Weg der Könige" + assert result.publish_year == 2010 + + def test_falls_back_to_work_title_without_a_matched_edition(self): + provider = OpenLibraryProvider() + + result = provider._parse_search_doc( + { + "key": "/works/OL15358691W", + "title": "The Way of Kings", + "author_name": ["Brandon Sanderson"], + "isbn": ["9780765326355"], + "cover_i": 14658316, + "language": ["eng"], + } + ) + + assert result is not None + assert result.title == "The Way of Kings" + assert result.search_title is None + assert result.language == "eng" + + def test_ignores_matched_edition_identical_to_work_title(self): + """An edition that happens to share the work's title adds nothing new.""" + provider = OpenLibraryProvider() + + result = provider._parse_search_doc( + { + "key": "/works/OL15358691W", + "title": "The Way of Kings", + "author_name": ["Brandon Sanderson"], + "isbn": ["9780765326355"], + "cover_i": 14658316, + "language": ["eng"], + "editions": { + "docs": [ + { + "key": "/books/OL1M", + "title": "The Way of Kings", + "language": ["eng"], + } + ] + }, + } + ) + + assert result is not None + assert result.search_title is None + assert result.isbn_13 == "9780765326355"