A minimal Docker HTTP API that wraps Calibre's ebook-convert pipeline. Upload a file, get the converted ebook back. Uses the official Calibre Linux binary — no source build required.
docker run -p 8000:8000 --cpus=2 --memory=3g ghcr.io/lncrawl/ebook-convert-api:latestConvert an EPUB to MOBI:
curl -s \
-F "file=@book.epub" \
-F "output_format=mobi" \
http://localhost:8000/convert \
--output book.mobiWith conversion options (each option is its own form field):
curl -s \
-F "file=@book.epub" \
-F "output_format=epub" \
-F "base_font_size=12" \
-F "margin_top=36" \
-F "embed_all_fonts=true" \
http://localhost:8000/convert \
--output book-restyled.epubConverts a file. Blocks until done, then streams the result and cleans up.
Request — multipart/form-data:
| Field | Type | Required | Description |
|---|---|---|---|
file |
upload | yes | Input ebook. Format detected from filename extension. |
output_format |
enum | yes | Target format: epub, mobi, azw3, pdf, ... |
| option fields | varies | no | One typed field per Calibre option — see Conversion options. |
Response — binary file with Content-Disposition: attachment.
| Status | Meaning |
|---|---|
200 |
Converted file |
400 |
Conversion failed — body contains Calibre error detail |
413 |
Upload exceeds MAX_UPLOAD_MB |
422 |
Invalid output_format or an option with the wrong type |
503 |
All workers busy — retry after a moment |
504 |
Conversion timed out |
Returns the full list of supported input and output formats.
{
"input_formats": ["azw", "epub", "fb2", "mobi", "pdf", ...],
"output_formats": ["azw3", "epub", "mobi", "pdf", ...]
}Returns every Calibre option valid for that conversion, grouped by category (pre-generated at image build time) — useful for building UIs or discovering option names. Each group has a group label and a list of option metadata (name, cli_flag, help, type, default, choices). The type is one of str, int, float, bool, choice, or file (an upload — see Conversion options). The groups are: Input (input-format options), the shared categories (Look & Feel, Structure Detection, Table of Contents, Heuristic Processing, Search & Replace, Metadata, General), and Output (output-format options). Server-only options (see the security note) are never included.
[
{
"group": "Look & Feel",
"options": [
{
"name": "base_font_size",
"cli_flag": "--base-font-size",
"help": "The base font size in pts...",
"type": "float",
"default": 0,
"choices": null
}
]
}
]Responds 404 if either format is not supported.
{
"status": "ok",
"calibre_version": "9.9.0",
"max_concurrent_jobs": 2
}Lightweight readiness probe used by Docker and Kubernetes health checks.
Every Calibre conversion option is exposed as its own typed form field on POST /convert,
so the interactive docs at /docs render number/boolean inputs, enum dropdowns, and per-option
help. All option fields are optional — omit a field and Calibre's own default applies.
curl -s \
-F "file=@book.epub" \
-F "output_format=epub" \
-F "base_font_size=12" \
-F "embed_all_fonts=true" \
-F "epub_version=3" \
http://localhost:8000/convert \
--output book.epubThe form advertises the union of options across all formats. Only the options valid for the
chosen input → output pair are applied; any others you send are ignored. To discover the exact
set, types, defaults, and allowed choices for a given pair, call
GET /formats/{in}/{out}/options.
A few Calibre options take a file rather than a scalar value (type: "file" in the options
metadata): cover, search_replace, transform_css_rules, transform_html_rules, and
read_metadata_from_opf. Send these as additional file fields — the server stores the upload for
the job and passes its path to Calibre. A raw server-side path is never accepted (sending one as a
text value yields 422).
curl -s \
-F "file=@book.epub" \
-F "output_format=epub" \
-F "cover=@cover.jpg" \
http://localhost:8000/convert \
--output book.epubSecurity: Server-only flags are never exposed and are ignored if sent: the unsafe output-directory flags
--debug-pipelineand--extract-to, and Calibre's Debug group (--verbose). The file-path options above are the only way to reference a file, and only via upload — the server never reads an arbitrary host path.
Environment variables (all optional):
| Variable | Default | Description |
|---|---|---|
MAX_CONCURRENT_JOBS |
2 |
Worker count (ProcessPoolExecutor size). |
CONVERSION_TIMEOUT_SECONDS |
300 |
Per-job timeout. 504 returned on breach. |
MAX_UPLOAD_MB |
100 |
Upload size limit. 413 returned on breach. |
USE_AUTH |
false |
Enable auth middleware stub (not yet implemented). |
MAX_CONCURRENT_JOBS sets the number of worker processes directly. Size it to the
container's CPU allocation and the memory each ebook-convert job needs (the PDF path,
via headless QtWebEngine, is the heaviest at ~350–550 MB per job). For example
docker run --cpus=4 --memory=3g comfortably runs MAX_CONCURRENT_JOBS=4.
Install uv and poethepoet, then:
uv syncThe poe task runner wraps all common workflows:
# Run production container (builds the image; the option catalog is generated during that build)
poe up
# Run dev container (live-reloads on local file changes via bind-mount)
poe dev
poe dev-exec # open a shell in the running dev container
# Lint / format / typecheck
poe check
# Tests (integration tests hit a live server at API_BASE_URL, default http://localhost:8000;
# the whole session is skipped if it's unreachable — so run `poe dev`/`poe up` first)
poe test
# Regenerate the option catalog (data/catalog.json)
poe catalog # uses local Calibre
poe catalog-dev # uses the dev image's pinned Calibre (run while/after `poe dev`)
# Dependency management
uv add <pkg> # add a dependency
uv lock --upgrade # upgrade all deps, regenerate uv.lockThe app always runs inside Docker. poe dev bind-mounts ./app into the container and starts uvicorn with --reload, so local edits trigger an automatic server restart without rebuilding the image.
poe dev
# edit any file under app/ → server reloads automaticallyAdd a dependency:
uv add <package>Regenerate the lockfile after manual pyproject.toml edits:
uv lockApache 2.0 — see LICENSE.