Files
chitai/docs/calibre-import.md
patrick 55e00ba960 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.
2026-08-17 13:38:44 -04:00

25 KiB

Implementation brief: importing a Calibre library

Written for an agent picking this up cold. Read the repo-root AGENTS.md and backend/AGENTS.md first — this brief assumes both, particularly the Filesystem behaviour and Duplicate detection sections.

Every claim about Calibre's schema and on-disk layout below was checked against a real library at ~/Documents/Calibre Library (6 books, current Calibre). Where a fact came from Calibre's source rather than that library, it says so.

Feasibility: high, and most of the machinery already exists

metadata.db is plain SQLite with a schema that has been stable for a decade, and the files sit beside it in a predictable tree. Chitai already has every piece an import needs:

Needed Already in the tree
Ingest files that are already on disk BookService.create_many_from_existing_files (services/book.py:1280)
Deduplicate authors/tags/publishers/series _populate_with_unique_relationships (services/book.py:1752), via as_unique_async
Generic identifiers with a scheme map Identifier, and parse_identifier (services/metadata_extractor.py:58) — whose map already covers isbn, amazon, mobi-asin, google, goodreads, doi, calibre
Decide where a book lives on disk BookPathGenerator, _reserve_book_path (services/book.py:1066)
Not import the same book twice find_duplicate_files (:461) and find_duplicate_books (:535)
Store a cover _save_cover_image (:1994)

So this is a reader, not new ingest machinery: turn Calibre rows into the metadata dict BookService already accepts, and hand it to a slightly generalised version of the consume-directory path. The parsing is the easy half.

The hard parts are elsewhere, and all three are addressed below:

  1. Calibre libraries hold formats Chitai cannot describe, let alone read — and one of them currently 500s the book detail endpoint (see prerequisites).
  2. A 5,000-book import is a long-running job, and the app has no job/progress concept.
  3. Whether files are copied into the library or referenced in place — which is a product decision with a large blast radius, because in-place means Chitai's write paths point at somebody's Calibre library.

What a Calibre library actually is

Calibre Library/
├── metadata.db                       the whole catalogue
├── metadata_db_prefs_backup.json     ignore
├── .caltrash/  .calnotes/            ignore — deleted books still live in .caltrash
└── <Author Name>/
    └── <Title> (<book id>)/          == books.path
        ├── cover.jpg                 iff books.has_cover
        ├── metadata.opf              ignore; the db is authoritative
        └── <data.name>.<format>      one per row in `data`

The tables that matter, and nothing else: books, authors + books_authors_link, publishers + books_publishers_link, tags + books_tags_link, series + books_series_link, languages + books_languages_link, comments, identifiers, data, books_pages_link, last_read_positions.

Ten things that will produce wrong data if you do not know them:

  • Never query the views. meta, tag_browser_* and friends call SQLite functions Calibre registers from Python at connection time. Verified: SELECT * FROM meta fails with no such function: sortconcat. Query base tables only.

  • pubdate has a sentinel, not a null. An unknown publication date is stored as 0101-01-01 00:00:00+00:00 (Calibre's UNDEFINED_DATE, year 101). It parses fine as a date, so nothing will complain — two of the six books in the reference library carry it. Drop any pubdate with year < 1000. The same sentinel appears in timestamp.

  • data.name is lossy and is not the title. It is the on-disk stem, truncated to Calibre's filename limit and sanitised. Verified in the reference library: the book titled The Project Gutenberg eBook #33283: Calculus Made Easy, 2nd Edition is stored as The Project Gutenberg eBook #33283_ Calcul - Silvanus Phillips Thompson.pdf. So the file extractors must not be consulted for metadata (see decision 2), and books.path/data.name are for locating files only.

  • books.title may contain characters Calibre strips from its own paths: became _ above, and titles legitimately contain / (AC/DC). BookPathGenerator interpolates the title straight into a path and only collapses repeated slashes (services/filesystem_library.py), so an unsanitised Calibre title can silently add a directory level. Sanitise / and control characters out of title before path generation.

  • authors.name escapes commas as |. Calibre's AuthorsTable unserialises with name.replace('|', ',') (from Calibre's db/tables.py; the reference library has no such name, so this one is unverified locally). Do the same replacement, and pass authors.namenot authors.sort, which is Melville, Herman. format_author_name would flip the sort form correctly anyway, but there is no reason to hand it the worse input.

  • series_index is a REAL. 7.0 must become "7", not "7.0"Book.series_position is a string, and find_duplicate_books's series-position disqualifier compares it as one.

  • languages.lang_code is ISO 639-2/B (eng), while EpubExtractor stores raw DC:language (en). Both will coexist in the column. Book.language is free text and the edit form is a plain <input>, so nothing breaks; normalising to two letters is optional polish, not part of this work.

  • comments.text is HTML. Book.description is rendered as plain text by CollapsibleText, so <p> tags will show literally. Strip to text on import.

  • books_pages_link is usually empty of real data. It carries needs_scan and, in the reference library, pages = 0 for all six books. Only use it when pages > 0.

  • identifiers.type is free text. The reference library holds isbn, amazon and mobi-asin, all of which parse_identifier already maps. Feed every identifier through it and keep whatever survives; do not filter to a known list.

Decisions to settle before writing code

  1. Copy files into the library. Do not move, do not reference in place — for the first version. Moving leaves metadata.db pointing at files that are gone, which quietly destroys a library the user still uses. Referencing in place is genuinely desirable (nobody wants two copies of 80 GB) but it points book.path at the Calibre tree, and update_book moves directories while delete_books deletes files — so a metadata edit in Chitai would rearrange somebody's Calibre library. Library.read_only exists but is enforced in exactly one place (services/library.py:54, at creation). See phase 3.

  2. Trust Calibre's metadata; do not run the extractors. Calibre's catalogue is curated, its filenames are truncated garbage, and running Extractor.extract_metadata over thousands of files means opening every EPUB and rendering a cover page from every PDF. Take the cover from cover.jpg directly. The one exception worth allowing: fill pages from the file when Calibre has no useful value, behind a flag, off by default.

  3. The source is a server-side path, not an upload. Reversed in review, and the reason this brief was wrong is worth keeping. The premise — "the library lives on the same host as the backend in every realistic deployment" — is false for the common case: Calibre is a desktop application, and its library is on the desktop. So the split is by surface, not by preference:

    • Over HTTP: an uploaded zip only. …/imports/calibre/upload. There is no endpoint taking a server path; one was built and then removed deliberately.
    • On the server: a path only. litestar calibre-import <path>, which is where a very large library or a headless migration belongs — an upload has to carry the whole archive across first.

    A CLI taking a path needs no justification. An endpoint taking one would have: it would let any authenticated caller read any directory the backend can, and TODO.md records there is no authorization tier at all. Not adding it is one less thing to gate later.

  4. Import into an existing Chitai library, chosen by the caller. Creating a library is already one action, and the Calibre tree is rewritten by BookPathGenerator regardless.

  5. Re-running an import must be safe, and file-level dedupe already makes it so. The same bytes are recognised by (hash, size) whatever their path, so a second run over the same library skips everything. No import bookkeeping is needed for idempotency.

Prerequisite: a .mobi file breaks the book endpoint — done

Landed ahead of the import itself. guess_content_type in services/utils.py now names every format from its extension, the column keeps a null when nothing can name one, and the OPDS feed substitutes application/octet-stream at the one place a string is required. The Read control is driven by isReadable rather than by the file count. See the section below for why it mattered, and backend/AGENTS.md for the rule as it now stands.

FileMetadataRead.content_type is a required str (schemas/book.py:33), but every ingest path fills it from mimetypes.guess_type, which returns None for .mobi, .azw, .fb2, .lit and .htmlz (verified). FileMetadata.content_type is nullable in the model, so the row stores fine and then fails response validation on the way out — a book whose only file is a MOBI would be unreadable through the API.

Nothing in the tree hits this today because the browser upload path is used with EPUBs and PDFs. A Calibre library is full of MOBI and AZW3. Fix it first, either way round:

  • make the schema field str | None, and/or
  • add a small extension→MIME table for the ebook formats mimetypes does not know (application/x-mobipocket-ebook, application/vnd.amazon.ebook, application/x-fictionbook+xml).

Do both, in fact: the table is the right answer for OPDS clients, which choose an acquisition link by MIME type, and the nullable field is the safety net.

Related, but not a blocker: Chitai reads EPUB and PDF only. openBookInReader (book/[bookId]/+page.svelte:66) branches on getFileType(...) === 'EPUB' | 'PDF' and does nothing for anything else, so an AZW3-only book gets a Read button that silently fails. Importing those files is still right — they are downloadable and they are the user's — but the button should be disabled for a book with no readable file. One $derived on the page, worth doing in the same branch.

Design

1. services/calibre.py — a pure reader, no Chitai types

@dataclass(frozen=True)
class CalibreFile:
    path: Path          # absolute, resolved against the library root
    format: str         # "EPUB", as stored
    size: int           # data.uncompressed_size, for a cheap sanity check

@dataclass(frozen=True)
class CalibreBook:
    calibre_id: int
    uuid: str
    title: str
    authors: list[str]
    ...                 # one field per row of the mapping table below
    cover: Path | None
    files: list[CalibreFile]

class CalibreLibrary:
    def __init__(self, root: Path) -> None: ...
    async def open(self) -> None: ...          # copy + connect, see below
    async def books(self) -> AsyncIterator[CalibreBook]: ...
    async def close(self) -> None: ...

Deliberately knows nothing about Book, BookService or the session — it is a file-format reader, unit-testable against a fixture database with no Postgres and no app.

Two implementation notes:

  • Copy metadata.db to a temp file and read the copy. Calibre may be running and writing; opening the live file read-only either sees a torn state or needs the -wal sidecar. The database is small (438 KB for six books, single-digit MB for thousands), so a copy costs nothing and removes the whole problem.
  • sqlite3 inside asyncio.to_thread, not a new dependency. The connection is used for a handful of queries. Do not add aiosqlite for this.

Read the whole catalogue in one query per table and join in Python — six or so SELECTs and a few dicts, versus a per-book N+1 across ten tables. At self-hosted scale the entire catalogue minus descriptions fits in memory comfortably; if comments.text for 20k books is a concern, fetch that one table per batch.

2. The mapping

Calibre Chitai Notes
books.title title Sanitise / and control chars for path generation. Extractor.format_book_title may still be worth applying to split a subtitle at the second colon — but do not run split_edition, Calibre's title is the curated one.
authors.name via books_authors_link authors |,. Order by books_authors_link.id; Book.author_links is an ordering_list, so insertion order is the displayed order.
comments.text description Strip HTML to text.
books.pubdate published_date Drop the year-101 sentinel.
series.name, books.series_index series, series_position 7.0"7".
tags.name tags
publishers.name publisher books_publishers_link is unique per book.
languages.lang_code (lowest item_order) language Chitai holds one.
identifiers.type / .val identifiers Through parse_identifier; keep what survives.
books.uuid identifiers["calibre-uuid"] The one durable link back to the source row. normalize_identifier returns a key for it (it is not in _PER_BUILD_NAMES), which is desirable: a book re-imported from the same Calibre library matches on it exactly.
books_pages_link.pages pages Only when > 0.
cover.jpg when has_cover cover_image
data rows files
books.timestamp Book.created_at is audit-managed; do not fight it.
ratings, annotations, custom_columns No home in the model. Out of scope.
last_read_positions BookProgress Phase 2.

3. The ingest

As built, this went on BookService as create_many_from_calibre, not into a separate services/calibre_import.py. The orchestration needs _reserve_book_path, _save_cover_image, _screen_for_duplicates and _record_possible_duplicates, and reaching into four privates from another module is worse than one more method in the file where the other two ingest paths already live. _record_possible_duplicates was changed to take the list it appends to rather than an ImportResult, so every ingest path can share it whatever its own result type is. Two other deviations: CalibreLibrary.books() returns a list rather than an async iterator, because the caller needs the total up front anyway; and the reader reports identifiers exactly as Calibre keyed them, with the fold onto Chitai's schemes done by the importer, which keeps the reader free of Chitai imports.

Per book, in this order — it mirrors create_many_from_existing_files, which is the closest existing shape:

  1. fingerprint_file each source file (services/utils.py:164).
  2. find_duplicate_files against the target library. All files known → skip the book entirely, recording it. Some known → import the rest.
  3. Build the metadata dict from the CalibreBook.
  4. _reserve_book_path(path_gen.generate_path(data)).
  5. Copy each file to parent / _unused_path(...), building FileMetadata from the fingerprint already computed. services/utils.py has move_file but no copy — add copy_file beside it, streaming through aiofiles in CHUNK_SIZE blocks like _save_book_files does, not shutil.copy (a 40 MB blocking read inside the event loop).
  6. Cover: open cover.jpg with PIL and hand the Image to _save_cover_image, which already accepts one and converts to WebP.
  7. super().create(data) through BookService, then find_duplicate_books and record candidates — same as _record_possible_duplicates (services/book.py:1253).
  8. Commit per book. A 5,000-book import inside one transaction is one failure away from nothing, and per-book commits are what lets the library page show books arriving — which is the behaviour commit 85367da deliberately built.

Report an ImportResult-shaped outcome; reuse ImportResult itself if it fits, extending it with a failures: list[tuple[int, str]] keyed by Calibre id. One book must never fail the run — a missing file, an unreadable cover or a NOT NULL violation gets recorded and skipped.

4. Progress, and where the import runs

As built, the HTTP surface is an uploaded archive and nothing else — see decision 3.

POST …/imports/calibre/upload takes a zipped library. The job owns the temp directory it is unpacked into and deletes it when it ends. Extraction refuses zip slip, an archive too big for the disk, and one with no metadata.db within three levels — all answered 400 before a job exists.

This forced a fix to the SvelteKit proxy, which buffered request bodies with arrayBuffer(): survivable for one book, not for a multi-gigabyte archive. POST and PATCH now stream request.body through with duplex: 'half'.

A preview endpoint was built and then removed with the path route. It read a server-side catalogue and reported its size before writing anything, which is only useful when the caller named a directory. An upload has already been carried across by the time anything can be read, so unpacking it is the validation step — an archive that is not a Calibre library is refused there.

The import outlives its request, so the handler starts it and returns a handle:

  • POST /libraries/{library_id:int}/imports/calibre — body {path, copy_files: true}, returns {job_id, total}. 202.
  • GET /libraries/imports/{job_id}{state, total, processed, created, skipped, failed, current_title, errors}.
  • DELETE /libraries/imports/{job_id} — cancel; the task checks a flag between books.

Keep the registry in memory, a dict[str, ImportJob] on a module-level singleton, with the task created by asyncio.create_task. This matches what the app already does — the consume watcher is an in-process singleton started from a lifespan hook — and it is roughly thirty lines against a model, a migration and a service for the alternative.

State that limitation explicitly in the docstring: it assumes one worker process. The production CMD is litestar run, which is single-process, so this holds today; TODO.md already records that the production image should move to uvicorn with a worker count, and doing that would mean a poll landing on a worker that has never heard of the job. The consume watcher has the same problem, so this is not a new constraint — but the next person to add workers needs to find it written down. If import history is ever wanted, that is when an import_jobs table earns its migration.

Also add a CLI entry point. A 200 GB library imported through a browser tab that must stay open is a bad experience, and pyproject.toml already declares a chitai script. A Litestar CLI command (litestar --app-dir src/chitai/ calibre-import <path> --library <slug>) is ~20 lines over the same service and is the right tool for the initial migration, which is the case this whole feature exists for. The endpoint is for people who would rather click.

5. Frontend

Model it on the duplicates screen, which is the closest precedent in shape and placement:

  • Route (root)/settings/libraries/[libraryId]/import — beside settings/libraries/[libraryId]/duplicates, reached from the library settings page.
  • getCalibreImport (a query) and cancelCalibreImport (a command) in src/lib/api/calibre-import.remote.ts, re-exported from src/lib/api/index.ts. Starting an import is not a remote function: the archive goes to the backend through the proxy so the browser streams straight through, where a remote function would put the whole thing through the SvelteKit process first. The screen uses XMLHttpRequest for it, which is the only way to get upload progress.
  • A file input, an upload progress bar, then a progress bar polling getCalibreImport every second or two, a running count, and the failures listed at the end with their Calibre ids.
  • Finish with a link to the library's duplicates screen. An import into a non-empty library is the single most likely way to produce duplicate books, and that screen already handles them.

Do not route this through the upload tray. The tray reports on a client-driven queue it owns (upload-queue.svelte.ts); this is server-side work whose state survives a page reload, and conflating the two would mean teaching the tray to poll.

Regenerate src/lib/schema/openapi/schema.d.ts against a backend running your branch — a stale server silently writes a stale file.

Testing

The fixture is the interesting part. Build a Calibre library in a tmp_path fixture rather than committing a binary metadata.db: a helper that executes the subset of Calibre's CREATE TABLE statements (they are in this document's shape, and in any real library's sqlite_master), inserts a handful of books, and lays out <Author>/<Title> (id)/ directories containing the existing EPUB and PDF fixtures from backend/tests/data_files/ plus a copy of cover.jpg. Generated beats committed here because the tests need to assert on odd rows — the pubdate sentinel, a | in an author name, a title with a colon — and those are clearer written in Python than hidden in a blob.

  • Unit (tests/unit/test_calibre.py) — the reader alone: field mapping; the year-101 pubdate dropped; series_index 7.0 → "7"; | unescaped in an author name; HTML stripped from comments; pages = 0 ignored; identifiers passed through parse_identifier; a data row whose file is missing from disk reported rather than raised; .caltrash never walked.
  • Service (tests/unit/test_services/test_calibre_import.py) — a book with two formats lands as one record with two files; the source files still exist afterwards; a second run over the same library creates nothing; a library with one broken book imports the rest; authors and tags shared between two books produce one Author / Tag row each; possible_duplicates reported when the target library already holds the same book.
  • Integration (tests/integration/test_calibre_import.py) — POST returns 202 with a job id, polling reaches a terminal state, and the books are then listable through GET /books. A .mobi-only book must come back from GET /books/{id} without a 500 — that is the prerequisite's regression test.

pytest needs Docker (pytest-databases).

Verification

nix-shell                                                    # postgres + migrations applied
cd backend
pytest tests/                                                # take your own baseline first
uv run litestar --app-dir src/chitai/ run --port 8001        # for the OpenAPI regeneration
cd ../frontend && pnpm check                                 # baseline: 30 errors, 1 warning, 8 files

Neither pnpm check nor pnpm lint is clean on this repo — baseline before assuming an error is yours.

End to end, against a real library (~/Documents/Calibre Library will do): import into an empty Chitai library and confirm the six books arrive with their covers, authors, tags, series positions and identifiers intact; that the AZW3 book is listed and downloadable; that the source library is byte-for-byte untouched (diff -r a copy taken beforehand); and that re-running the import creates nothing and reports six skipped books. Then import the same library into a library that already holds one of those books by upload, and confirm it lands on the duplicates screen rather than as a second copy.

Phasing

Phase Scope
0 The content_type prerequisite, plus the Read button. Done — see below.
1 services/calibre.py, the ingest, the CLI command, copy-only. Done — a headless one-time migration works today.
2 The upload endpoint, the job registry and the settings screen — done. The HTTP surface is an uploaded zip only; see decision 3, which this reversed. last_read_positionsBookProgress is not done: it needs a Calibre-user → Chitai-user mapping, and the answer differs between the endpoint (which has a current_user) and the CLI (which has none). That decision is the next thing to make.
3 Reference-in-place import. Its real content is enforcing Library.read_only across update_book, delete_books, add_files and remove_files — which is a feature of its own and should not be smuggled in under an import.

Out of scope

Annotations and highlights (no model to put them in), custom columns, ratings, virtual libraries and saved searches → bookshelves, format conversion, writing anything back to Calibre, and any form of continuing two-way sync. This is a one-way migration.