diff --git a/docs/index.md b/docs/index.md
index 3dd79d1e..3498746d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -17,6 +17,7 @@ Use the guides below to set up the app, connect your library tools, and understa
- [OIDC](oidc.md)
- [API Access](api-access.md)
- [URL Search Parameters](url-search-parameters.md)
+- [Library Check](library-check.md)
- [Custom Scripts](custom-scripts.md)
## Help
diff --git a/docs/library-check.md b/docs/library-check.md
new file mode 100644
index 00000000..33a8f573
--- /dev/null
+++ b/docs/library-check.md
@@ -0,0 +1,43 @@
+# Library Check
+
+Shelfmark can mark search results you already own, so you do not download a second copy
+of a book that is already on your shelf. The check is read only and off by default.
+
+## Calibre
+
+Point Shelfmark at the `metadata.db` of a Calibre library (Calibre, Calibre-Web,
+Calibre-Web-Automated, anything that keeps the standard Calibre format) and it reads the
+database directly. No HTTP call, no API token, and nothing is ever written back.
+
+1. Mount the library folder into the container read only, for example
+ `/path/to/calibre-library:/calibre-library:ro`. Mount the folder rather than the file
+ so the `-wal` and `-shm` sidecars are visible, otherwise a library that is being
+ written to can read as out of date.
+2. In **Settings, General**, turn on **Mark books already in your Calibre library**.
+3. Leave **Calibre metadata.db path** at `/calibre-library/metadata.db` unless you mounted
+ it somewhere else.
+4. Press **Test Calibre library**. It reports how many books it indexed.
+
+A result that matches the library then carries an **In library** badge in the card, list
+and compact views, and in the details dialog.
+
+## How a match is decided
+
+In order of confidence:
+
+1. An external id the metadata provider and the library agree on.
+2. An ISBN, compared in both ISBN-10 and ISBN-13 form.
+3. Fuzzy title tokens plus the author surname, the same rule the rest of the app uses for
+ book matching.
+
+## Behaviour worth knowing
+
+- **It fails open.** If the database cannot be read, Shelfmark logs a warning, reuses the
+ last successful read if it has one, and otherwise treats the book as not owned. A broken
+ path degrades the badge, it never blocks a search.
+- **Results are cached** for ten minutes, and refreshed early when the database file
+ changes, so a large library costs one read rather than one per search.
+- **Ebooks only.** A Calibre library holds ebooks, so the badge answers for ebooks. The
+ provider interface in `shelfmark/core/library_providers/` takes more libraries: add a
+ module with the `LibraryProvider` shape and list it in `all_providers()`. Nothing above
+ that function knows which libraries exist.
diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py
index 476e9d54..4b70b14d 100644
--- a/shelfmark/config/settings.py
+++ b/shelfmark/config/settings.py
@@ -389,6 +389,13 @@ def _clear_metadata_cache(current_values: dict) -> dict:
}
+def _test_calibre_library(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
+ """Action-button callback: read the Calibre database and count the books."""
+ from shelfmark.core import library_index
+
+ return library_index.test_connection("calibre", current_values)
+
+
@register_settings("general", "General", icon="settings", order=0)
def general_settings() -> list[SettingsField]:
"""Core application settings."""
@@ -412,6 +419,31 @@ def general_settings() -> list[SettingsField]:
description="Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text.",
placeholder="http://audiobookshelf:8080",
),
+ CheckboxField(
+ key="LIBRARY_CHECK_CALIBRE_ENABLED",
+ label="Mark books already in your Calibre library",
+ description=(
+ "Read the Calibre metadata.db and mark search results you already own, so you "
+ "do not download a second copy. Read only, nothing is written to the library."
+ ),
+ default=False,
+ ),
+ TextField(
+ key="CALIBRE_LIBRARY_DB_PATH",
+ label="Calibre metadata.db path",
+ description=(
+ "Path to metadata.db as seen from inside the Shelfmark container. Mount the "
+ "Calibre library folder read-only, e.g. /path/to/calibre-library:/calibre-library:ro."
+ ),
+ default="/calibre-library/metadata.db",
+ placeholder="/calibre-library/metadata.db",
+ ),
+ ActionButton(
+ key="test_calibre_library",
+ label="Test Calibre library",
+ description="Check that Shelfmark can read the Calibre database and count the books.",
+ callback=_test_calibre_library,
+ ),
HeadingField(
key="search_defaults_heading",
title="Default Search Filters",
diff --git a/shelfmark/core/library_index.py b/shelfmark/core/library_index.py
new file mode 100644
index 00000000..26d25db8
--- /dev/null
+++ b/shelfmark/core/library_index.py
@@ -0,0 +1,192 @@
+"""Library ownership check: is this book already in one of the user's libraries?
+
+Each ``LibraryProvider`` (see ``library_providers``) indexes its library into
+``LibraryEntry`` rows. This module caches those rows per provider, routes a lookup to
+the providers that hold the requested content type, and matches by identifier first
+(the book's id in the same metadata provider, ISBN in either 10 or 13 form), otherwise
+fuzzy title-token overlap plus the author surname (``text_match``) - the same rule the
+release matcher in ``auto_download`` uses.
+
+Fail-open by design: a disabled or unreachable library never stalls the pipeline.
+``is_in_library`` answers False (or from the stale cache) and logs a warning.
+"""
+
+from __future__ import annotations
+
+import threading
+import time
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+from shelfmark.core.library_providers import all_providers
+from shelfmark.core.logger import setup_logger
+from shelfmark.core.text_match import (
+ COLLECTION_MARKERS,
+ author_surname,
+ extra_work_tokens,
+ isbn_variants,
+ title_tokens_match,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+ from shelfmark.core.library_providers import LibraryEntry, LibraryProvider
+ from shelfmark.metadata_providers import BookMetadata
+
+logger = setup_logger(__name__)
+
+_CACHE_TTL_SECONDS = 600 # Re-index a library at most every 10 minutes unless it changed.
+
+
+@dataclass
+class _CacheSlot:
+ entries: list[LibraryEntry] | None = None
+ fetched_at: float = 0.0
+ fingerprint: object | None = None
+
+
+_lock = threading.Lock()
+_cache: dict[str, _CacheSlot] = {}
+
+
+def _slot(provider_name: str) -> _CacheSlot:
+ with _lock:
+ return _cache.setdefault(provider_name, _CacheSlot())
+
+
+def _store(provider_name: str, entries: list[LibraryEntry], fingerprint: object | None) -> None:
+ slot = _slot(provider_name)
+ with _lock:
+ slot.entries = entries
+ slot.fetched_at = time.monotonic()
+ slot.fingerprint = fingerprint
+
+
+def _entries_for(provider: LibraryProvider) -> list[LibraryEntry]:
+ """Cached entries for one provider, re-indexed past the TTL or when the library changed."""
+ slot = _slot(provider.name)
+ try:
+ fingerprint = provider.fingerprint()
+ with _lock:
+ cached = slot.entries
+ fresh = time.monotonic() - slot.fetched_at < _CACHE_TTL_SECONDS
+ unchanged = fingerprint == slot.fingerprint
+ if cached is not None and fresh and unchanged:
+ return cached
+ entries = provider.fetch_entries()
+ except Exception as exc: # noqa: BLE001 - any failure must fail open
+ logger.warning("library check: %s unavailable (%s); failing open", provider.describe(), exc)
+ with _lock:
+ return slot.entries or [] # Use the stale cache if we have one.
+
+ _store(provider.name, entries, fingerprint)
+ logger.info("library check: indexed %d %s item(s)", len(entries), provider.display_name)
+ return entries
+
+
+def _enabled_providers(content_type: str | None) -> list[LibraryProvider]:
+ return [
+ provider
+ for provider in all_providers()
+ if provider.is_enabled()
+ and (content_type is None or content_type in provider.content_types)
+ ]
+
+
+def any_provider_enabled() -> bool:
+ return any(provider.is_enabled() for provider in all_providers())
+
+
+def match_entries(book: BookMetadata, entries: list[LibraryEntry]) -> str | None:
+ """How ``book`` is held: ``"owned"``, ``"collection"``, or None when it is not.
+
+ ``"collection"`` means a shelf title that bundles several works matched, e.g. an
+ omnibus. The reader has the book, but saying so plainly would misdescribe what is
+ on the shelf.
+ """
+ if not entries:
+ return None
+
+ external_id = (book.provider, str(book.provider_id)) if book.provider_id else None
+ book_isbns = isbn_variants(book.isbn_13) | isbn_variants(book.isbn_10)
+ for entry in entries:
+ if external_id in entry.external_ids or entry.isbns & book_isbns:
+ return "owned"
+
+ title = book.search_title or book.title
+ surname = author_surname(book.search_author or (book.authors[0] if book.authors else ""))
+ collection: str | None = None
+ for entry in entries:
+ if not title_tokens_match(title, set(entry.tokens)):
+ continue
+ if surname is not None and surname not in entry.tokens:
+ continue
+ # A shorter search title is a subset of every longer shelf title sharing its
+ # words, so "Dune" matched "Dune Messiah". Words the shelf adds that name
+ # another work disqualify it; packaging words do not.
+ if extra_work_tokens(set(entry.title_tokens), title, set(entry.context_tokens)):
+ continue
+ if entry.title_tokens & COLLECTION_MARKERS:
+ collection = "collection"
+ continue
+ return "owned"
+
+ return collection
+
+
+def book_matches_entries(book: BookMetadata, entries: list[LibraryEntry]) -> bool:
+ """True if ``book`` is on the shelf at all, however it is packaged."""
+ return match_entries(book, entries) is not None
+
+
+def is_in_library(book: BookMetadata, content_type: str | None = None) -> bool:
+ """True if an enabled library holding ``content_type`` already has ``book`` (fail-open).
+
+ ``content_type`` None consults every enabled provider.
+ """
+ return any(
+ book_matches_entries(book, _entries_for(provider))
+ for provider in _enabled_providers(content_type)
+ )
+
+
+def _holding(book: BookMetadata, content_type: str) -> str | None:
+ """Strongest holding across the enabled libraries for one content type."""
+ kinds = {
+ match_entries(book, _entries_for(provider)) for provider in _enabled_providers(content_type)
+ }
+ if "owned" in kinds:
+ return "owned"
+ return "collection" if "collection" in kinds else None
+
+
+def ownership(book: BookMetadata) -> dict[str, str | None] | None:
+ """Per-format holding for the UI: ``{"ebook": "owned" | "collection" | None}``.
+
+ Only content types with an enabled library are reported; None when no library
+ check is enabled at all. Fail-open like ``is_in_library``.
+ """
+ result = {
+ content_type: _holding(book, content_type)
+ for content_type in ("ebook", "audiobook")
+ if _enabled_providers(content_type)
+ }
+ return result or None
+
+
+def test_connection(provider_name: str, current_values: Mapping[str, Any] | None) -> dict[str, Any]:
+ """Settings action: index one library now and report the item count.
+
+ ``current_values`` are the unsaved form values, so the button works before Save.
+ """
+ provider = next((p for p in all_providers(current_values) if p.name == provider_name), None)
+ if provider is None:
+ return {"success": False, "message": f"Unknown library provider: {provider_name}"}
+ try:
+ fingerprint = provider.fingerprint()
+ entries = provider.fetch_entries()
+ except Exception as exc: # noqa: BLE001 - surface any error to the user
+ return {"success": False, "message": f"{provider.describe()}: {exc}"}
+ _store(provider.name, entries, fingerprint)
+ return {"success": True, "message": f"{provider.describe()}: indexed {len(entries)} item(s)."}
diff --git a/shelfmark/core/library_providers/__init__.py b/shelfmark/core/library_providers/__init__.py
new file mode 100644
index 00000000..16a12789
--- /dev/null
+++ b/shelfmark/core/library_providers/__init__.py
@@ -0,0 +1,73 @@
+"""Library ownership providers.
+
+Each provider indexes one library the user already owns into matchable
+``LibraryEntry`` rows. ``shelfmark.core.library_index`` caches those rows per provider
+and answers whether a requested book is already on the shelf.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, Protocol
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+
+@dataclass(frozen=True)
+class LibraryEntry:
+ """Normalized, matchable representation of one library item."""
+
+ tokens: frozenset[str]
+ isbns: frozenset[str]
+ asins: frozenset[str]
+ # (metadata provider name, id in that provider) pairs, e.g. ("hardcover", "446681").
+ external_ids: frozenset[tuple[str, str]] = frozenset()
+ # Title tokens alone, without the author and series that `tokens` also carries.
+ # The matcher needs them to tell "the shelf title says more than the search did"
+ # from "the shelf entry merely has an author", which decides whether a shorter
+ # search title is the same book or a different one in the same series.
+ title_tokens: frozenset[str] = frozenset()
+ # Series and author words. Shelf titles often bake these in ("Alex Cross 25:
+ # Cross Kill"), so the matcher must not read them as naming a different work.
+ context_tokens: frozenset[str] = frozenset()
+
+
+class LibraryProvider(Protocol):
+ """One owned-book library the ownership check can consult."""
+
+ name: str
+ display_name: str
+ content_types: frozenset[str]
+
+ def is_enabled(self) -> bool: ...
+
+ def fetch_entries(self) -> list[LibraryEntry]: ...
+
+ def describe(self) -> str: ...
+
+ def fingerprint(self) -> object | None:
+ """Cheap change token (e.g. a file mtime); None when the library has none."""
+ ...
+
+
+def setting(
+ config: Any, key: str, default: object = "", overrides: Mapping[str, Any] | None = None
+) -> object:
+ """Read ``key`` from ``config``, preferring a non-empty unsaved form value in ``overrides``."""
+ if overrides:
+ value = overrides.get(key)
+ if value not in (None, ""):
+ return value
+ return config.get(key, default)
+
+
+def all_providers(overrides: Mapping[str, Any] | None = None) -> list[LibraryProvider]:
+ """Concrete providers, in a fixed order. ``overrides`` are unsaved form values.
+
+ Adding a library means writing a module with the :class:`LibraryProvider` shape and
+ listing it here. Nothing above this function knows which libraries exist.
+ """
+ from shelfmark.core.library_providers.calibre import CalibreLibrary
+
+ return [CalibreLibrary(overrides)]
diff --git a/shelfmark/core/library_providers/calibre.py b/shelfmark/core/library_providers/calibre.py
new file mode 100644
index 00000000..1c0611af
--- /dev/null
+++ b/shelfmark/core/library_providers/calibre.py
@@ -0,0 +1,161 @@
+"""Calibre library provider.
+
+Reads a Calibre ``metadata.db`` (plain Calibre, Calibre-Web or Calibre-Web Automated)
+read-only and indexes each book's title, authors, series and identifiers.
+
+The database is opened with ``mode=ro`` first: on a WAL-mode library that sees the
+un-checkpointed writes as long as the ``-wal``/``-shm`` files beside it are visible.
+When that open fails (typically a read-only mount where SQLite cannot create those
+files, or a locked database) it is retried once with ``immutable=1``, a snapshot that
+can lag until Calibre next checkpoints. The file mtime is the change fingerprint, so a
+write or checkpoint refreshes the index ahead of the cache TTL.
+"""
+
+from __future__ import annotations
+
+import re
+import sqlite3
+from contextlib import closing
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+from shelfmark.core.config import config as app_config
+from shelfmark.core.library_providers import LibraryEntry, setting
+from shelfmark.core.logger import setup_logger
+from shelfmark.core.text_match import isbn_variants, tokens
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+logger = setup_logger(__name__)
+
+# "(Alex Cross Series #11)", "[Illustrated Edition]" and friends.
+_PARENTHETICAL = re.compile(r"[(\[][^)\]]*[)\]]")
+
+_DEFAULT_DB_PATH = "/calibre-library/metadata.db"
+_BUSY_TIMEOUT_SECONDS = 5
+
+_ISBN_TYPES = frozenset({"isbn", "isbn13", "isbn-13", "isbn10", "isbn-10"})
+_ASIN_TYPES = frozenset({"amazon", "mobi-asin", "asin"})
+# Calibre identifier type -> Shelfmark metadata provider name.
+_EXTERNAL_ID_TYPES = {
+ "hardcover-id": "hardcover",
+ "google": "googlebooks",
+ "openlibrary": "openlibrary",
+ "olid": "openlibrary",
+}
+
+_BOOKS_SQL = "SELECT id, title FROM books"
+_AUTHORS_SQL = "SELECT l.book, a.name FROM books_authors_link l JOIN authors a ON a.id = l.author"
+_SERIES_SQL = "SELECT l.book, s.name FROM books_series_link l JOIN series s ON s.id = l.series"
+_IDENTIFIERS_SQL = "SELECT book, type, val FROM identifiers"
+
+
+def _db_path(overrides: Mapping[str, Any] | None = None) -> Path:
+ return Path(
+ str(
+ setting(app_config, "CALIBRE_LIBRARY_DB_PATH", _DEFAULT_DB_PATH, overrides)
+ or _DEFAULT_DB_PATH
+ )
+ )
+
+
+def _probe(conn: sqlite3.Connection) -> sqlite3.Connection:
+ """Force the first read so open failures surface here rather than mid-query."""
+ try:
+ conn.execute("PRAGMA schema_version").fetchone()
+ except sqlite3.Error:
+ conn.close()
+ raise
+ return conn
+
+
+def _connect(path: Path) -> sqlite3.Connection:
+ uri = f"{path.absolute().as_uri()}?mode=ro"
+ try:
+ conn = _probe(sqlite3.connect(uri, uri=True, timeout=_BUSY_TIMEOUT_SECONDS))
+ except sqlite3.OperationalError as exc:
+ logger.info(
+ "library check: %s cannot be read in place (%s); using an immutable snapshot, "
+ "which may lag until Calibre checkpoints the library",
+ path,
+ exc,
+ )
+ conn = _probe(sqlite3.connect(f"{uri}&immutable=1", uri=True))
+ return conn
+
+
+def _read_entries(conn: sqlite3.Connection) -> list[LibraryEntry]:
+ titles: dict[int, str] = dict(conn.execute(_BOOKS_SQL).fetchall())
+ series: dict[int, str] = dict(conn.execute(_SERIES_SQL).fetchall())
+
+ authors: dict[int, list[str]] = {}
+ for book_id, name in conn.execute(_AUTHORS_SQL):
+ authors.setdefault(book_id, []).append(name)
+
+ isbns: dict[int, set[str]] = {}
+ asins: dict[int, set[str]] = {}
+ external_ids: dict[int, set[tuple[str, str]]] = {}
+ for book_id, id_type, raw in conn.execute(_IDENTIFIERS_SQL):
+ value = str(raw or "").strip()
+ kind = str(id_type or "").strip().lower()
+ if not value:
+ continue
+ if kind in _ISBN_TYPES:
+ isbns.setdefault(book_id, set()).update(isbn_variants(value))
+ elif kind in _ASIN_TYPES:
+ asins.setdefault(book_id, set()).add(value.upper())
+ elif provider := _EXTERNAL_ID_TYPES.get(kind):
+ external_ids.setdefault(book_id, set()).add((provider, value))
+
+ entries: list[LibraryEntry] = []
+ for book_id, title in titles.items():
+ # A trailing parenthetical is series or edition metadata by convention rather
+ # than part of the work name, and Calibre users often put it there instead of
+ # in the series field. It stays in the recall set below and is dropped from the
+ # title set, which is what decides whether the shelf holds a different book.
+ title_tok = set(tokens(_PARENTHETICAL.sub(" ", title)))
+ context_tok = set(tokens(series.get(book_id)))
+ for name in authors.get(book_id, ()):
+ context_tok |= set(tokens(name))
+ tok = title_tok | context_tok
+ entries.append(
+ LibraryEntry(
+ frozenset(tok),
+ frozenset(isbns.get(book_id, ())),
+ frozenset(asins.get(book_id, ())),
+ frozenset(external_ids.get(book_id, ())),
+ frozenset(title_tok),
+ frozenset(context_tok),
+ )
+ )
+ return entries
+
+
+class CalibreLibrary:
+ """Ebook ownership via a read-only Calibre ``metadata.db``."""
+
+ name = "calibre"
+ display_name = "Calibre"
+ content_types = frozenset({"ebook"})
+
+ def __init__(self, overrides: Mapping[str, Any] | None = None) -> None:
+ self._overrides = overrides
+
+ def is_enabled(self) -> bool:
+ return bool(setting(app_config, "LIBRARY_CHECK_CALIBRE_ENABLED", False, self._overrides))
+
+ def describe(self) -> str:
+ return f"Calibre library at {_db_path(self._overrides)}"
+
+ def fingerprint(self) -> float | None:
+ path = _db_path(self._overrides)
+ candidates = (path, path.with_name(f"{path.name}-wal"))
+ return max((p.stat().st_mtime for p in candidates if p.exists()), default=None)
+
+ def fetch_entries(self) -> list[LibraryEntry]:
+ path = _db_path(self._overrides)
+ if not path.is_file():
+ raise FileNotFoundError(f"No Calibre database at {path}")
+ with closing(_connect(path)) as conn:
+ return _read_entries(conn)
diff --git a/shelfmark/core/text_match.py b/shelfmark/core/text_match.py
new file mode 100644
index 00000000..4c66fa07
--- /dev/null
+++ b/shelfmark/core/text_match.py
@@ -0,0 +1,171 @@
+"""Shared text-normalization + fuzzy token-matching helpers for book matching.
+
+Used by the library ownership check (``library_index``) and its providers, so every
+library matches titles, authors and ISBNs the same way.
+"""
+
+from __future__ import annotations
+
+import re
+
+DEFAULT_TITLE_MATCH_THRESHOLD = 0.85
+
+# Short/common words that add noise to title token matching.
+STOPWORDS = frozenset(
+ {
+ "a",
+ "an",
+ "the",
+ "of",
+ "and",
+ "or",
+ "to",
+ "in",
+ "on",
+ "for",
+ "with",
+ "is",
+ "by",
+ }
+)
+
+
+def tokens(text: str | None) -> list[str]:
+ """Lowercase alphanumeric tokens from arbitrary text."""
+ if not text:
+ return []
+ return [tok for tok in re.split(r"[^a-z0-9]+", text.lower()) if tok]
+
+
+def significant_tokens(text: str | None) -> list[str]:
+ """Tokens with stopwords and 1-char noise removed."""
+ return [tok for tok in tokens(text) if len(tok) >= 2 and tok not in STOPWORDS]
+
+
+def author_surname(author: str | None) -> str | None:
+ """Return the most distinctive author token (the surname), or None."""
+ value = author or ""
+ if "," in value: # "Last, First" -> keep the "Last" portion
+ value = value.split(",")[0]
+ toks = significant_tokens(value)
+ return toks[-1] if toks else None
+
+
+def title_tokens_match(
+ title: str | None,
+ haystack_tokens: set[str],
+ threshold: float = DEFAULT_TITLE_MATCH_THRESHOLD,
+) -> bool:
+ """True when enough significant title tokens appear in ``haystack_tokens``."""
+ title_toks = significant_tokens(title)
+ if not title_toks:
+ return False
+ present = sum(1 for tok in title_toks if tok in haystack_tokens)
+ return (present / len(title_toks)) >= threshold
+
+
+# Words marking a title that bundles several works. A shelf entry carrying one of
+# these holds the searched book, but as part of something larger, which is worth
+# telling the reader apart from owning it on its own.
+COLLECTION_MARKERS = frozenset(
+ {
+ "omnibus",
+ "collection",
+ "complete",
+ "boxed",
+ "boxset",
+ "box",
+ "set",
+ "bundle",
+ "anthology",
+ "compendium",
+ "trilogy",
+ "duology",
+ "books",
+ "volumes",
+ "vols",
+ }
+)
+
+# Words marking a different printing of the same work, which changes nothing about
+# whether the reader owns it.
+EDITION_MARKERS = frozenset(
+ {
+ "edition",
+ "editions",
+ "series",
+ "book",
+ "vol",
+ "volume",
+ "illustrated",
+ "annotated",
+ "unabridged",
+ "abridged",
+ "deluxe",
+ "revised",
+ "reissue",
+ "anniversary",
+ }
+)
+
+# Neither kind is evidence that the shelf holds a different book.
+PACKAGING_MARKERS = COLLECTION_MARKERS | EDITION_MARKERS
+
+
+def extra_work_tokens(
+ shelf_title_tokens: set[str],
+ search_title: str | None,
+ context_tokens: set[str] | None = None,
+) -> set[str]:
+ """Significant words the shelf title adds that suggest a different work.
+
+ Packaging words are ignored, since "Illustrated Edition" and "Books 1-6" describe
+ the same work differently wrapped, while "Messiah" or "Chapter Two" name another
+ Only alphabetic words of three or more characters count, so volume numbers and
+ structural words do not make a shelf entry look like a different book.
+
+ ``context_tokens`` are the entry's series and author words, which shelf titles
+ often repeat ("Alex Cross 25: Cross Kill") without meaning another book.
+ """
+ search_toks = set(tokens(search_title)) | (context_tokens or set())
+ return {
+ tok
+ for tok in shelf_title_tokens
+ # Only a real word counts. Numbers are left alone deliberately: a shelf title
+ # carries volume numbers far more often than it names a numbered sequel, and
+ # wrongly claiming ownership is the costlier mistake, since the sync then
+ # never fetches the book. "Persepolis 2" is the case this concedes.
+ if tok.isalpha()
+ and len(tok) >= 3
+ and tok not in STOPWORDS
+ and tok not in PACKAGING_MARKERS
+ and tok not in search_toks
+ }
+
+
+def normalize_isbn(value: object) -> str:
+ """Normalize an ISBN to comparable form (digits + trailing X, uppercased)."""
+ if not value:
+ return ""
+ return re.sub(r"[^0-9xX]", "", str(value)).upper()
+
+
+def _isbn10_to_isbn13(isbn10: str) -> str | None:
+ """ISBN-13 form of a normalized ISBN-10: 978 prefix plus a recomputed check digit."""
+ core = isbn10[:9]
+ if len(isbn10) != 10 or not core.isdigit():
+ return None
+ digits = f"978{core}"
+ total = sum(int(d) * (1 if i % 2 == 0 else 3) for i, d in enumerate(digits))
+ return f"{digits}{(10 - total % 10) % 10}"
+
+
+def isbn_variants(value: object) -> frozenset[str]:
+ """Comparable forms of an ISBN: normalized, plus the ISBN-13 form of an ISBN-10."""
+ isbn = normalize_isbn(value)
+ if not isbn:
+ return frozenset()
+ variants = {isbn}
+ if len(isbn) == 10 and (isbn13 := _isbn10_to_isbn13(isbn)):
+ variants.add(isbn13)
+ return frozenset(variants)
diff --git a/shelfmark/main.py b/shelfmark/main.py
index e1936cfa..e52cc1f8 100644
--- a/shelfmark/main.py
+++ b/shelfmark/main.py
@@ -2700,6 +2700,8 @@ def api_metadata_search() -> Response | tuple[Response, int]:
if book_dict.get("cover_url"):
cache_id = f"{book_dict['provider']}_{book_dict['provider_id']}"
book_dict["cover_url"] = transform_cover_url(book_dict["cover_url"], cache_id)
+ for book, book_dict in zip(search_result.books, books_data, strict=True):
+ _attach_library_ownership(book, book_dict)
response_data = {
"books": books_data,
@@ -2760,6 +2762,15 @@ def api_metadata_field_options() -> Response:
return jsonify({"options": []})
+def _attach_library_ownership(book: Any, book_dict: dict[str, Any]) -> None:
+ """Add per-format ownership under ``library`` when a library check is enabled."""
+ from shelfmark.core import library_index
+
+ owned = library_index.ownership(book)
+ if owned is not None:
+ book_dict["library"] = owned
+
+
def _resolve_metadata_provider(provider_name: str) -> MetadataProvider:
"""Validate, instantiate and return a ready metadata provider.
@@ -2808,6 +2819,7 @@ def api_metadata_book(provider: str, book_id: str) -> Response | tuple[Response,
return jsonify({"error": "Book not found"}), 404
book_dict = asdict(book)
+ _attach_library_ownership(book, book_dict)
# Transform cover_url to local proxy URL when caching is enabled
from shelfmark.core.utils import transform_cover_url
@@ -3114,6 +3126,7 @@ def api_releases() -> Response | tuple[Response, int]:
# Convert book to dict and transform cover_url
book_dict = asdict(book)
+ _attach_library_ownership(book, book_dict)
from shelfmark.core.utils import transform_cover_url
if book_dict.get("cover_url"):
diff --git a/src/frontend/src/components/DetailsModal.tsx b/src/frontend/src/components/DetailsModal.tsx
index ad5b4af2..d5d598a2 100644
--- a/src/frontend/src/components/DetailsModal.tsx
+++ b/src/frontend/src/components/DetailsModal.tsx
@@ -9,6 +9,7 @@ import { isMetadataBook } from '../types';
import { bookSupportsTargets } from '../utils/bookTargetLoader';
import { isUserCancelledError } from '../utils/errors';
import { BookTargetDropdown } from './BookTargetDropdown';
+import { LibraryBadge, isInLibrary } from './shared';
interface DetailsModalProps {
book: Book | null;
@@ -292,6 +293,13 @@ export const DetailsModal = ({
))}
+ {isMetadata && isInLibrary(book.library) && (
+
+
Library
+
+
+ )}
+
{/* ISBN - Universal mode only */}
{isMetadata && (book.isbn_13 || book.isbn_10) && (
diff --git a/src/frontend/src/components/resultsViews/CardView.tsx b/src/frontend/src/components/resultsViews/CardView.tsx
index e20f5917..9ecbd7b5 100644
--- a/src/frontend/src/components/resultsViews/CardView.tsx
+++ b/src/frontend/src/components/resultsViews/CardView.tsx
@@ -6,7 +6,7 @@ import { getDownloadsCount } from '../../types';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
-import { DisplayFieldBadges } from '../shared';
+import { DisplayFieldBadges, LibraryBadge } from '../shared';
const SkeletonLoader = () => (
@@ -99,6 +99,7 @@ export const CardView = ({
#{book.series_position}