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.
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:
- Calibre libraries hold formats Chitai cannot describe, let alone read — and one of them currently 500s the book detail endpoint (see prerequisites).
- A 5,000-book import is a long-running job, and the app has no job/progress concept.
- 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 metafails withno such function: sortconcat. Query base tables only. -
pubdatehas a sentinel, not a null. An unknown publication date is stored as0101-01-01 00:00:00+00:00(Calibre'sUNDEFINED_DATE, year 101). It parses fine as adate, so nothing will complain — two of the six books in the reference library carry it. Drop anypubdatewith year < 1000. The same sentinel appears intimestamp. -
data.nameis 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 titledThe Project Gutenberg eBook #33283: Calculus Made Easy, 2nd Editionis stored asThe Project Gutenberg eBook #33283_ Calcul - Silvanus Phillips Thompson.pdf. So the file extractors must not be consulted for metadata (see decision 2), andbooks.path/data.nameare for locating files only. -
books.titlemay contain characters Calibre strips from its own paths —:became_above, and titles legitimately contain/(AC/DC).BookPathGeneratorinterpolates 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 oftitlebefore path generation. -
authors.nameescapes commas as|. Calibre'sAuthorsTableunserialises withname.replace('|', ',')(from Calibre'sdb/tables.py; the reference library has no such name, so this one is unverified locally). Do the same replacement, and passauthors.name— notauthors.sort, which isMelville, Herman.format_author_namewould flip the sort form correctly anyway, but there is no reason to hand it the worse input. -
series_indexis a REAL.7.0must become"7", not"7.0"—Book.series_positionis a string, andfind_duplicate_books's series-position disqualifier compares it as one. -
languages.lang_codeis ISO 639-2/B (eng), whileEpubExtractorstores rawDC:language(en). Both will coexist in the column.Book.languageis 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.textis HTML.Book.descriptionis rendered as plain text byCollapsibleText, so<p>tags will show literally. Strip to text on import. -
books_pages_linkis usually empty of real data. It carriesneeds_scanand, in the reference library,pages = 0for all six books. Only use it whenpages > 0. -
identifiers.typeis free text. The reference library holdsisbn,amazonandmobi-asin, all of whichparse_identifieralready maps. Feed every identifier through it and keep whatever survives; do not filter to a known list.
Decisions to settle before writing code
-
Copy files into the library. Do not move, do not reference in place — for the first version. Moving leaves
metadata.dbpointing 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 pointsbook.pathat the Calibre tree, andupdate_bookmoves directories whiledelete_booksdeletes files — so a metadata edit in Chitai would rearrange somebody's Calibre library.Library.read_onlyexists but is enforced in exactly one place (services/library.py:54, at creation). See phase 3. -
Trust Calibre's metadata; do not run the extractors. Calibre's catalogue is curated, its filenames are truncated garbage, and running
Extractor.extract_metadataover thousands of files means opening every EPUB and rendering a cover page from every PDF. Take the cover fromcover.jpgdirectly. The one exception worth allowing: fillpagesfrom the file when Calibre has no useful value, behind a flag, off by default. -
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.mdrecords there is no authorization tier at all. Not adding it is one less thing to gate later. - Over HTTP: an uploaded zip only.
-
Import into an existing Chitai library, chosen by the caller. Creating a library is already one action, and the Calibre tree is rewritten by
BookPathGeneratorregardless. -
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_typeinservices/utils.pynow names every format from its extension, the column keeps a null when nothing can name one, and the OPDS feed substitutesapplication/octet-streamat the one place a string is required. The Read control is driven byisReadablerather than by the file count. See the section below for why it mattered, andbackend/AGENTS.mdfor 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
mimetypesdoes 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.dbto 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-walsidecar. The database is small (438 KB for six books, single-digit MB for thousands), so a copy costs nothing and removes the whole problem. sqlite3insideasyncio.to_thread, not a new dependency. The connection is used for a handful of queries. Do not addaiosqlitefor 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
BookServiceascreate_many_from_calibre, not into a separateservices/calibre_import.py. The orchestration needs_reserve_book_path,_save_cover_image,_screen_for_duplicatesand_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_duplicateswas changed to take the list it appends to rather than anImportResult, 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:
fingerprint_fileeach source file (services/utils.py:164).find_duplicate_filesagainst the target library. All files known → skip the book entirely, recording it. Some known → import the rest.- Build the metadata dict from the
CalibreBook. _reserve_book_path(path_gen.generate_path(data)).- Copy each file to
parent / _unused_path(...), buildingFileMetadatafrom the fingerprint already computed.services/utils.pyhasmove_filebut no copy — addcopy_filebeside it, streaming throughaiofilesinCHUNK_SIZEblocks like_save_book_filesdoes, notshutil.copy(a 40 MB blocking read inside the event loop). - Cover: open
cover.jpgwith PIL and hand theImageto_save_cover_image, which already accepts one and converts to WebP. super().create(data)throughBookService, thenfind_duplicate_booksand record candidates — same as_record_possible_duplicates(services/book.py:1253).- 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
85367dadeliberately 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/uploadtakes 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 nometadata.dbwithin 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 streamrequest.bodythrough withduplex: '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— besidesettings/libraries/[libraryId]/duplicates, reached from the library settings page. getCalibreImport(aquery) andcancelCalibreImport(acommand) insrc/lib/api/calibre-import.remote.ts, re-exported fromsrc/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 usesXMLHttpRequestfor it, which is the only way to get upload progress.- A file input, an upload progress bar, then a progress bar polling
getCalibreImportevery 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_index7.0 →"7";|unescaped in an author name; HTML stripped fromcomments;pages = 0ignored; identifiers passed throughparse_identifier; adatarow whose file is missing from disk reported rather than raised;.caltrashnever 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 oneAuthor/Tagrow each;possible_duplicatesreported when the target library already holds the same book. - Integration (
tests/integration/test_calibre_import.py) —POSTreturns 202 with a job id, polling reaches a terminal state, and the books are then listable throughGET /books. A.mobi-only book must come back fromGET /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_positions → BookProgress 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.