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"