Files
shelfmark/tests/core/test_library_provider_calibre.py
T
splitsec2 37a77e9562 feat(library): mark search results already in a Calibre library (#1377)
Per discussion #1372, where you said you were fine with this specific
implementation: check whether metadata.db exists, read it if so, and
show a check mark saying the book is already there.

Searching for a book you already own gives no hint that you own it, so
the easiest way to end up with a second copy is to not remember you have
the first. This reads a Calibre `metadata.db`, read only, and marks
matching results with an **In library** badge in the card, list and
compact views and in the details dialog.

Off by default. It sits in Settings, General beside the existing Library
URL, with a test button that reports how many books it indexed. No HTTP
call, no token, nothing written back.

Matching runs most to least confident: a shared external id, then an
ISBN compared in both ISBN-10 and ISBN-13 form, then fuzzy title tokens
plus the author surname. The check fails open, so an unreadable database
degrades the badge and never blocks a search, and entries are cached for
ten minutes with an early refresh when the file changes, so a large
library costs one read rather than one per search.

`text_match.py` is new and shared by the index and the provider, so
title, author and ISBN matching stays consistent in one place.

## On the provider interface

`library_index` talks only to a `LibraryProvider` protocol and knows
nothing about Calibre. That is deliberate but it is not speculative
generality, it is what let me send you the Calibre half on its own: I
run an Audiobookshelf provider on the same interface in my fork, which
is where the audiobook side of the badge comes from. I have left that
out because it is a new service integration rather than something
already in the codebase, which is the line your non-goals draw. Happy to
send it separately if you ever want it, and equally happy for the answer
to be no.

Adding a library is a module with the `LibraryProvider` shape plus one
line in `all_providers()`.

## Verification

- `tests/core/test_library_index.py`: id, ISBN and fuzzy matching,
per-content-type provider selection, fail-open on provider errors,
stale-cache reuse, TTL and fingerprint refresh, per-provider cache
isolation, and the test-connection path including unsaved form values.
- `tests/core/test_text_match.py`: ISBN variants and token matching.
- `src/frontend/src/tests/libraryBadge.test.ts` and the added cases in
`bookTransformers.test.ts`.
- Python suite (3269) and frontend suite (206) green, plus ruff, ruff
format, basedpyright, vulture, tsc, oxlint, oxfmt and the production
build.
2026-09-25 18:18:10 -04:00

319 lines
11 KiB
Python

"""Tests for the read-only Calibre metadata.db library provider."""
from __future__ import annotations
import os
import sqlite3
from pathlib import Path
from typing import Any
import pytest
from shelfmark.core import library_index
from shelfmark.core.library_providers import calibre
from shelfmark.metadata_providers import BookMetadata
_SCHEMA = """
CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT NOT NULL, uuid TEXT);
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE books_authors_link (id INTEGER PRIMARY KEY, book INTEGER NOT NULL, author INTEGER NOT NULL);
CREATE TABLE series (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE books_series_link (id INTEGER PRIMARY KEY, book INTEGER NOT NULL, series INTEGER NOT NULL);
CREATE TABLE identifiers (id INTEGER PRIMARY KEY, book INTEGER NOT NULL, type TEXT NOT NULL, val TEXT NOT NULL);
"""
_BOOKS = [
(1, "Dungeon Crawler Carl", "u1"),
(2, "Good Omens", "u2"),
(3, "Untitled Draft", "u3"),
(4, "The Martian", "u4"),
]
_AUTHORS = [(1, "Matt Dinniman"), (2, "Terry Pratchett"), (3, "Neil Gaiman"), (4, "Andy Weir")]
_AUTHOR_LINKS = [(1, 1), (2, 2), (2, 3), (4, 4)] # book 3 has no author
_SERIES = [(1, "Dungeon Crawler Carl")]
_SERIES_LINKS = [(1, 1)]
_IDENTIFIERS = [
(1, "isbn", "9780593820247"),
(1, "mobi-asin", "b08bkgyqxw"),
(1, "hardcover-id", "446681"),
(1, "google", "yEem0QEACAAJ"),
(2, "isbn-10", "0060853980"),
(2, "amazon", "B000FC0V3W"),
(2, "goodreads", "12067"),
(4, "isbn-13", "978-0-8041-3902-1"),
(4, "openlibrary", "OL17091839W"),
]
def _make_library(path: Path, *, wal: bool = False) -> None:
conn = sqlite3.connect(path)
if wal:
conn.execute("PRAGMA journal_mode=wal")
conn.executescript(_SCHEMA)
conn.executemany("INSERT INTO books(id, title, uuid) VALUES (?, ?, ?)", _BOOKS)
conn.executemany("INSERT INTO authors(id, name) VALUES (?, ?)", _AUTHORS)
conn.executemany("INSERT INTO books_authors_link(book, author) VALUES (?, ?)", _AUTHOR_LINKS)
conn.executemany("INSERT INTO series(id, name) VALUES (?, ?)", _SERIES)
conn.executemany("INSERT INTO books_series_link(book, series) VALUES (?, ?)", _SERIES_LINKS)
conn.executemany("INSERT INTO identifiers(book, type, val) VALUES (?, ?, ?)", _IDENTIFIERS)
conn.commit()
conn.close()
def _configure(monkeypatch: pytest.MonkeyPatch, path: Path, *, enabled: bool = True) -> None:
values: dict[str, Any] = {
"CALIBRE_LIBRARY_DB_PATH": str(path),
"LIBRARY_CHECK_CALIBRE_ENABLED": enabled,
}
monkeypatch.setattr(
calibre.app_config,
"get",
lambda key, default=None, user_id=None: values.get(key, default),
)
monkeypatch.setattr(library_index, "_cache", {})
def _infos(monkeypatch: pytest.MonkeyPatch) -> list[str]:
messages: list[str] = []
monkeypatch.setattr(calibre.logger, "info", lambda msg, *args: messages.append(msg % args))
return messages
def _by_token(entries: list[Any], token: str) -> Any:
matches = [entry for entry in entries if token in entry.tokens]
assert len(matches) == 1, token
return matches[0]
def _dcc() -> BookMetadata:
return BookMetadata(
provider="hardcover",
provider_id="446681",
title="Dungeon Crawler Carl",
authors=["Matt Dinniman"],
)
def test_indexes_titles_authors_series_and_identifiers(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = tmp_path / "metadata.db"
_make_library(path)
_configure(monkeypatch, path)
entries = calibre.CalibreLibrary().fetch_entries()
assert len(entries) == 4
dcc = _by_token(entries, "dinniman")
assert {"dungeon", "crawler", "carl", "matt"} <= dcc.tokens
assert dcc.isbns == {"9780593820247"}
assert dcc.asins == {"B08BKGYQXW"}
assert dcc.external_ids == {("hardcover", "446681"), ("googlebooks", "yEem0QEACAAJ")}
omens = _by_token(entries, "omens")
assert {"pratchett", "gaiman"} <= omens.tokens
assert omens.isbns == {"0060853980", "9780060853983"}
assert omens.asins == {"B000FC0V3W"}
assert omens.external_ids == frozenset()
martian = _by_token(entries, "martian")
assert martian.isbns == {"9780804139021"}
assert martian.external_ids == {("openlibrary", "OL17091839W")}
draft = _by_token(entries, "draft")
assert draft.tokens == {"untitled", "draft"}
assert (draft.isbns, draft.asins, draft.external_ids) == (frozenset(), frozenset(), frozenset())
def test_reads_a_read_only_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
path = tmp_path / "metadata.db"
_make_library(path)
path.chmod(0o444)
_configure(monkeypatch, path)
assert len(calibre.CalibreLibrary().fetch_entries()) == 4
def test_wal_reads_see_uncheckpointed_writes(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = tmp_path / "metadata.db"
_make_library(path, wal=True)
_configure(monkeypatch, path)
infos = _infos(monkeypatch)
writer = sqlite3.connect(path)
writer.execute("PRAGMA wal_autocheckpoint=0")
writer.execute("INSERT INTO books(id, title, uuid) VALUES (5, 'Fresh Arrival', 'u5')")
writer.commit()
try:
assert (tmp_path / "metadata.db-wal").exists()
entries = calibre.CalibreLibrary().fetch_entries()
finally:
writer.close()
assert len(entries) == 5
assert infos == []
@pytest.mark.skipif(os.geteuid() == 0, reason="root is not bound by directory permissions")
def test_wal_without_side_files_on_a_read_only_mount_uses_a_snapshot(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
lib_dir = tmp_path / "library"
lib_dir.mkdir()
path = lib_dir / "metadata.db"
_make_library(path, wal=True)
for suffix in ("-wal", "-shm"):
(lib_dir / f"metadata.db{suffix}").unlink(missing_ok=True)
_configure(monkeypatch, path)
infos = _infos(monkeypatch)
lib_dir.chmod(0o555)
try:
entries = calibre.CalibreLibrary().fetch_entries()
finally:
lib_dir.chmod(0o755)
assert len(entries) == 4
assert not (lib_dir / "metadata.db-wal").exists()
assert len(infos) == 1
assert "immutable snapshot" in infos[0]
def test_in_place_open_failure_falls_back_to_an_immutable_open(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = tmp_path / "metadata.db"
_make_library(path)
_configure(monkeypatch, path)
infos = _infos(monkeypatch)
real_connect = sqlite3.connect
uris: list[str] = []
def fake_connect(database: str, **kwargs: Any) -> sqlite3.Connection:
uris.append(database)
if "immutable=1" not in database:
raise sqlite3.OperationalError("attempt to write a readonly database")
return real_connect(database, **kwargs)
monkeypatch.setattr(calibre.sqlite3, "connect", fake_connect)
assert len(calibre.CalibreLibrary().fetch_entries()) == 4
assert [uri.endswith("?mode=ro") for uri in uris] == [True, False]
assert uris[1].endswith("?mode=ro&immutable=1")
assert "attempt to write a readonly database" in infos[0]
def test_locked_database_falls_back_to_a_snapshot(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = tmp_path / "metadata.db"
_make_library(path)
_configure(monkeypatch, path)
monkeypatch.setattr(calibre, "_BUSY_TIMEOUT_SECONDS", 0.01)
infos = _infos(monkeypatch)
writer = sqlite3.connect(path, isolation_level=None)
writer.execute("BEGIN EXCLUSIVE")
writer.execute("INSERT INTO books(id, title, uuid) VALUES (5, 'Pending', 'u5')")
try:
entries = calibre.CalibreLibrary().fetch_entries()
finally:
writer.rollback()
writer.close()
assert len(entries) == 4
assert "database is locked" in infos[0]
def test_unreadable_database_raises_and_the_facade_fails_open(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = tmp_path / "metadata.db"
_make_library(path)
_configure(monkeypatch, path)
def always_fail(database: str, **kwargs: Any) -> sqlite3.Connection:
raise sqlite3.OperationalError("disk I/O error")
monkeypatch.setattr(calibre.sqlite3, "connect", always_fail)
warnings: list[str] = []
monkeypatch.setattr(
library_index.logger, "warning", lambda msg, *args: warnings.append(msg % args)
)
with pytest.raises(sqlite3.OperationalError):
calibre.CalibreLibrary().fetch_entries()
assert library_index.is_in_library(_dcc(), "ebook") is False
assert warnings == [
f"library check: Calibre library at {path} unavailable (disk I/O error); failing open"
]
def test_missing_file_is_enabled_but_fetch_raises_cleanly(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = tmp_path / "missing.db"
_configure(monkeypatch, path)
provider = calibre.CalibreLibrary()
assert provider.is_enabled() is True
assert provider.describe() == f"Calibre library at {path}"
assert provider.fingerprint() is None
with pytest.raises(FileNotFoundError):
provider.fetch_entries()
result = library_index.test_connection("calibre", None)
assert result["success"] is False
assert str(path) in result["message"]
assert library_index.is_in_library(_dcc(), "ebook") is False
def test_disabled_provider_is_not_consulted(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = tmp_path / "metadata.db"
_make_library(path)
_configure(monkeypatch, path, enabled=False)
assert calibre.CalibreLibrary().is_enabled() is False
assert library_index.any_provider_enabled() is False
assert library_index.is_in_library(_dcc(), "ebook") is False
def test_enabled_provider_answers_ebook_lookups_only(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = tmp_path / "metadata.db"
_make_library(path)
_configure(monkeypatch, path)
assert library_index.any_provider_enabled() is True
assert library_index.is_in_library(_dcc(), "ebook") is True
assert library_index.is_in_library(_dcc(), "audiobook") is False
assert library_index.test_connection("calibre", None) == {
"success": True,
"message": f"Calibre library at {path}: indexed 4 item(s).",
}
def test_fingerprint_follows_the_database_and_wal_mtime(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = tmp_path / "metadata.db"
_make_library(path)
_configure(monkeypatch, path)
provider = calibre.CalibreLibrary()
assert provider.fingerprint() == path.stat().st_mtime
wal = tmp_path / "metadata.db-wal"
wal.write_bytes(b"")
later = path.stat().st_mtime + 60
os.utime(wal, (later, later))
assert provider.fingerprint() == later