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
@@ -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"