feat: import a Calibre library

Reads metadata.db and copies the books into a library — from a zip uploaded on
the library settings page, or from a path with `litestar calibre-import`. The
source is never touched, and re-running only picks up what is new.

Also names the formats mimetypes does not know: a Calibre library is full of
MOBI and AZW3, and a null content type used to fail the book endpoint.
This commit is contained in:
2026-08-17 13:38:44 -04:00
parent 85367daf7e
commit 55e00ba960
34 changed files with 4723 additions and 50 deletions
+282
View File
@@ -0,0 +1,282 @@
"""
Build a Calibre library on disk, for tests to read.
Generated rather than committed as a binary `metadata.db`, because the rows worth
testing are the awkward ones — the year-101 pubdate, a `|` in an author name, a REAL
series index, HTML in a comment — and those are clearer written out in Python than
hidden inside a blob.
The schema below is Calibre's own, copied from a real library's `sqlite_master`, reduced
to the tables the reader touches. `books_pages_link` is created separately by
`add_pages`: it is recent, and a library made by an older Calibre will not have it.
"""
from __future__ import annotations
import shutil
import sqlite3
from pathlib import Path
SCHEMA = """
CREATE TABLE books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'Unknown' COLLATE NOCASE,
sort TEXT COLLATE NOCASE,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
pubdate TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
series_index REAL NOT NULL DEFAULT 1.0,
author_sort TEXT COLLATE NOCASE,
path TEXT NOT NULL DEFAULT '',
uuid TEXT,
has_cover BOOL DEFAULT 0,
last_modified TIMESTAMP NOT NULL DEFAULT '2000-01-01 00:00:00+00:00'
);
CREATE TABLE authors (
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
sort TEXT COLLATE NOCASE, link TEXT NOT NULL DEFAULT '', UNIQUE(name)
);
CREATE TABLE books_authors_link (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, author INTEGER NOT NULL,
UNIQUE(book, author)
);
CREATE TABLE publishers (
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
sort TEXT COLLATE NOCASE, link TEXT NOT NULL DEFAULT '', UNIQUE(name)
);
CREATE TABLE books_publishers_link (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, publisher INTEGER NOT NULL,
UNIQUE(book)
);
CREATE TABLE tags (
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
link TEXT NOT NULL DEFAULT '', UNIQUE (name)
);
CREATE TABLE books_tags_link (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, tag INTEGER NOT NULL,
UNIQUE(book, tag)
);
CREATE TABLE series (
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
sort TEXT COLLATE NOCASE, link TEXT NOT NULL DEFAULT '', UNIQUE (name)
);
CREATE TABLE books_series_link (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, series INTEGER NOT NULL,
UNIQUE(book)
);
CREATE TABLE languages (
id INTEGER PRIMARY KEY, lang_code TEXT NOT NULL COLLATE NOCASE,
link TEXT NOT NULL DEFAULT '', UNIQUE(lang_code)
);
CREATE TABLE books_languages_link (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, lang_code INTEGER NOT NULL,
item_order INTEGER NOT NULL DEFAULT 0, UNIQUE(book, lang_code)
);
CREATE TABLE comments (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL,
text TEXT NOT NULL COLLATE NOCASE, UNIQUE(book)
);
CREATE TABLE identifiers (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL,
type TEXT NOT NULL DEFAULT 'isbn' COLLATE NOCASE,
val TEXT NOT NULL COLLATE NOCASE, UNIQUE(book, type)
);
CREATE TABLE data (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL,
format TEXT NOT NULL COLLATE NOCASE, uncompressed_size INTEGER NOT NULL,
name TEXT NOT NULL, UNIQUE(book, format)
);
"""
# Calibre's own "no date". Stored, never null, and a valid date — which is exactly why
# it has to be recognised rather than parsed.
UNDEFINED_DATE = "0101-01-01 00:00:00+00:00"
# What a `cover.jpg` that PIL cannot read looks like. Real libraries hold these, from
# an interrupted download or a failed conversion.
CORRUPT_COVER = b"\xff\xd8\xff\xe0 not really a jpeg"
def write_cover(path: Path) -> None:
"""
Write a real, readable JPEG.
Generated with PIL rather than embedded as a hex blob: a hand-rolled JPEG that is
subtly malformed fails inside the import as an unrelated error, which is exactly the
confusion this avoids.
"""
from PIL import Image
Image.new("RGB", (2, 3), (10, 20, 30)).save(path, "JPEG")
class CalibreFixture:
"""A Calibre library being assembled under `root`."""
def __init__(self, root: Path) -> None:
self.root = root
self.root.mkdir(parents=True, exist_ok=True)
self.connection = sqlite3.connect(self.root / "metadata.db")
self.connection.executescript(SCHEMA)
def add_pages_table(self) -> None:
"""Add `books_pages_link`, which only a recent Calibre creates."""
self.connection.executescript(
"""
CREATE TABLE books_pages_link (
book INTEGER PRIMARY KEY,
pages INTEGER DEFAULT 0 NOT NULL,
algorithm INTEGER DEFAULT 0 NOT NULL,
format TEXT DEFAULT '' NOT NULL COLLATE NOCASE,
format_size INTEGER DEFAULT 0 NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
needs_scan INTEGER NOT NULL DEFAULT 0
);
"""
)
def add_book(
self,
book_id: int,
title: str,
*,
authors: list[str] | None = None,
pubdate: str = UNDEFINED_DATE,
series: str | None = None,
series_index: float = 1.0,
tags: list[str] | None = None,
publisher: str | None = None,
languages: list[str] | None = None,
comment: str | None = None,
identifiers: dict[str, str] | None = None,
uuid: str | None = None,
pages: int | None = None,
cover: bool = False,
corrupt_cover: bool = False,
formats: dict[str, Path] | None = None,
directory: str | None = None,
) -> Path:
"""
Add one book, with its files laid out the way Calibre lays them out.
Args:
formats: Format name (`EPUB`) to a real file to copy in. Its on-disk stem is
Calibre's, not the title — that is the point of the `data` table.
directory: Override the `books.path` value, for testing a row whose
directory is not where the convention would put it.
Returns:
The book's directory.
"""
author_names = authors or ["Unknown"]
relative = directory or f"{author_names[0]}/{title} ({book_id})"
book_directory = self.root / relative
book_directory.mkdir(parents=True, exist_ok=True)
self.connection.execute(
"INSERT INTO books (id, title, pubdate, series_index, path, uuid, has_cover) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
book_id,
title,
pubdate,
series_index,
relative,
uuid or f"uuid-{book_id}",
int(cover or corrupt_cover),
),
)
for name in author_names:
self._link("authors", "books_authors_link", "author", book_id, name)
for name in tags or []:
self._link("tags", "books_tags_link", "tag", book_id, name)
if series:
self._link("series", "books_series_link", "series", book_id, series)
if publisher:
self._link(
"publishers", "books_publishers_link", "publisher", book_id, publisher
)
for order, code in enumerate(languages or []):
language_id = self._lookup("languages", "lang_code", code)
self.connection.execute(
"INSERT INTO books_languages_link (book, lang_code, item_order) "
"VALUES (?, ?, ?)",
(book_id, language_id, order),
)
if comment is not None:
self.connection.execute(
"INSERT INTO comments (book, text) VALUES (?, ?)", (book_id, comment)
)
for name, value in (identifiers or {}).items():
self.connection.execute(
"INSERT INTO identifiers (book, type, val) VALUES (?, ?, ?)",
(book_id, name, value),
)
if pages is not None:
self.connection.execute(
"INSERT INTO books_pages_link (book, pages) VALUES (?, ?)",
(book_id, pages),
)
if corrupt_cover:
(book_directory / "cover.jpg").write_bytes(CORRUPT_COVER)
elif cover:
write_cover(book_directory / "cover.jpg")
for format, origin in (formats or {}).items():
# Calibre's on-disk stem: sanitised, truncated, and not the title.
stem = f"{title[:40]} - {author_names[0]}".replace(":", "_")
destination = book_directory / f"{stem}.{format.lower()}"
shutil.copy(origin, destination)
self.connection.execute(
"INSERT INTO data (book, format, uncompressed_size, name) "
"VALUES (?, ?, ?, ?)",
(book_id, format, destination.stat().st_size, stem),
)
return book_directory
def add_missing_format(self, book_id: int, format: str, stem: str) -> None:
"""Record a file in the catalogue without putting one on disk."""
self.connection.execute(
"INSERT INTO data (book, format, uncompressed_size, name) VALUES (?, ?, ?, ?)",
(book_id, format, 1234, stem),
)
def _link(
self, table: str, link_table: str, column: str, book_id: int, name: str
) -> None:
item_id = self._lookup(table, "name", name)
self.connection.execute(
f"INSERT INTO {link_table} (book, {column}) VALUES (?, ?)",
(book_id, item_id),
)
def _lookup(self, table: str, column: str, value: str) -> int:
row = self.connection.execute(
f"SELECT id FROM {table} WHERE {column} = ?", (value,)
).fetchone()
if row:
return int(row[0])
cursor = self.connection.execute(
f"INSERT INTO {table} ({column}) VALUES (?)", (value,)
)
return int(cursor.lastrowid or 0)
def commit(self) -> Path:
"""Finish writing and return the library root."""
self.connection.commit()
self.connection.close()
return self.root
+91
View File
@@ -1272,3 +1272,94 @@ class TestFileManagement:
)
# Should succeed (idempotent)
assert response2.status_code == 204
@pytest.mark.asyncio
class TestUnnameableFormats:
"""
A file whose format nothing can name must still round-trip.
`mimetypes.guess_type` answers None for `.mobi`, `.azw`, `.fb2` and `.lit`, which
is most of what a library imported from elsewhere carries alongside its EPUBs.
`FileMetadataRead.content_type` used to be a required string, so such a book was
created and then failed serialisation on its way back out — a 500 on a book the
reader can otherwise download.
"""
def upload(self, name: str) -> list[tuple[str, tuple]]:
# `application/octet-stream` is what a browser posts for these, and it is not
# an answer — the extension is what names the format.
return [("files", (name, b"BOOKMOBI\x00 payload", "application/octet-stream"))]
async def test_a_mobi_is_named_from_its_extension(
self, authenticated_client: AsyncClient
) -> None:
response = await authenticated_client.post(
"/books?library_id=1", files=self.upload("Dune.mobi"), data={"library_id": 1}
)
assert response.status_code == 201
book = response.json()
assert book["files"][0]["content_type"] == "application/x-mobipocket-ebook"
detail = await authenticated_client.get(f"/books/{book['id']}")
assert detail.status_code == 200
async def test_an_unknown_extension_stores_no_content_type(
self, authenticated_client: AsyncClient
) -> None:
"""Null, not a placeholder — and the book still serialises either way."""
response = await authenticated_client.post(
"/books?library_id=1",
files=self.upload("Notes.xyzzy"),
data={"library_id": 1},
)
assert response.status_code == 201
book = response.json()
assert book["files"][0]["content_type"] is None
detail = await authenticated_client.get(f"/books/{book['id']}")
assert detail.status_code == 200
assert detail.json()["files"][0]["content_type"] is None
async def test_the_file_downloads(
self, authenticated_client: AsyncClient
) -> None:
"""Litestar supplies its own media type when the row carries none."""
created = await authenticated_client.post(
"/books?library_id=1",
files=self.upload("Notes.xyzzy"),
data={"library_id": 1},
)
book = created.json()
response = await authenticated_client.get(
f"/books/download/{book['id']}/{book['files'][0]['id']}"
)
assert response.status_code == 200
assert response.headers["content-type"] == "application/octet-stream"
async def test_the_opds_feed_survives_a_null_content_type(
self, authenticated_client: AsyncClient
) -> None:
"""
The one place the type has to be a string.
`Link.type` is required, so a null fails the whole feed rather than one entry.
OPDS clients speak Basic, not the JWT the rest of the API uses.
"""
await authenticated_client.post(
"/books?library_id=1",
files=self.upload("Notes.xyzzy"),
data={"library_id": 1},
)
feed = await authenticated_client.get(
"/opds/acquisition?feed_id=all&feed_title=All+Books",
auth=("user1@example.com", "password123"),
)
assert feed.status_code == 200
assert 'type="application/octet-stream"' in feed.text
@@ -0,0 +1,400 @@
"""
Tests for the Calibre import endpoints.
The API takes a zipped library and nothing else — a desktop Calibre install is usually
not on the server, and importing from a path the server can already see stays a
server-side operation (`litestar calibre-import`).
"""
import asyncio
import tempfile
import zipfile
from pathlib import Path
import pytest
from httpx import AsyncClient
from chitai.services.calibre_import import registry
from tests.calibre_fixtures import CalibreFixture
EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
OTHER_EPUB = Path("tests/data_files/The Art of War - Sun Tzu.epub")
@pytest.fixture(autouse=True)
def _clear_registry():
"""The registry is a module-level singleton, so it leaks between tests."""
registry._jobs.clear()
yield
registry._jobs.clear()
@pytest.fixture(name="source")
def fx_source(tmp_path: Path) -> Path:
fixture = CalibreFixture(tmp_path / "calibre")
fixture.add_book(
1,
"The Metamorphosis",
authors=["Franz Kafka"],
tags=["Fiction"],
cover=True,
formats={"EPUB": EPUB},
)
fixture.add_book(2, "The Art of War", authors=["Sun Tzu"], formats={"EPUB": OTHER_EPUB})
fixture.add_book(3, "Metadata Only", authors=["Nobody"])
return fixture.commit()
def zip_of(root: Path, into: Path, prefix: str = "") -> Path:
"""Zip a directory the way a file manager would."""
into.mkdir(parents=True, exist_ok=True)
archive = into / "library.zip"
with zipfile.ZipFile(archive, "w") as writing:
for path in sorted(root.rglob("*")):
if path.is_file():
writing.write(path, f"{prefix}{path.relative_to(root)}")
return archive
async def upload(
client: AsyncClient,
archive: Path,
library_id: int = 1,
allow_duplicates: bool = False,
) -> tuple[int, dict]:
response = await client.post(
f"/libraries/{library_id}/imports/calibre/upload",
files=[("archive", (archive.name, archive.read_bytes(), "application/zip"))],
data={"allow_duplicates": str(allow_duplicates).lower()},
)
return response.status_code, response.json()
async def wait_for(client: AsyncClient, job_id: str) -> dict:
"""Poll until the job is no longer running, the way the screen does."""
for _ in range(200):
response = await client.get(f"/libraries/imports/{job_id}")
assert response.status_code == 200
job = response.json()
if job["state"] != "running":
return job
await asyncio.sleep(0.05)
raise AssertionError("the import never finished")
async def test_an_uploaded_library_imports(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
status, job = await upload(
authenticated_client, zip_of(source, tmp_path / "out", prefix="Calibre Library/")
)
assert status == 202
assert job["state"] == "running"
assert job["library_id"] == 1
# The archive's name, not the temp directory it was unpacked into.
assert job["source"] == "library.zip"
finished = await wait_for(authenticated_client, job["id"])
assert finished["state"] == "finished"
assert finished["total"] == 3
assert finished["created"] == 2
assert finished["skipped"] == 1
assert finished["failed"] == 0
assert finished["error"] is None
assert finished["current_title"] is None
listed = await authenticated_client.get("/books?library_id=1")
titles = [book["title"] for book in listed.json()["items"]]
assert "The Metamorphosis" in titles
assert "The Art of War" in titles
async def test_a_library_zipped_without_a_wrapping_folder(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
"""Zipping the contents is as common as zipping the folder."""
status, job = await upload(authenticated_client, zip_of(source, tmp_path / "out"))
assert status == 202
finished = await wait_for(authenticated_client, job["id"])
assert finished["created"] == 2
async def test_the_unpacked_copy_is_cleaned_up(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
"""
An unpacked archive is a second copy of the whole library.
The books worth keeping have been copied into the library by the time the job ends,
so nothing is lost with it — and nothing will come back for it.
"""
status, job = await upload(authenticated_client, zip_of(source, tmp_path / "out"))
assert status == 202
workspace = registry.get(job["id"]).workspace
assert workspace is not None
await wait_for(authenticated_client, job["id"])
assert not workspace.exists()
async def test_uploading_the_same_library_twice_imports_nothing_new(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
"""Re-running is safe, which is what makes an interrupted import resumable."""
archive = zip_of(source, tmp_path / "out")
for _ in range(2):
_, job = await upload(authenticated_client, archive)
finished = await wait_for(authenticated_client, job["id"])
assert finished["created"] == 0
assert finished["skipped"] == 3
async def test_allow_duplicates_stores_the_files_again(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
"""
The one option the screen offers, and it has to reach the import.
Without it the second pass skips everything, which is the previous test.
"""
archive = zip_of(source, tmp_path / "out")
_, first = await upload(authenticated_client, archive)
await wait_for(authenticated_client, first["id"])
_, second = await upload(authenticated_client, archive, allow_duplicates=True)
finished = await wait_for(authenticated_client, second["id"])
assert finished["created"] == 2
assert finished["skipped"] == 1 # still the book with no files
async def test_two_imports_into_one_library_conflict(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
archive = zip_of(source, tmp_path / "out")
first_status, first = await upload(authenticated_client, archive)
assert first_status == 202
second_status, second = await upload(authenticated_client, archive)
assert second_status == 409
assert second["extra"]["job_id"] == first["id"]
await wait_for(authenticated_client, first["id"])
async def test_a_finished_import_does_not_block_the_next_one(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
archive = zip_of(source, tmp_path / "out")
_, first = await upload(authenticated_client, archive)
await wait_for(authenticated_client, first["id"])
status, second = await upload(authenticated_client, archive)
assert status == 202
await wait_for(authenticated_client, second["id"])
async def test_cancelling_stops_after_the_current_book(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
"""
Cancelling is not aborting: a book abandoned mid-copy would leave files with no row.
Whether this cancels before any book, after one, or after the lot is a race — the
catalogue is three books long. What must hold either way is that the state is
terminal and every book it did import is complete.
"""
_, job = await upload(authenticated_client, zip_of(source, tmp_path / "out"))
cancelled = await authenticated_client.delete(f"/libraries/imports/{job['id']}")
assert cancelled.status_code == 200
final = await wait_for(authenticated_client, job["id"])
assert final["state"] in {"cancelled", "finished"}
listed = await authenticated_client.get("/books?library_id=1")
for book in listed.json()["items"]:
assert book["files"]
async def test_failures_are_reported_on_the_job(
authenticated_client: AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""One book failing is recorded and does not stop the run."""
fixture = CalibreFixture(tmp_path / "calibre")
fixture.add_book(1, "Fine", authors=["A"], formats={"EPUB": EPUB})
fixture.add_book(2, "Doomed", authors=["B"], formats={"EPUB": OTHER_EPUB})
root = fixture.commit()
from chitai.services.book import BookService
original = BookService.create
async def fail_on_the_second(self, data, *args, **kwargs):
if isinstance(data, dict) and data.get("title") == "Doomed":
raise RuntimeError("no room on the shelf")
return await original(self, data, *args, **kwargs)
monkeypatch.setattr(BookService, "create", fail_on_the_second)
_, job = await upload(authenticated_client, zip_of(root, tmp_path / "out"))
finished = await wait_for(authenticated_client, job["id"])
assert finished["state"] == "finished"
assert finished["created"] == 1
assert finished["failed"] == 1
assert finished["failures"][0]["calibre_id"] == 2
assert "no room on the shelf" in finished["failures"][0]["reason"]
async def test_a_second_copy_is_counted_as_a_possible_duplicate(
authenticated_client: AsyncClient, tmp_path: Path
) -> None:
"""A count, not the records — the duplicates screen is what shows them."""
padded = tmp_path / "padded.epub"
padded.write_bytes(EPUB.read_bytes() + b"\0" * 64)
fixture = CalibreFixture(tmp_path / "calibre")
fixture.add_book(1, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": EPUB})
fixture.add_book(
2, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": padded}
)
root = fixture.commit()
_, job = await upload(authenticated_client, zip_of(root, tmp_path / "out"))
finished = await wait_for(authenticated_client, job["id"])
assert finished["created"] == 2
assert finished["possible_duplicates"] == 1
async def test_polling_an_unknown_job(authenticated_client: AsyncClient) -> None:
response = await authenticated_client.get("/libraries/imports/not-a-job")
assert response.status_code == 404
async def test_cancelling_an_unknown_job(authenticated_client: AsyncClient) -> None:
response = await authenticated_client.delete("/libraries/imports/not-a-job")
assert response.status_code == 404
async def test_importing_into_a_library_that_does_not_exist(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
status, _ = await upload(
authenticated_client, zip_of(source, tmp_path / "out"), library_id=999
)
assert status == 404
async def test_importing_into_a_read_only_library(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
"""A read-only library points at a tree Chitai does not own."""
root = tmp_path / "read-only"
root.mkdir()
created = await authenticated_client.post(
"/libraries",
json={"name": "Read Only", "root_path": str(root), "read_only": True},
)
assert created.status_code == 201
status, body = await upload(
authenticated_client,
zip_of(source, tmp_path / "out"),
library_id=created.json()["id"],
)
assert status == 400
assert "read-only" in body["detail"]
async def test_an_import_needs_authentication(
client: AsyncClient, source: Path, tmp_path: Path
) -> None:
status, _ = await upload(client, zip_of(source, tmp_path / "out"))
assert status == 401
class TestRefusedArchives:
"""Everything wrong with an archive is answered now, not as a job that fails later."""
async def test_a_hostile_archive(
self, authenticated_client: AsyncClient, tmp_path: Path
) -> None:
"""Zip slip."""
archive = tmp_path / "hostile.zip"
with zipfile.ZipFile(archive, "w") as writing:
writing.writestr("metadata.db", "not really")
writing.writestr("../../escaped.txt", "gotcha")
status, body = await upload(authenticated_client, archive)
assert status == 400
assert "outside itself" in body["detail"]
assert registry._jobs == {}
async def test_something_that_is_not_a_zip(
self, authenticated_client: AsyncClient, tmp_path: Path
) -> None:
archive = tmp_path / "notes.txt"
archive.write_bytes(b"just some text")
status, body = await upload(authenticated_client, archive)
assert status == 400
assert "not a zip" in body["detail"]
async def test_an_archive_with_no_catalogue(
self, authenticated_client: AsyncClient, tmp_path: Path
) -> None:
archive = tmp_path / "books.zip"
with zipfile.ZipFile(archive, "w") as writing:
writing.writestr("Some Book.epub", "content")
status, body = await upload(authenticated_client, archive)
assert status == 400
assert "no metadata.db" in body["detail"]
async def test_a_refusal_leaves_no_temp_files(
self, authenticated_client: AsyncClient, tmp_path: Path
) -> None:
"""Every refusal path removes the workspace it had already made."""
before = set(Path(tempfile.gettempdir()).glob("tmp*"))
archive = tmp_path / "books.zip"
with zipfile.ZipFile(archive, "w") as writing:
writing.writestr("Some Book.epub", "content")
await upload(authenticated_client, archive)
assert set(Path(tempfile.gettempdir()).glob("tmp*")) == before
+392
View File
@@ -0,0 +1,392 @@
import zipfile
from datetime import date
from pathlib import Path
from types import SimpleNamespace
import pytest
from chitai.services.calibre import (
CalibreLibrary,
CalibreLibraryError,
extract_calibre_archive,
format_series_index,
parse_date,
strip_html,
unescape_author,
)
from tests.calibre_fixtures import UNDEFINED_DATE, CalibreFixture
EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
@pytest.fixture(name="library_root")
def fx_library_root(tmp_path: Path) -> Path:
"""A small Calibre library covering the rows that are easy to read wrongly."""
fixture = CalibreFixture(tmp_path / "Calibre Library")
fixture.add_pages_table()
fixture.add_book(
1,
"The Metamorphosis",
authors=["Franz Kafka"],
pubdate="1915-10-15 00:00:00+00:00",
tags=["Fiction", "Absurdist"],
publisher="Kurt Wolff Verlag",
languages=["deu", "eng"],
comment="<p>A travelling salesman.</p><p>He wakes up <i>changed</i>.</p>",
identifiers={"isbn": "978-0-486-29030-0", "amazon": "B01N5IB20Q"},
uuid="11111111-2222-3333-4444-555555555555",
pages=201,
cover=True,
formats={"EPUB": EPUB},
)
# Volume seven of a series, and no publication date — the two values most likely to
# be carried through verbatim when they should not be.
fixture.add_book(
2,
"Persepolis Rising",
# Calibre escapes the comma and nothing else, so the space after it is stored
# as-is: `Corey, Jr.` is written `Corey| Jr.`.
authors=["Corey| Jr., James S. A."],
series="The Expanse",
series_index=7.0,
formats={"EPUB": EPUB},
)
# A novella between two novels: a fractional position is real and must survive.
fixture.add_book(
3, "Strange Dogs", series="The Expanse", series_index=6.5, formats={"PDF": PDF}
)
# Every row Calibre will happily hold and Chitai cannot use: no files at all.
fixture.add_book(4, "Metadata Only")
# A catalogue row whose file is not on disk.
fixture.add_book(5, "Lost Book")
fixture.add_missing_format(5, "EPUB", "Lost Book - Unknown")
return fixture.commit()
async def test_reads_a_book_whole(library_root: Path) -> None:
async with CalibreLibrary(library_root) as library:
assert await library.count() == 5
books = await library.books()
book = books[0]
assert book.calibre_id == 1
assert book.title == "The Metamorphosis"
assert book.authors == ["Franz Kafka"]
assert book.published_date == date(1915, 10, 15)
assert book.tags == ["Absurdist", "Fiction"]
assert book.publisher == "Kurt Wolff Verlag"
assert book.pages == 201
assert book.uuid == "11111111-2222-3333-4444-555555555555"
# One language, and the one Calibre put first.
assert book.language == "deu"
# Reported as Calibre wrote them: folding `amazon` onto `asin` is the importer's
# job, not the reader's.
assert book.identifiers == {"isbn": "978-0-486-29030-0", "amazon": "B01N5IB20Q"}
assert book.cover is not None
assert book.cover.is_file()
assert len(book.files) == 1
assert book.files[0].format == "EPUB"
assert book.files[0].path.is_file()
# The stem is Calibre's, truncated and sanitised — never the title.
assert book.files[0].path.name != f"{book.title}.epub"
async def test_the_undefined_date_is_not_a_date(library_root: Path) -> None:
"""`0101-01-01` parses fine, which is exactly the problem."""
async with CalibreLibrary(library_root) as library:
books = {book.calibre_id: book for book in await library.books()}
assert books[2].published_date is None
async def test_series_position_is_a_plain_string(library_root: Path) -> None:
async with CalibreLibrary(library_root) as library:
books = {book.calibre_id: book for book in await library.books()}
assert books[2].series == "The Expanse"
assert books[2].series_position == "7"
assert books[3].series_position == "6.5"
# `series_index` defaults to 1.0 for every book, so a position without a series
# would invent a volume one out of nothing.
assert books[1].series is None
assert books[1].series_position is None
async def test_author_commas_are_unescaped(library_root: Path) -> None:
async with CalibreLibrary(library_root) as library:
books = {book.calibre_id: book for book in await library.books()}
assert books[2].authors == ["Corey, Jr., James S. A."]
async def test_comments_come_back_as_text(library_root: Path) -> None:
async with CalibreLibrary(library_root) as library:
books = {book.calibre_id: book for book in await library.books()}
assert books[1].description == "A travelling salesman.\nHe wakes up changed."
assert books[2].description is None
async def test_files_are_reported_whether_or_not_they_exist(library_root: Path) -> None:
"""
The reader says what the catalogue says. Whether the bytes are there is a question
for whoever is about to copy them, which stats them anyway.
"""
async with CalibreLibrary(library_root) as library:
books = {book.calibre_id: book for book in await library.books()}
assert books[4].files == []
assert len(books[5].files) == 1
assert not books[5].files[0].path.exists()
async def test_a_library_without_the_pages_table_still_reads(tmp_path: Path) -> None:
"""`books_pages_link` is recent; an older library simply does not have it."""
fixture = CalibreFixture(tmp_path / "Old Library")
fixture.add_book(1, "Old Book", formats={"EPUB": EPUB})
root = fixture.commit()
async with CalibreLibrary(root) as library:
books = await library.books()
assert books[0].pages is None
async def test_the_original_is_never_opened(library_root: Path) -> None:
"""
The catalogue is copied before it is read, and the copy goes away afterwards.
Calibre may be running and writing; this is what keeps a live library out of it.
"""
before = (library_root / "metadata.db").read_bytes()
library = CalibreLibrary(library_root)
await library.open()
workspace = library._workspace
assert workspace is not None and (workspace / "metadata.db").is_file()
await library.close()
assert not workspace.exists()
assert (library_root / "metadata.db").read_bytes() == before
async def test_closing_twice_is_harmless(library_root: Path) -> None:
library = CalibreLibrary(library_root)
await library.open()
await library.close()
await library.close()
async def test_a_directory_that_is_not_a_calibre_library(tmp_path: Path) -> None:
with pytest.raises(CalibreLibraryError, match="not a Calibre library"):
await CalibreLibrary(tmp_path).open()
@pytest.mark.parametrize(
("stored", "expected"),
[
("2017-12-04 04:00:00+00:00", date(2017, 12, 4)),
("2001-07-02 00:00:00+00:00", date(2001, 7, 2)),
("1999-01-31", date(1999, 1, 31)),
# Calibre's sentinel, and anything else implausibly early.
(UNDEFINED_DATE, None),
("0101-01-01", None),
(None, None),
("", None),
("not a date", None),
],
)
def test_parse_date(stored: str | None, expected: date | None) -> None:
assert parse_date(stored) == expected
@pytest.mark.parametrize(
("index", "expected"),
[
(7.0, "7"),
(1.0, "1"),
(6.5, "6.5"),
(0.0, "0"),
(12.25, "12.25"),
(None, None),
],
)
def test_format_series_index(index: float | None, expected: str | None) -> None:
assert format_series_index(index) == expected
@pytest.mark.parametrize(
("stored", "expected"),
[
("Doyle| Sir Arthur Conan", "Doyle, Sir Arthur Conan"),
("Franz Kafka", "Franz Kafka"),
(" Herman Melville ", "Herman Melville"),
],
)
def test_unescape_author(stored: str, expected: str) -> None:
assert unescape_author(stored) == expected
@pytest.mark.parametrize(
("html", "expected"),
[
("<p>One.</p><p>Two.</p>", "One.\nTwo."),
("Plain text", "Plain text"),
("<div>A<br>B</div>", "A\nB"),
("<p>Caf&eacute; &amp; bar</p>", "Café & bar"),
("<ul><li>One</li><li>Two</li></ul>", "One\nTwo"),
# Markup carrying no text at all is nothing, not an empty description.
("<p></p>", None),
("", None),
(None, None),
],
)
def test_strip_html(html: str | None, expected: str | None) -> None:
assert strip_html(html) == expected
class TestArchives:
"""A Calibre library that arrives zipped rather than as a path."""
def zipped(self, root: Path, into: Path, prefix: str = "") -> Path:
"""Zip a directory the way a file manager would."""
archive = into / "library.zip"
with zipfile.ZipFile(archive, "w") as writing:
for path in sorted(root.rglob("*")):
if path.is_file():
writing.write(path, f"{prefix}{path.relative_to(root)}")
return archive
async def test_a_library_zipped_at_its_root(
self, library_root: Path, tmp_path: Path
) -> None:
archive = self.zipped(library_root, tmp_path)
destination = tmp_path / "unpacked"
destination.mkdir()
catalogue = await extract_calibre_archive(archive, destination)
assert catalogue == destination
async with CalibreLibrary(catalogue) as library:
assert await library.count() == 5
async def test_a_library_zipped_inside_a_folder(
self, library_root: Path, tmp_path: Path
) -> None:
"""Zipping the folder itself is at least as common as zipping its contents."""
archive = self.zipped(library_root, tmp_path, prefix="Calibre Library/")
destination = tmp_path / "unpacked"
destination.mkdir()
catalogue = await extract_calibre_archive(archive, destination)
assert catalogue == destination / "Calibre Library"
async with CalibreLibrary(catalogue) as library:
assert await library.count() == 5
async def test_an_entry_pointing_outside_the_archive_is_refused(
self, tmp_path: Path
) -> None:
"""
Zip slip. `ZipFile.extract` sanitises names itself, but relying on that silently
is how the next person to change the extraction call reintroduces it.
"""
archive = tmp_path / "hostile.zip"
with zipfile.ZipFile(archive, "w") as writing:
writing.writestr("metadata.db", "not really")
writing.writestr("../../escaped.txt", "gotcha")
destination = tmp_path / "unpacked"
destination.mkdir()
with pytest.raises(CalibreLibraryError, match="outside itself"):
await extract_calibre_archive(archive, destination)
assert not (tmp_path.parent / "escaped.txt").exists()
async def test_something_that_is_not_a_zip(self, tmp_path: Path) -> None:
archive = tmp_path / "not.zip"
archive.write_bytes(b"PK-ish, but no")
destination = tmp_path / "unpacked"
destination.mkdir()
with pytest.raises(CalibreLibraryError, match="not a zip file"):
await extract_calibre_archive(archive, destination)
async def test_an_archive_with_no_catalogue(self, tmp_path: Path) -> None:
archive = tmp_path / "books.zip"
with zipfile.ZipFile(archive, "w") as writing:
writing.writestr("Some Book.epub", "content")
destination = tmp_path / "unpacked"
destination.mkdir()
with pytest.raises(CalibreLibraryError, match="no metadata.db"):
await extract_calibre_archive(archive, destination)
# Refused before anything was written.
assert list(destination.iterdir()) == []
async def test_a_catalogue_buried_too_deep(self, tmp_path: Path) -> None:
"""Somebody's whole backup tree is not a library, however much it contains one."""
archive = tmp_path / "backup.zip"
with zipfile.ZipFile(archive, "w") as writing:
writing.writestr("backups/2026/january/library/metadata.db", "not really")
destination = tmp_path / "unpacked"
destination.mkdir()
with pytest.raises(CalibreLibraryError, match="within 3 levels"):
await extract_calibre_archive(archive, destination)
async def test_an_archive_too_big_for_the_disk(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
Checked before writing, not discovered part-way through.
A full disk takes the whole application down, and the size is in the archive
already.
"""
archive = tmp_path / "huge.zip"
with zipfile.ZipFile(archive, "w") as writing:
writing.writestr("metadata.db", "not really")
destination = tmp_path / "unpacked"
destination.mkdir()
monkeypatch.setattr(
"chitai.services.calibre.shutil.disk_usage",
lambda _path: SimpleNamespace(total=1024, used=1024, free=0),
)
with pytest.raises(CalibreLibraryError, match="only"):
await extract_calibre_archive(archive, destination)
assert list(destination.iterdir()) == []
+64
View File
@@ -0,0 +1,64 @@
from pathlib import Path
import pytest
from chitai.services.utils import guess_content_type
@pytest.mark.parametrize(
("filename", "expected"),
[
# What `mimetypes` already knows, kept here so a host with a thin
# /etc/mime.types cannot change the answer without a test noticing.
("Frankenstein.epub", "application/epub+zip"),
("Calculus.pdf", "application/pdf"),
("Persepolis.azw3", "application/vnd.amazon.mobi8-ebook"),
("Watchmen.cbz", "application/vnd.comicbook+zip"),
# What it does not, and where a Calibre library's older formats live.
("Dune.mobi", "application/x-mobipocket-ebook"),
("Dune.prc", "application/x-mobipocket-ebook"),
("Dune.azw", "application/vnd.amazon.ebook"),
("Voyna i Mir.fb2", "application/x-fictionbook+xml"),
("Voyna i Mir.fbz", "application/x-zip-compressed-fb2"),
("Reader.lit", "application/x-ms-reader"),
("Reader.lrf", "application/x-sony-bbeb"),
("Watchmen.cb7", "application/x-cb7"),
# Case is not part of the answer, and Calibre writes formats uppercase.
("Dune.MOBI", "application/x-mobipocket-ebook"),
# Nothing can name these, and None is the answer rather than a placeholder.
("Notes.xyzzy", None),
("README", None),
],
)
def test_guess_content_type(filename: str, expected: str | None) -> None:
assert guess_content_type(Path(filename)) == expected
# A str and a Path must agree, and an upload's `filename` carries its relative
# path, so a name with directories in front of it has to resolve the same way.
assert guess_content_type(filename) == expected
assert guess_content_type(f"Some Author/Some Book/{filename}") == expected
def test_fallback_is_used_only_when_the_extension_says_nothing() -> None:
"""A client's claim fills a gap; it never overrides the name."""
assert (
guess_content_type(Path("Dune.mobi"), fallback="application/pdf")
== "application/x-mobipocket-ebook"
)
assert (
guess_content_type(Path("Notes.xyzzy"), fallback="application/epub+zip")
== "application/epub+zip"
)
def test_an_unspecified_fallback_is_not_an_answer() -> None:
"""
`application/octet-stream` from a client is it saying it does not know.
Browsers post exactly that for every extension they do not recognise, which is most
ebook formats. Storing it would be indistinguishable from having determined a
format, so it is discarded and the column keeps its null.
"""
assert (
guess_content_type(Path("Notes.xyzzy"), fallback="application/octet-stream")
is None
)
@@ -0,0 +1,69 @@
"""Tests for BookPathGenerator."""
from pathlib import Path
from chitai.services.filesystem_library import BookPathGenerator, sanitize_path_component
ROOT = Path("/library")
def path_for(**book) -> Path:
return BookPathGenerator(ROOT).generate_path(book)
def test_author_and_title() -> None:
assert path_for(title="Dune", authors=["Frank Herbert"]) == (
ROOT / "Frank Herbert" / "Dune"
)
def test_a_book_with_no_authors() -> None:
assert path_for(title="Beowulf", authors=[]) == ROOT / "Unknown" / "Beowulf"
def test_a_series_adds_a_level_and_pads_the_position() -> None:
assert path_for(
title="Persepolis Rising",
authors=["James S. A. Corey"],
series="The Expanse",
series_position="7",
) == ROOT / "James S. A. Corey" / "The Expanse" / "07 - Persepolis Rising"
def test_a_slash_in_a_title_does_not_add_a_directory() -> None:
"""
The separators in the path come from the template, never from the metadata.
A title with a slash in it — "AC/DC", "Him/Her" — would otherwise put the book one
level below where `book.path` says it is, which is what deletes, moves and file
lookups all act on. Calibre keeps the real title in its database and strips this
from its own directory names, so an import is where they surface.
"""
generated = path_for(title="Back in Black: AC/DC", authors=["Murray Engleheart"])
assert generated == ROOT / "Murray Engleheart" / "Back in Black: AC_DC"
assert generated.relative_to(ROOT).parts == ("Murray Engleheart", "Back in Black: AC_DC")
def test_a_slash_in_an_author_or_series_is_handled_too() -> None:
assert path_for(title="Split", authors=["A/B Collective"]) == (
ROOT / "A_B Collective" / "Split"
)
assert path_for(
title="Volume One", authors=["Someone"], series="Either/Or", series_position="1"
) == ROOT / "Someone" / "Either_Or" / "01 - Volume One"
def test_control_characters_are_removed() -> None:
assert path_for(title="Line\nBreak", authors=["Someone"]) == (
ROOT / "Someone" / "Line_Break"
)
def test_sanitize_path_component() -> None:
assert sanitize_path_component("AC/DC") == "AC_DC"
assert sanitize_path_component("back\\slash") == "back_slash"
assert sanitize_path_component(" padded ") == "padded"
# Colons and other punctuation are legal in a path and are left alone.
assert sanitize_path_component("Title: Subtitle") == "Title: Subtitle"
@@ -0,0 +1,415 @@
"""Tests for importing a Calibre library through BookService."""
from pathlib import Path
import pytest
from chitai.config import settings
from chitai.database import models as m
from chitai.services import BookService
from chitai.services.calibre import CalibreLibrary
from tests.calibre_fixtures import CalibreFixture
DATA_FILES = Path("tests/data_files")
EPUB = DATA_FILES / "Metamorphosis - Franz Kafka.epub"
OTHER_EPUB = DATA_FILES / "The Art of War - Sun Tzu.epub"
PDF = DATA_FILES / "Calculus Made Easy - Silvanus Thompson.pdf"
@pytest.fixture(name="calibre_root")
def fx_calibre_root(tmp_path: Path) -> Path:
"""Three books: one plain, one in two formats, one Chitai cannot use."""
fixture = CalibreFixture(tmp_path / "source")
fixture.add_pages_table()
fixture.add_book(
1,
"The Metamorphosis",
authors=["Franz Kafka"],
pubdate="1915-10-15 00:00:00+00:00",
tags=["Fiction", "Absurdist"],
publisher="Kurt Wolff Verlag",
languages=["deu"],
comment="<p>He wakes up <i>changed</i>.</p>",
identifiers={"isbn": "978-0-486-29030-0", "amazon": "B01N5IB20Q"},
uuid="11111111-2222-3333-4444-555555555555",
pages=201,
cover=True,
formats={"EPUB": EPUB},
)
fixture.add_book(
2,
"The Art of War",
authors=["Sun Tzu"],
series="Classics",
series_index=3.0,
formats={"EPUB": OTHER_EPUB, "PDF": PDF},
)
fixture.add_book(3, "Metadata Only", authors=["Nobody"])
return fixture.commit()
async def library_of(root: Path) -> CalibreLibrary:
source = CalibreLibrary(root)
await source.open()
return source
async def test_imports_a_catalogue(
books_service: BookService, test_library: m.Library, calibre_root: Path
) -> None:
source = await library_of(calibre_root)
try:
result = await books_service.create_many_from_calibre(source, test_library)
finally:
await source.close()
assert result.total == 3
assert len(result.created) == 2
# The book with no files is left out: a record with nothing to read, and a directory
# to match, is worse than not importing it.
assert [skipped.calibre_id for skipped in result.skipped] == [3]
assert result.skipped[0].reason == "no files in the catalogue"
assert result.failed == []
book = await books_service.get(result.created[0])
assert book.title == "The Metamorphosis"
assert [author.name for author in book.authors] == ["Franz Kafka"]
assert sorted(tag.name for tag in book.tags) == ["Absurdist", "Fiction"]
assert book.publisher is not None and book.publisher.name == "Kurt Wolff Verlag"
assert book.published_date is not None and book.published_date.year == 1915
assert book.language == "deu"
assert book.pages == 201
assert book.cover_image is not None
# The HTML is gone; `Book.description` is rendered as text.
assert book.description == "He wakes up changed."
async def test_two_formats_are_one_book(
books_service: BookService, test_library: m.Library, calibre_root: Path
) -> None:
source = await library_of(calibre_root)
try:
result = await books_service.create_many_from_calibre(source, test_library)
finally:
await source.close()
book = await books_service.get(result.created[1])
assert book.title == "The Art of War"
assert sorted(Path(file.path).suffix for file in book.files) == [".epub", ".pdf"]
# A REAL series index reaches the column as the string everything else writes.
assert book.series is not None and book.series.title == "Classics"
assert book.series_position == "3"
async def test_identifiers_are_folded_onto_chitai_schemes(
books_service: BookService, test_library: m.Library, calibre_root: Path
) -> None:
"""
`amazon` becomes `asin`, a hyphenated ISBN survives, and the Calibre uuid is kept.
The uuid is deliberately not stored under `uuid`, which duplicate matching ignores
because an EPUB regenerates one per build. Calibre's is stable, so it is the durable
link back to the row it came from.
"""
source = await library_of(calibre_root)
try:
result = await books_service.create_many_from_calibre(source, test_library)
finally:
await source.close()
book = await books_service.get(result.created[0])
identifiers = {identifier.name: identifier.value for identifier in book.identifiers}
assert identifiers["asin"] == "B01N5IB20Q"
assert identifiers["isbn-13"] == "9780486290300"
assert identifiers["calibre-uuid"] == "11111111-2222-3333-4444-555555555555"
matching = {
identifier.name: identifier.normalized_value for identifier in book.identifiers
}
# Stored under its own name, matched under one scheme for both ISBN forms.
assert matching["isbn-13"] == "isbn:9780486290300"
# And the uuid carries a real matching key, which is the whole reason it is not
# filed under `uuid`.
assert matching["calibre-uuid"] is not None
async def test_the_source_library_is_left_alone(
books_service: BookService, test_library: m.Library, calibre_root: Path
) -> None:
"""Files are copied. Moving them would leave `metadata.db` pointing at nothing."""
before = {
path: path.stat().st_mtime_ns
for path in sorted(calibre_root.rglob("*"))
if path.is_file()
}
source = await library_of(calibre_root)
try:
result = await books_service.create_many_from_calibre(source, test_library)
finally:
await source.close()
after = {
path: path.stat().st_mtime_ns
for path in sorted(calibre_root.rglob("*"))
if path.is_file()
}
assert after == before
# And the copies are really there, under the library's own layout.
for book_id in result.created:
book = await books_service.get(book_id)
for file in book.files:
assert (Path(book.path or "") / file.path).is_file()
async def test_importing_twice_creates_nothing(
books_service: BookService, test_library: m.Library, calibre_root: Path
) -> None:
"""
Re-running is safe with no bookkeeping: the bytes are recognised wherever they sit.
This is what makes an interrupted import resumable by simply running it again.
"""
for _ in range(2):
source = await library_of(calibre_root)
try:
result = await books_service.create_many_from_calibre(source, test_library)
finally:
await source.close()
assert result.created == []
assert sorted(skipped.reason for skipped in result.skipped) == [
"already stored",
"already stored",
"no files in the catalogue",
]
held_by = [
skipped.book_id for skipped in result.skipped if skipped.reason == "already stored"
]
assert all(book_id is not None for book_id in held_by)
async def test_a_file_the_catalogue_lists_but_disk_does_not(
books_service: BookService, test_library: m.Library, tmp_path: Path
) -> None:
"""Calibre keeps the row when a file is moved away behind its back."""
fixture = CalibreFixture(tmp_path / "source")
fixture.add_book(1, "Present", authors=["A"], formats={"EPUB": EPUB})
fixture.add_book(2, "Absent", authors=["B"])
fixture.add_missing_format(2, "EPUB", "Absent - B")
root = fixture.commit()
source = await library_of(root)
try:
result = await books_service.create_many_from_calibre(source, test_library)
finally:
await source.close()
assert len(result.created) == 1
assert [(s.calibre_id, s.reason) for s in result.skipped] == [(2, "no files on disk")]
async def test_one_broken_book_does_not_stop_the_import(
books_service: BookService, test_library: m.Library, tmp_path: Path
) -> None:
"""
A failure is recorded and the run continues, leaving no files behind for it.
An orphaned directory would make the next attempt reserve `title (2)` and look as
though it had worked.
"""
fixture = CalibreFixture(tmp_path / "source")
fixture.add_book(1, "First", authors=["A"], formats={"EPUB": EPUB})
fixture.add_book(2, "Doomed", authors=["B"], formats={"EPUB": OTHER_EPUB})
fixture.add_book(3, "Third", authors=["C"], formats={"PDF": PDF})
root = fixture.commit()
original = books_service.create
async def fail_on_the_second(data, *args, **kwargs):
if isinstance(data, dict) and data.get("title") == "Doomed":
raise RuntimeError("no room on the shelf")
return await original(data, *args, **kwargs)
books_service.create = fail_on_the_second # type: ignore[method-assign]
source = await library_of(root)
try:
result = await books_service.create_many_from_calibre(source, test_library)
finally:
await source.close()
books_service.create = original # type: ignore[method-assign]
assert len(result.created) == 2
assert len(result.failed) == 1
assert result.failed[0].calibre_id == 2
assert "no room on the shelf" in result.failed[0].reason
# Nothing of the failed book was left in the library. Checked against the path the
# template would have produced, rather than by walking the root — the Calibre source
# sits under it in these tests, and its own files are meant to still be there.
assert not (Path(test_library.root_path) / "B").exists()
# And the books either side of it are where they should be.
for book_id in result.created:
book = await books_service.get(book_id)
for file in book.files:
assert (Path(book.path or "") / file.path).is_file()
async def test_a_cover_that_cannot_be_read_is_not_fatal(
books_service: BookService, test_library: m.Library, tmp_path: Path
) -> None:
"""
A truncated `cover.jpg` costs the cover, not the book.
Real libraries hold them, from an interrupted download or a failed conversion, and
the cover is the one thing in the directory that can be replaced from the book page.
"""
fixture = CalibreFixture(tmp_path / "source")
fixture.add_book(
1, "Unreadable Cover", authors=["A"], corrupt_cover=True, formats={"EPUB": EPUB}
)
root = fixture.commit()
source = await library_of(root)
try:
result = await books_service.create_many_from_calibre(source, test_library)
finally:
await source.close()
assert result.failed == []
assert len(result.created) == 1
book = await books_service.get(result.created[0])
assert book.cover_image is None
assert len(book.files) == 1
async def test_shared_authors_and_tags_are_one_row_each(
books_service: BookService, test_library: m.Library, tmp_path: Path, session
) -> None:
"""Two books by one author must not produce two `Author` rows."""
fixture = CalibreFixture(tmp_path / "source")
fixture.add_book(
1, "One", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": EPUB}
)
fixture.add_book(
2, "Two", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": OTHER_EPUB}
)
root = fixture.commit()
source = await library_of(root)
try:
result = await books_service.create_many_from_calibre(source, test_library)
finally:
await source.close()
assert len(result.created) == 2
first, second = [await books_service.get(book_id) for book_id in result.created]
assert first.authors[0].id == second.authors[0].id
assert first.tags[0].id == second.tags[0].id
async def test_a_second_copy_is_reported_not_refused(
books_service: BookService, test_library: m.Library, tmp_path: Path
) -> None:
"""
Two catalogue rows for one book, with different bytes, both import.
File-level dedupe cannot see it — the archives differ — so book-level detection
reports the pair and leaves the decision to the reader.
"""
padded = tmp_path / "padded.epub"
padded.write_bytes(EPUB.read_bytes() + b"\0" * 64)
fixture = CalibreFixture(tmp_path / "source")
fixture.add_book(
1, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": EPUB}
)
fixture.add_book(
2, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": padded}
)
root = fixture.commit()
source = await library_of(root)
try:
result = await books_service.create_many_from_calibre(source, test_library)
finally:
await source.close()
assert len(result.created) == 2
assert len(result.possible_duplicates) == 1
assert result.possible_duplicates[0].candidates[0].book_id == result.created[0]
async def test_allow_duplicates_stores_the_same_bytes_again(
books_service: BookService, test_library: m.Library, calibre_root: Path
) -> None:
for allow in (False, True):
source = await library_of(calibre_root)
try:
result = await books_service.create_many_from_calibre(
source, test_library, allow_duplicates=allow
)
finally:
await source.close()
assert len(result.created) == 2
async def test_duplicate_scope_off_imports_everything(
books_service: BookService,
test_library: m.Library,
calibre_root: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "duplicate_scope", "off")
for _ in range(2):
source = await library_of(calibre_root)
try:
result = await books_service.create_many_from_calibre(source, test_library)
finally:
await source.close()
assert len(result.created) == 2
async def test_progress_is_reported_per_book(
books_service: BookService, test_library: m.Library, calibre_root: Path
) -> None:
"""The import is long enough that its progress is the only thing worth watching."""
seen = []
source = await library_of(calibre_root)
try:
await books_service.create_many_from_calibre(
source, test_library, on_progress=seen.append
)
finally:
await source.close()
assert [progress.processed for progress in seen] == [1, 2, 3]
assert all(progress.total == 3 for progress in seen)
assert [progress.outcome for progress in seen] == ["created", "created", "skipped"]
assert seen[0].title == "The Metamorphosis"