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
+95
View File
@@ -108,6 +108,22 @@ The backend owns files on disk, not just rows:
if it differs, moves the directory contents and prunes empty parents. Keep that in mind before
changing metadata handling.
### Content types come from the extension, and null means null
Every ingest path names a file's format with `guess_content_type` (`services/utils.py`), never with
`mimetypes.guess_type` directly and never with what the client said. Python's built-in map answers
`None` for `.mobi`, `.azw`, `.prc`, `.fb2`, `.fbz`, `.lit`, `.lrf` and `.cb7` — most of what a
library imported from elsewhere carries — so `EBOOK_CONTENT_TYPES` fills those in. A browser's
`application/octet-stream` is discarded rather than used as a fallback: it is the client saying it
does not know, and storing it is indistinguishable from having determined a format.
When nothing can name the extension the column **stays null**. That is the honest answer, and only
one consumer cannot take it: OPDS `Link.type` is a required string, so
`services/opds/opds.py` substitutes `application/octet-stream` at that boundary. Litestar's
`ASGIFileResponse` already does its own fallback, so `get_file` can pass a null straight through.
`FileMetadataRead.content_type` is nullable for the same reason — it was once required, which
turned a stored null into a 500 on a book that was otherwise fine.
## Duplicate detection
Every ingest path screens incoming files against what is already stored, keyed on
@@ -126,6 +142,7 @@ The policy differs by how deliberate the import is:
| `create_book` (single, with metadata) | All-or-nothing: raises `DuplicateFilesError`, which `controllers/book.py` renders as a **409** carrying the refused files in `extra`. |
| `add_files` | A file the book already carries is a no-op; one stored under another book raises `DuplicateFilesError`. |
| `create_many_from_existing_files` (consume watcher) | Skips, and **moves the file to `CHITAI_DUPLICATE_PATH/<library slug>/`** — nothing is deleted, and it cannot stay put because `watchfiles` only reports additions. That path must stay outside `consume_path` or the watcher re-imports it and tries to read the directory name as a library slug. |
| `create_many_from_calibre` (Calibre import) | Skips per file, and skips a whole book whose files are all known. Nothing is moved — the source is somebody else's library. This is what makes a re-run a no-op and an interrupted import resumable by running it again. |
`allow_duplicates=true` overrides all of it, on every endpoint. Keep that working — the
hash is not proof of identity, so a wrong verdict has to be recoverable, and a scripted
@@ -242,6 +259,84 @@ under the new rules, with nothing to show that anything is wrong. Copy
`normalize_title` learned to strip compact edition markers (`2E`, `5e`). It is
idempotent and safe to re-run.
## Importing from Calibre
Two pieces, deliberately separated:
- **`services/calibre.py`** reads `metadata.db` and the tree beside it. It knows nothing about
`Book`, `BookService` or a session, so it is testable without Postgres, and it reports what
Calibre wrote rather than what Chitai wants — identifiers come back keyed by `identifiers.type`
verbatim. It also unpacks a zipped library (`extract_calibre_archive`).
- **`BookService.create_many_from_calibre`** does the ingest, next to the two other ingest paths
because it needs the same privates they do (`_reserve_book_path`, `_save_cover_image`,
`_screen_for_duplicates`). The CLI in `cli.py` is a thin wrapper over it.
- **`services/calibre_import.py`** is lifecycle only — the job registry behind the endpoints:
state, progress, cancellation, and a session of its own.
`docs/calibre-import.md` is the full brief, including the phases not built yet. What matters here:
- **Files are copied, never moved.** `metadata.db` would go on pointing at files that are gone,
which quietly ruins a library somebody still uses. `copy_file` streams rather than using
`shutil.copy`, which would block the loop for a 40 MB read.
- **The extractors are not run.** This is the one ingest path that trusts its input: Calibre's
catalogue is curated and its filenames are truncated to ~42 characters, so `data.name` locates a
file and the database carries the metadata. The title is stored verbatim for the same reason —
no edition split out, no subtitle guessed.
- **The catalogue is copied before it is read**, and the copy is opened read-write. Calibre may be
running; opening the live file either sees a torn state or needs to recover a write-ahead log,
which read-only access cannot do. `close()` removes the copy in a `finally`, or a failure leaves
a catalogue-sized file in the temp directory.
- **`check_same_thread=False` plus an `asyncio.Lock`.** Every query runs through
`asyncio.to_thread`, which hands out whichever worker is free, so the connection outlives the
thread that opened it. The lock is what makes that safe. Removing either one reintroduces
`SQLite objects created in a thread can only be used in that same thread`, intermittently —
the pool often reuses one thread, so it passes until it does not.
- **One book never costs the run.** A failure is recorded in `CalibreImportResult.failed`, the
session is rolled back so the next book can use it, and the files that book had already copied
are deleted — an orphaned directory would make the next attempt reserve `title (2)` and look as
though it had worked. A cover that PIL cannot open costs the cover, not the book.
- **`calibre-uuid`, not `uuid`.** `books.uuid` is stable for the life of the row, so it is the
durable link back to the source and worth matching on. `uuid` is the name `normalize_identifier`
refuses, because an EPUB regenerates one per build.
### The API takes an uploaded archive; the CLI takes a path
**`POST /libraries/{id}/imports/calibre/upload`** is the only way in over HTTP. It takes a zipped
Calibre library, answers **202** with a job handle, and unpacks into a temp directory the job owns.
`GET /libraries/imports/{job_id}` is polled; `DELETE` on the same path stops it.
There is deliberately **no endpoint that imports from a server path**. A desktop Calibre install is
not on the server, and importing from a path the server can already see is a server-side operation
— which is what `litestar --app-dir src/chitai/ calibre-import <path> --library <slug>` is for,
including its `--dry-run`. Do not add the path endpoint back without being asked: it was built,
then removed on purpose.
- **The registry is in memory, so it assumes one worker process.** That holds today (`litestar run`
is single-process, and the consume watcher is already an in-process singleton), but the day
`TODO.md`'s "production image runs the development server" item is fixed with a worker count, a
poll can land on a worker that never heard of the job. `services/calibre_import.py` says so at
the top; an `import_jobs` table is the answer when that happens.
- **The job opens its own session.** The request that started it is long gone and its session
closed with it.
- **Cancelling is not aborting.** A flag is read between books, never during one, so a cancelled
import leaves whole books behind and never half of one. `task.cancel()` would abandon a book
mid-copy and leave files with no row describing them.
- **The job deletes its workspace** — the unpacked archive is a second copy of the whole library,
and the books worth keeping have been copied into the library proper by the time it ends. Removed
even when the run failed, since nothing will come back for it.
- **Extraction refuses** an entry pointing outside the archive (zip slip), an archive that will not
fit on disk, and one with no `metadata.db` within three levels. All three answer 400 before a job
exists, rather than as a job that reports FAILED a moment later.
- **The upload is streamed both sides.** The archive reaches disk in chunks rather than being read
whole, and the SvelteKit proxy passes `request.body` through instead of buffering it — see
`frontend/AGENTS.md`.
Things Calibre does that will produce wrong data if you forget them are documented at the top of
`services/calibre.py` — the `0101-01-01` date sentinel, `|` for a comma in an author name, the
REAL `series_index` that defaults to 1.0 for every book, HTML in `comments`, the views that need
SQLite functions Calibre registers from Python, and `books_pages_link` being both recent and
usually empty. `tests/calibre_fixtures.py` builds a library exercising all of them.
## KOReader hashing
`services/utils.py` reimplements KOReader's partial-MD5 document identifier: 1 KiB samples at