Author SHA1 Message Date
patrick 412a73cedf fix: tell the tests where the database actually is
ci / backend (push) Failing after 1m16s
ci / frontend (push) Successful in 1m58s
DOCKER_HOST only decides which daemon creates the container; the address the
test dials comes from POSTGRES_HOST, which defaults to the job container's own
loopback. Setting one without the other still times out.
2026-08-18 00:49:41 -04:00
patrick 5176dfcb77 fix: give the backend CI job a docker daemon it can address
ci / backend (push) Failing after 1m30s
ci / frontend (push) Successful in 1m58s
The runner has docker, so the database container started; the job just could
not reach it. pytest-databases takes the address from DOCKER_HOST, and unset
means 127.0.0.1 -- the job container's loopback, not the namespace the sibling
published on. A dind service makes it resolve to a host that answers.
2026-08-18 00:45:43 -04:00
patrick b85ba92380 fix: declare ruff as a dev dependency
ci / backend (push) Failing after 1m7s
ci / frontend (push) Successful in 1m59s
CI has no nix shell, and ruff was only ever a nix package, so both lint steps
died with "Failed to spawn: ruff". Pinned exactly: the formatter deciding what
is correct must be one version, or CI rejects what the dev shell produced.
2026-08-18 00:37:00 -04:00
patrick e9fe18266d feat: lint and format tests in CI
ci / backend (push) Failing after 36s
ci / frontend (push) Successful in 3m26s
Only src/ was covered, which is why it stayed clean while tests/ drifted to 38
findings. migrations/ stays out: its alembic template emits unused imports, so
every generated revision would fail the gate.
2026-08-17 20:56:24 -04:00
patrick 5ec5a4d334 chore: bring the test suite up to ruff's standards
Mostly automatic: empty f-strings, unused imports, formatting. The manual half
was duplicated import blocks stranded mid-file and `== None` assertions, which
become `is None` here because they compare plain attributes -- unlike the
identical rule in filters/book.py, where they build SQL.

Two unused variables are the tests never checking the disk in either
delete_files direction. Left in place under a noqa so the gap stays visible.
2026-08-17 20:56:24 -04:00
patrick 0c25f63600 feat: block CI on the frontend type check
svelte-check is clean, so it gates like the rest. eslint still reports without
failing, now for two findings rather than 87: both are the unsanitized book
description, written up in TODO.md.
2026-08-17 17:59:06 -04:00
patrick 64fed8671e fix: clear the last type errors
asChild is a bits-ui v1 prop the icon picker no longer needs -- it already
passes a child snippet. The drop zone set both webkitdirectory and a bare
directory attribute, which no browser implements. The library view seeds its
collection state once by design, so it says so with untrack.
2026-08-17 17:57:46 -04:00
patrick 3a29294f96 chore: clear the mechanical lint and type findings
Dead imports and locals removed, each blocks keyed, `any` narrowed to unknown.
Two context setters kept their calls and lost only the unused binding; the
settings redirect no longer awaits a parent whose data it discards. A leading
underscore now marks a binding that only holds a position.
2026-08-17 17:55:21 -04:00
patrick 428168c07a chore: stop lint rules that do not model runes from reporting
no-unused-expressions is off for Svelte files: a bare `book;` in an $effect
declares a dependency. The URL and Set instances it flagged are local
temporaries, marked individually. The one real finding was visibleApiKeys,
genuine reactive state cloned on every mutation to force an update; it is a
SvelteSet now.
2026-08-17 17:42:34 -04:00
patrick fbe8a8bf21 fix: type the proxy handlers and the delete callback
The RequestHandler import sat commented out while all four handlers were
annotated with it, so every destructured parameter was an implicit any --
24 of the 30 svelte-check errors. deleteFn returned `{}`, which void is not
assignable to.
2026-08-17 17:42:34 -04:00
patrick 157cc60e91 chore: update lucide icons, prettier-plugin-svelte and jsrepo
The prettier plugin's major reflows one attribute in textarea.svelte.
2026-08-17 16:32:08 -04:00
patrick 930b222b28 chore: update to TypeScript 6
TypeScript 7 is held back: svelte-check refuses it unless 6 and 7 are both
installed behind an npm alias and driven with its experimental --tsgo flag.
2026-08-17 16:29:38 -04:00
patrick 01cdd95bc7 chore: update to Vite 8
Swaps the bundler to rolldown, so chunk layout changes but output does not.
Carries @sveltejs/vite-plugin-svelte 7, which is the release built for it.
2026-08-17 16:27:38 -04:00
patrick e898069b03 chore: update to ESLint 10
no-useless-assignment joined the recommended set and does not understand runes,
reading every `$bindable()` prop default as a dead store; off for Svelte files.
Carries @eslint/js, @eslint/compat, globals and @types/node with it.
2026-08-17 16:21:43 -04:00
patrick b33f57d942 chore: update frontend dependencies within their semver ranges
SvelteKit 2.53 to 2.70 renamed the remote form enhance callback's `form` to
`element`, which would have thrown on reset in the signup, device, library and
cover forms.
2026-08-17 16:13:42 -04:00
patrick 54043a97d2 feat: check formatting, linting and tests in CI
Every push runs ruff, prettier, pytest and svelte-check; a tagged release runs the
blocking half again before it publishes an image. eslint and svelte-check report
without failing, since 87 and 30 findings predate the workflow.
2026-08-17 15:17:20 -04:00
patrick 45b03764d2 chore: format the tree and clear ruff's findings
Applies ruff format, ruff check --fix and prettier, so CI can gate on all three.
The surviving unused imports were all re-exports in __init__.py, now covered by a
per-file ignore; the pdf.js viewer and the generated openapi types join the
vendored code that eslint and prettier already skip.
2026-08-17 15:17:14 -04:00
patrick b70ed5cb51 feat: build and publish release images from version tags
Tagging v* builds both images on Gitea Actions and pushes them to the instance
registry, then smoke-tests the published stack. The frontend read its backend
URL through import.meta.env, which Vite resolves at build time, so an image
could only point at whatever the build host had; it now reads it at runtime.
2026-08-17 15:05:18 -04:00
patrick 0220f2d970 feat: fetch the next page of books before the reader hits the bottom
The prefetch zone is a viewport deep rather than 200px, and the observer
re-observes after a page lands so a chain of loads is not cut short.
2026-08-17 13:48:40 -04:00
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
patrick 85367daf7e feat: show books as an import adds them, not once it ends
Refresh the list every couple of seconds while the queue runs, holding off
while the reader has scrolled past the first page.
2026-08-17 11:21:49 -04:00
163 changed files with 8290 additions and 3052 deletions
+9
View File
@@ -1,6 +1,15 @@
# Change this secret to something random
CHITAI_TOKEN_SECRET=secret
# Which release to run. Pin this to a tag (e.g. 0.1.0) for a real deployment so a
# restart cannot silently move you to a newer image.
CHITAI_VERSION=latest
# The URL you reach the app on, exactly as it appears in the browser. Logging in fails
# with a 403 if this does not match, because SvelteKit rejects the form POST as
# cross-origin. Include the port unless it is the scheme default.
CHITAI_ORIGIN=http://localhost:3000
# Setup the path to your initial library (optional)
CHITAI_DEFAULT_LIBRARY_NAME=Books
CHITAI_DEFAULT_LIBRARY_PATH="libraries/books"
+102
View File
@@ -0,0 +1,102 @@
name: ci
# Formatting, linting, types and tests on every push and pull request.
#
# Blocking: ruff format, ruff check, pytest, prettier and svelte-check.
# Non-blocking: eslint, which reports two `{@html}` XSS findings in collapsible-text.svelte.
# Those are a real vulnerability rather than a lint nit — book descriptions from EPUB files
# are not sanitized — and fixing them is a backend change. Drop the `continue-on-error` once
# that lands, at which point every check blocks.
#
# The release workflow runs the blocking half again before it publishes anything.
on:
push:
branches: ['**']
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
# pytest-databases starts PostgreSQL in a container and then connects to it, and those are
# two different addresses that have to be set separately:
#
# DOCKER_HOST which daemon to create the container on (_service.py get_docker_host)
# POSTGRES_HOST where the test then connects (docker/postgres.py, default
# 127.0.0.1 -- the job container's own loopback, where nothing listens,
# because the database is a sibling container on another namespace)
#
# Setting only the first leaves the tests dialling 127.0.0.1 and timing out with
# "Service 'pytest_databases_postgres' failed to come online".
#
# If the runner is ever given its own dind sidecar, drop this services block and keep the
# two env vars pointed at whatever host it exposes.
services:
docker:
image: docker:27-dind
options: --privileged
env:
DOCKER_TLS_CERTDIR: ''
env:
DOCKER_HOST: tcp://docker:2375
POSTGRES_HOST: docker
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
# astral-sh/setup-uv is not mirrored on gitea.com, unlike the actions used elsewhere
# here, so install uv directly rather than depending on DEFAULT_ACTIONS_URL.
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --locked
- name: Format
run: uv run ruff format --check src/ tests/
- name: Lint
run: uv run ruff check src/ tests/
- name: Tests
run: uv run pytest tests/ -q
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Enable pnpm
run: corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Split out of `pnpm lint` (which is prettier && eslint) so formatting can block
# while eslint does not.
- name: Format
run: pnpm exec prettier --check .
- name: Lint
run: pnpm exec eslint .
continue-on-error: true
- name: Types
run: pnpm check
+142
View File
@@ -0,0 +1,142 @@
name: release
# Builds and publishes the two container images from a version tag.
#
# Tagging v1.2.3 publishes chitai-backend and chitai-frontend as 1.2.3, 1.2, 1 and latest.
# A prerelease tag (v1.2.3-rc.1) publishes only 1.2.3-rc.1 and leaves latest alone, which
# makes -rc tags a safe way to exercise this workflow.
#
# Note that `uses: docker/...` does not mean github.com here the way it would on GitHub. Gitea
# resolves a bare reference against the instance's DEFAULT_ACTIONS_URL, which defaults to
# gitea.com; all five actions below are mirrored there at these tags. If that setting is ever
# pointed somewhere without them, set it to `github` in app.ini rather than editing this file.
#
# Requires a runner with a working Docker daemon (a docker:dind sidecar, or host mode with
# the socket mounted) and, for the smoke job, the compose plugin.
on:
push:
tags:
- 'v*'
env:
REGISTRY: git.jaroszew.ski
jobs:
# The blocking half of ci.yml, run again on the tagged commit so a release cannot publish
# an image whose tests fail. Deliberately duplicated rather than shared: Gitea's support
# for reusable workflows is thinner than GitHub's, and this is a dozen lines.
# Keep in step with ci.yml.
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Backend format, lint and tests
working-directory: backend
run: |
uv sync --locked
uv run ruff format --check src/ tests/
uv run ruff check src/ tests/
uv run pytest tests/ -q
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Frontend format and types
working-directory: frontend
run: |
corepack enable
pnpm install --frozen-lockfile
pnpm exec prettier --check .
pnpm check
build:
needs: quality
runs-on: ubuntu-latest
strategy:
# The two images are independent artifacts; don't cancel a good build for a bad one.
fail-fast: false
matrix:
include:
- component: backend
context: ./backend
- component: frontend
context: ./frontend
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITEA_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}-${{ matrix.component }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
labels: |
org.opencontainers.image.title=chitai-${{ matrix.component }}
org.opencontainers.image.source=https://git.jaroszew.ski/${{ github.repository }}
- uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Relies on the runner's cache server. If it is disabled, drop these two lines —
# a cold build of both images is only a few minutes.
cache-from: type=gha
cache-to: type=gha,mode=max
smoke:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITEA_TOKEN }}
# Boots the stack from the images that were just pushed, rather than rebuilding them.
# This is what catches migrations failing from entrypoint.sh, the frontend being unable
# to reach the backend, and a missing runtime environment variable.
- name: Boot the published images
env:
TAG: ${{ github.ref_name }}
run: |
cp .env.prod-example .env
echo "CHITAI_VERSION=${TAG#v}" >> .env
mkdir -p libraries
docker compose pull backend frontend
docker compose up -d --wait --wait-timeout 180
curl -fsS http://localhost:8000/healthcheck
curl -fsS http://localhost:3000/login > /dev/null
- name: Tear down
if: always()
run: |
docker compose logs --no-color || true
docker compose down -v || true
+1
View File
@@ -1,3 +1,4 @@
.env
.postgres/
.venv
tmp/
+11 -1
View File
@@ -70,9 +70,12 @@ Backend (from `backend/`):
```bash
uv run litestar --app-dir src/chitai/ run --reload # dev server on :8000
pytest tests/ # needs Docker (pytest-databases)
ruff format src/
ruff format src/ tests/
alchemy --config chitai.database.config.config make-migrations
alchemy --config chitai.database.config.config upgrade
# Import a Calibre library. Copies files; --dry-run reports without writing.
litestar --app-dir src/chitai/ calibre-import <path> --library <slug>
```
Frontend (from `frontend/`):
@@ -100,6 +103,13 @@ API docs are served by the running backend at `http://localhost:8000/schema/` (S
manually against a live backend).
- **Migrations are mandatory.** The app runs with `create_all=False`, so a model change without a
matching Alembic revision will not reach the database.
- **CI gates formatting, linting, types and tests.** `.gitea/workflows/ci.yml` runs on every push
and pull request: `ruff format --check src/ tests/`, `ruff check src/ tests/`, `pytest`,
`prettier --check` and `pnpm check` all **block**; only `eslint` reports without failing, and
only until the two `{@html}` findings in `TODO.md` are fixed. Ruff covers `src/` and `tests/`
but deliberately not `migrations/`, whose alembic template emits imports it does not use. A
`v*` tag additionally builds and publishes both container images — see
`docs/ci-release-pipeline.md`.
- **Commit messages** follow `type: summary``feat:`, `fix:`, `refactor:`, `chore:`.
- The three `README.md` files are user-facing. Agent-facing knowledge belongs in the `AGENTS.md`
files.
+11 -5
View File
@@ -26,12 +26,18 @@ eBook library management application.
### Installation (Production Deployment)
1. Clone the repository: `git clone <repository_url>` (Replace `<repository_url>` with the actual URL)
1. Clone the repository: `git clone https://git.jaroszew.ski/patrick/chitai.git`
2. Navigate to the project root: `cd chitai`
3. Build the Docker images: `docker compose build`
4. Copy the example environment file and configure: `cp .env.prod-example .env`
5. Run the Docker containers in detached mode: `docker compose up -d`
6. Access the frontend at: `http://localhost:3000/`
3. Copy the example environment file: `cp .env.prod-example .env`
4. Edit `.env`. At minimum set `CHITAI_TOKEN_SECRET` to something random, and set
`CHITAI_ORIGIN` to the URL you will reach the app on — logging in fails with a 403 if it
does not match. Pin `CHITAI_VERSION` to a release tag if you would rather not track `latest`.
5. Pull the released images: `docker compose pull`
6. Run the Docker containers in detached mode: `docker compose up -d`
7. Access the frontend at: `http://localhost:3000/`
To build the images from source instead of pulling them, run `docker compose build` in place of
step 5.
## Development
+58 -7
View File
@@ -114,18 +114,65 @@ CMD ["litestar", "--app-dir", "chitai", "run", "--host", "0.0.0.0", "--port", "8
`litestar run` is the CLI development runner. Production should invoke uvicorn or granian
directly, with a worker count.
### Nothing gates formatting, linting or types
### The delete_files flag is untested in both directions
`ruff format --check src/` reports 27 of 61 files unformatted, and `ruff check src/` finds
114 errors — 100 of them unused imports, the rest bare `except`, unused variables and
`== True` comparisons. `ruff check --fix` clears 51 automatically.
`backend/tests/integration/test_book.py``test_remove_file_with_delete_files_false_keeps_filesystem_file`
and `test_remove_file_with_delete_files_true_removes_filesystem_file`
There is no `[tool.ruff]` section in `pyproject.toml`, so only ruff's default `E4/E7/E9/F`
rules run, and no type checker is configured at all despite `# type: ignore` comments in
the tree. Individually these are trivial; collectively they say nothing runs on commit.
Both tests capture the file's path and then assert only `response.status_code == 204`.
Neither looks at the disk. So the flag that decides whether removing a file from a book
also **erases it from the filesystem** is covered in name only, in both directions.
Ruff surfaced this as two `F841` unused variables; the variables carry a `# noqa: F841`
and a comment rather than being deleted, so the gap stays visible. Remove the noqa when
the assertions land.
The reason it is not a two-line fix: `FileMetadata.path` is stored relative to `book.path`,
so the test has to resolve it against the library root to know what to stat. That
resolution is the same thing `BookService.get_files` is recorded as getting wrong (see
`backend/AGENTS.md`), so it is worth settling once and using in both places.
### No type checker on the backend
Formatting, linting and tests now run in CI (`.gitea/workflows/ci.yml`) and block, and the
tree is clean against them. What is still missing is a type checker: nothing runs one despite
`# type: ignore` comments in the tree.
`pyproject.toml` gained a `[tool.ruff.lint.per-file-ignores]` section for `__init__.py`
re-exports, but no rule selection — so only ruff's default `E4/E7/E9/F` rules run. Widening
that set is worthwhile and will surface a fresh batch of findings.
## Frontend
### Book descriptions are rendered as unsanitized HTML
`frontend/src/lib/components/ui/collapsible-text/collapsible-text.svelte` — lines 42 and 45
The component renders `{@html text}`, and its only caller is the book detail page:
`<CollapsibleText text={book.description} maxLength={500} />`. So whatever is in
`Book.description` reaches the DOM as markup.
The Calibre importer is fine — `services/calibre.py:401` passes comments through
`strip_html`, because Calibre stores HTML there. But `strip_html` is used **nowhere else in
the backend**, and `EpubExtractor._extract_description` returns
`epub.get_metadata("DC", "description")[0][0]` verbatim. EPUB `dc:description` routinely
carries markup, so an uploaded book with `<img src=x onerror=…>` in that field executes
script on the book page, with the session cookie in scope. Metadata edited through the UI
is stored unfiltered too.
This is the same class as the scripted-EPUB item below — untrusted file content reaching an
origin that holds a session — by a different route, and it does not need `allow-scripts` to
work.
Fix: sanitize at ingest, next to where Calibre already does. Reuse `strip_html` in
`_extract_description` if descriptions should be plain text, or run an allowlist sanitizer if
the formatting is worth keeping. Either way the stored rows need backfilling through the same
helper, since the validators only fire on write. Dropping `{@html}` to `{text}` in the
component fixes the display side but leaves the payload in the database.
These are the only two findings `pnpm exec eslint .` still reports; CI's eslint step stops
being `continue-on-error` once they are gone.
### Scripted EPUBs run against the app origin
**This is a regression from the foliate-js migration, not a pre-existing gap.**
@@ -242,6 +289,10 @@ so the whole file sits in the node process, per concurrent reader, and the brows
nothing until it completes. Passing `response.body` straight through restores the stream
and is a small change.
This is now **responses only**. The request side was fixed for the Calibre archive upload,
which cannot be held in memory: POST and PATCH pass `request.body` through with
`duplex: 'half'` (`bodyOf` in the same file). The response side is the same shape of fix.
**Range.** The proxy forwards only `Content-Type`, `Content-Disposition` and
`Content-Length`. It never sends the client's `Range` upstream, and would drop
`Accept-Ranges` and `Content-Range` coming back — a 206 without `Content-Range` is
+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
@@ -0,0 +1,114 @@
"""backfill file content types
Revision ID: d2d69065ede3
Revises: 49a9e85a0ffc
Create Date: 2026-08-17 11:37:58.959135
"""
import warnings
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend
from advanced_alchemy.types.encrypted_string import PGCryptoBackend
from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher
from advanced_alchemy.types.password_hash.passlib import PasslibHasher
from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher
from sqlalchemy import Text # noqa: F401
if TYPE_CHECKING:
from collections.abc import Sequence
__all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"]
sa.GUID = GUID
sa.DateTimeUTC = DateTimeUTC
sa.ORA_JSONB = ORA_JSONB
sa.EncryptedString = EncryptedString
sa.EncryptedText = EncryptedText
sa.StoredObject = StoredObject
sa.PasswordHash = PasswordHash
sa.Argon2Hasher = Argon2Hasher
sa.PasslibHasher = PasslibHasher
sa.PwdlibHasher = PwdlibHasher
sa.FernetBackend = FernetBackend
sa.PGCryptoBackend = PGCryptoBackend
# revision identifiers, used by Alembic.
revision = 'd2d69065ede3'
down_revision = '49a9e85a0ffc'
branch_labels = None
depends_on = None
def upgrade() -> None:
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning)
with op.get_context().autocommit_block():
schema_upgrades()
data_upgrades()
def downgrade() -> None:
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning)
with op.get_context().autocommit_block():
data_downgrades()
schema_downgrades()
def schema_upgrades() -> None:
"""schema upgrade migrations go here."""
pass
def schema_downgrades() -> None:
"""schema downgrade migrations go here."""
pass
def data_upgrades() -> None:
"""
Name the format of every file whose content type was never worked out.
`create_many_from_existing_files` filled the column from `mimetypes.guess_type`,
which answers None for `.mobi`, `.azw`, `.fb2` and `.lit` — so a consume-directory
import of any of those stored a null, and the OPDS acquisition link a reader app
uses to decide what it can open carried nothing.
Both write paths now go through `guess_content_type`, which this uses too, so the
formats in its table get named retroactively. A row it still cannot name is **left
null** rather than filled with a placeholder: null is the truth, the column is
nullable, and the one consumer that needs a string substitutes one itself.
Idempotent: it only looks at rows that carry nothing.
"""
from chitai.services.utils import guess_content_type
connection = op.get_bind()
files = connection.execute(
sa.text(
"SELECT id, path FROM file_metadata "
"WHERE content_type IS NULL OR content_type = ''"
)
).fetchall()
parameters = [
{"id": id, "content_type": content_type}
for id, path in files
if (content_type := guess_content_type(path)) is not None
]
if not parameters:
return
batch_size = 1000
for start in range(0, len(parameters), batch_size):
connection.execute(
sa.text(
"UPDATE file_metadata SET content_type = :content_type WHERE id = :id"
),
parameters[start : start + batch_size],
)
def data_downgrades() -> None:
"""Add any optional data downgrade migrations here!"""
+6
View File
@@ -36,8 +36,14 @@ dev = [
"pytest>=8.4.2",
"pytest-asyncio>=1.2.0",
"pytest-databases[postgres]>=0.15.0",
"ruff==0.15.14",
]
[tool.ruff.lint.per-file-ignores]
# The package __init__.py files exist to re-export their modules' public names, so every
# import in them is "unused" as far as F401 is concerned.
"__init__.py" = ["F401"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
+2 -1
View File
@@ -4,7 +4,8 @@ pkgs.mkShell {
buildInputs = with pkgs; [
# Python development environment for Chitai
python313Packages.greenlet
python313Packages.ruff
# ruff is a dev dependency in pyproject.toml, not a shell package: CI has no nix, so a
# nix-only formatter is one CI cannot run, and two copies could disagree on formatting.
uv
# postgres database
+7 -2
View File
@@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from chitai import controllers as c
from chitai.cli import CalibreCLIPlugin
from chitai.config import settings
from chitai.database.config import alchemy
from chitai.database.models.user import User
@@ -71,6 +72,7 @@ oauth2_auth = OAuth2PasswordBearerAuth[User](
watcher_task: asyncio.Task
@asynccontextmanager
async def setup_db_connection(app: Litestar) -> AsyncGenerator[None, None]:
# Setup databse
@@ -106,7 +108,9 @@ async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]:
book_service = BookService(session=db_session)
library_service = LibraryService(session=db_session)
file_watcher = ConsumeDirectoryWatcher(settings.consume_path, library_service, book_service)
file_watcher = ConsumeDirectoryWatcher(
settings.consume_path, library_service, book_service
)
watcher_task = asyncio.create_task(file_watcher.init_watcher())
try:
@@ -114,6 +118,7 @@ async def setup_directory_watcher(app: Litestar) -> AsyncGenerator[None, None]:
finally:
watcher_task.cancel()
def create_app() -> Litestar:
return Litestar(
route_handlers=[
@@ -133,7 +138,7 @@ def create_app() -> Litestar:
],
exception_handlers=exception_handlers,
lifespan=[setup_db_connection, setup_directory_watcher],
plugins=[alchemy],
plugins=[alchemy, CalibreCLIPlugin()],
on_app_init=[oauth2_auth.on_app_init],
openapi_config=OpenAPIConfig(
title="Chitai",
+186
View File
@@ -0,0 +1,186 @@
# src/chitai/cli.py
"""
Extra commands on the `litestar` CLI.
Registered through `CalibreCLIPlugin` in `app.py`, so they run as
`litestar --app-dir src/chitai/ calibre-import …` and get the app's own configuration
without a second way to load it.
The Calibre import lives here as well as behind an endpoint because the case it exists
for is a one-time migration of a library that may be hundreds of gigabytes. That should
not depend on a browser tab staying open.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import click
from click import Group
from litestar.plugins import CLIPluginProtocol
from chitai.config import settings
from chitai.database.models import Library
from chitai.services.book import BookService, CalibreImportProgress, CalibreImportResult
from chitai.services.calibre import CalibreLibrary, CalibreLibraryError
from chitai.services.library import LibraryService
class CalibreCLIPlugin(CLIPluginProtocol):
"""Adds `calibre-import` to the Litestar CLI."""
def on_cli_init(self, cli: Group) -> None:
cli.add_command(calibre_import)
@click.command(name="calibre-import")
@click.argument(
"source",
type=click.Path(exists=True, file_okay=False, path_type=Path),
)
@click.option(
"--library",
"library_slug",
required=True,
help="Slug of the Chitai library to import into.",
)
@click.option(
"--allow-duplicates",
is_flag=True,
help="Import books whose files the library already holds.",
)
@click.option(
"--dry-run",
is_flag=True,
help="Read the catalogue and report what it holds, without writing anything.",
)
def calibre_import(
source: Path, library_slug: str, allow_duplicates: bool, dry_run: bool
) -> None:
"""
Import a Calibre library from SOURCE, the directory holding its metadata.db.
Files are copied, never moved: the Calibre library is left exactly as it is, and
re-running skips whatever is already stored.
"""
asyncio.run(_import(source, library_slug, allow_duplicates, dry_run))
async def _import(
source: Path, library_slug: str, allow_duplicates: bool, dry_run: bool
) -> None:
try:
library_source = CalibreLibrary(source)
await library_source.open()
except CalibreLibraryError as exc:
raise click.ClickException(str(exc)) from exc
try:
if dry_run:
await _report(library_source)
return
async with settings.alchemy_config.get_session() as session:
library = await _library(session, library_slug)
result = await BookService(session=session).create_many_from_calibre(
library_source,
library,
allow_duplicates=allow_duplicates,
on_progress=_print_progress,
)
finally:
await library_source.close()
_print_summary(result)
if result.failed:
raise SystemExit(1)
async def _library(session: object, slug: str) -> Library:
"""Resolve the target library, or explain what the options were."""
service = LibraryService(session=session) # type: ignore[arg-type]
library = await service.get_one_or_none(Library.slug == slug)
if library is None:
available = ", ".join(sorted(item.slug for item in await service.list()))
raise click.ClickException(
f"No library with slug '{slug}'. Available: {available or 'none'}"
)
# A read-only library is one pointing at a tree Chitai does not own. Copying books
# into it would write into somebody else's directory.
if library.read_only:
raise click.ClickException(
f"Library '{slug}' is read-only, so nothing can be imported into it"
)
return library
async def _report(source: CalibreLibrary) -> None:
"""Describe the catalogue without touching the database."""
books = await source.books()
click.echo(f"{len(books)} book(s) in {source.root}\n")
for book in books:
authors = ", ".join(book.authors) or "unknown author"
formats = ", ".join(file.format for file in book.files) or "no files"
click.echo(f" #{book.calibre_id:<6} {book.title}")
click.echo(f" {'':<7} {authors} · {formats}")
missing = [
book
for book in books
if any(not file.path.is_file() for file in book.files) or not book.files
]
if missing:
click.echo(
f"\n{len(missing)} book(s) have files the catalogue lists "
"but disk does not:"
)
for book in missing:
click.echo(f" #{book.calibre_id} {book.title}")
def _print_progress(progress: CalibreImportProgress) -> None:
marker = {"created": "+", "skipped": "-", "failed": "!"}.get(progress.outcome, " ")
detail = f" ({progress.detail})" if progress.detail else ""
click.echo(
f"[{progress.processed:>5}/{progress.total}] {marker} {progress.title}{detail}"
)
def _print_summary(result: CalibreImportResult) -> None:
click.echo(
f"\n{len(result.created)} created, {len(result.skipped)} skipped, "
f"{len(result.failed)} failed, of {result.total}."
)
if result.duplicate_files:
click.echo(
f"{len(result.duplicate_files)} individual file(s) were already stored and "
"were left out of books that imported otherwise."
)
for failure in result.failed:
click.echo(f" failed #{failure.calibre_id} {failure.title}: {failure.reason}")
# Imported all the same — a metadata match is a guess, and the duplicates screen is
# where these get decided.
for possible in result.possible_duplicates:
names = ", ".join(
f"{candidate.title} (#{candidate.book_id})"
for candidate in possible.candidates
)
click.echo(
f" possible duplicate {possible.title} (#{possible.book_id}) "
f"may already be in the library as: {names}"
)
+1 -1
View File
@@ -1,7 +1,7 @@
# src/chitai/controllers/access.py
# Standard library
from typing import Annotated, Any
from typing import Annotated
import logging
# Third-party libraries
+1 -2
View File
@@ -4,9 +4,8 @@
from typing import Annotated
# Third-party libraries
from litestar import Controller, post, get, patch, delete
from litestar import Controller, get
from litestar.params import Dependency
from litestar.exceptions import HTTPException
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination
from advanced_alchemy.service import FilterTypeT
+26 -22
View File
@@ -8,47 +8,51 @@ from litestar import Controller, post, get, delete
from litestar.di import Provide
from chitai.services import dependencies as deps
class DeviceController(Controller):
""" Controller for managing KOReader devices."""
dependencies = {
"device_service": Provide(deps.provide_kosync_device_service)
}
class DeviceController(Controller):
"""Controller for managing KOReader devices."""
dependencies = {"device_service": Provide(deps.provide_kosync_device_service)}
path = "/devices"
@get()
async def get_devices(self, device_service: KosyncDeviceService, current_user: User) -> OffsetPagination[KosyncDeviceRead]:
""" Return a list of all the user's devices."""
devices = await device_service.list(
KosyncDevice.user_id == current_user.id
)
async def get_devices(
self, device_service: KosyncDeviceService, current_user: User
) -> OffsetPagination[KosyncDeviceRead]:
"""Return a list of all the user's devices."""
devices = await device_service.list(KosyncDevice.user_id == current_user.id)
return device_service.to_schema(devices, schema_type=KosyncDeviceRead)
@post()
async def create_device(self, data: KosyncDeviceCreate, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead:
device = await device_service.create({
'name': data.name,
'user_id': current_user.id
})
async def create_device(
self,
data: KosyncDeviceCreate,
device_service: KosyncDeviceService,
current_user: User,
) -> KosyncDeviceRead:
device = await device_service.create(
{"name": data.name, "user_id": current_user.id}
)
return device_service.to_schema(device, schema_type=KosyncDeviceRead)
@delete("/{device_id:int}")
async def delete_device(self, device_id: int, device_service: KosyncDeviceService, current_user: User) -> None:
async def delete_device(
self, device_id: int, device_service: KosyncDeviceService, current_user: User
) -> None:
# Ensure the device exists and is owned by the user
device = await device_service.get_one(
KosyncDevice.id == device_id,
KosyncDevice.user_id == current_user.id
KosyncDevice.id == device_id, KosyncDevice.user_id == current_user.id
)
await device_service.delete(device.id)
@get("/{device_id:int}/regenerate")
async def regenerate_device_api_key(self, device_id: int, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead:
async def regenerate_device_api_key(
self, device_id: int, device_service: KosyncDeviceService, current_user: User
) -> KosyncDeviceRead:
# Ensure the device exists and is owned by the user
device = await device_service.get_one(
KosyncDevice.id == device_id,
KosyncDevice.user_id == current_user.id
KosyncDevice.id == device_id, KosyncDevice.user_id == current_user.id
)
updated_device = await device_service.regenerate_api_key(device.id)
return device_service.to_schema(updated_device, schema_type=KosyncDeviceRead)
@@ -58,10 +58,14 @@ class KosyncController(Controller):
user: m.User,
) -> KosyncProgressRead:
"""Return the Kosync progress record associated with the given document."""
progress = await kosync_progress_service.get_by_document_hash(user.id, document_id)
progress = await kosync_progress_service.get_by_document_hash(
user.id, document_id
)
if not progress:
raise HTTPException(status_code=404, detail="No progress found for document")
raise HTTPException(
status_code=404, detail="No progress found for document"
)
return KosyncProgressRead(
document=progress.document,
@@ -85,6 +89,3 @@ class KosyncController(Controller):
detail="User accounts must be created via the main application",
status_code=HTTP_403_FORBIDDEN,
)
+169 -4
View File
@@ -1,12 +1,20 @@
# src/chitai/controllers/library.py
# Standard library
import asyncio
import shutil
import tempfile
from pathlib import Path
from typing import Annotated
# Third-party libraries
from litestar import Controller, post, get, patch, delete
from litestar.params import Dependency
import aiofiles
from aiofiles import os as aios
from litestar import Controller, post, get, delete
from litestar.enums import RequestEncodingType
from litestar.params import Body, Dependency
from litestar.exceptions import HTTPException
from litestar.status_codes import HTTP_200_OK, HTTP_202_ACCEPTED
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination
from advanced_alchemy.service import FilterTypeT
@@ -14,10 +22,21 @@ from advanced_alchemy.service import FilterTypeT
# Local imports
from chitai.database import models as m
from chitai.services import LibraryService
from chitai.schemas.library import LibraryCreate, LibraryRead
from chitai.schemas.library import (
CalibreArchiveUpload,
CalibreImportRead,
LibraryCreate,
LibraryRead,
)
from chitai.services.calibre import CalibreLibraryError, extract_calibre_archive
from chitai.services.calibre_import import registry
from chitai.services.utils import DirectoryDoesNotExist
# How much of an uploaded archive is held in memory at a time on its way to disk.
UPLOAD_CHUNK_SIZE = 262144 # 256 KiB
class LibraryController(Controller):
"""Controller for managing library operations."""
@@ -70,7 +89,153 @@ class LibraryController(Controller):
Injected Dependencies:
library_service: The service used to query and return library data.
"""
results, total = await library_service.list_and_count(*filters, load=[m.Library.books])
results, total = await library_service.list_and_count(
*filters, load=[m.Library.books]
)
return library_service.to_schema(
results, total, filters, schema_type=LibraryRead
)
@post(
path="{library_id:int}/imports/calibre/upload",
status_code=HTTP_202_ACCEPTED,
request_max_body_size=None,
)
async def upload_calibre_import(
self,
library_service: LibraryService,
library_id: int,
data: Annotated[
CalibreArchiveUpload, Body(media_type=RequestEncodingType.MULTI_PART)
],
) -> CalibreImportRead:
"""
Import a zipped Calibre library that was uploaded rather than named on disk.
For the case where the library is not on the server: zip the Calibre folder and
send it. Unpacked into a temp directory the job owns and deletes when it ends —
by which time the books worth keeping have been copied into the library proper.
The server-folder route stays the one for a very large library. This one has to
carry the whole archive over HTTP first.
Path Parameters:
library_id: The library to import into.
Request Body:
data: The `.zip` holding the Calibre library.
Returns:
The job, already running.
Raises:
HTTPException: 400 if the archive is not a zip, holds no `metadata.db`,
names an entry outside itself, or would not fit on disk; 409 if an
import into this library is already running.
"""
library = await self._importable(library_service, library_id)
if (running := registry.running_for(library_id)) is not None:
raise HTTPException(
status_code=409,
detail="An import into this library is already running",
extra={"job_id": running.id},
)
workspace = Path(await asyncio.to_thread(tempfile.mkdtemp))
try:
archive = workspace / "upload.zip"
await data.archive.seek(0)
async with aiofiles.open(archive, "wb") as destination:
while chunk := await data.archive.read(UPLOAD_CHUNK_SIZE):
await destination.write(chunk)
unpacked = workspace / "library"
unpacked.mkdir()
catalogue = await extract_calibre_archive(archive, unpacked)
# The archive itself is dead weight once unpacked, and the library it
# unpacked to can be large.
await aios.remove(archive)
except CalibreLibraryError as exc:
await asyncio.to_thread(shutil.rmtree, workspace, True)
raise HTTPException(status_code=400, detail=str(exc))
except Exception:
await asyncio.to_thread(shutil.rmtree, workspace, True)
raise
job = registry.start(
library,
catalogue,
workspace=workspace,
label=data.archive.filename or "uploaded archive",
allow_duplicates=data.allow_duplicates,
)
return CalibreImportRead.model_validate(job)
@get(path="imports/{job_id:str}")
async def get_import(self, job_id: str) -> CalibreImportRead:
"""
Report on an import.
Polled by the client while a run is going. Jobs are held in memory, so this is
answered by the process that started it — see `services/calibre_import.py`.
Path Parameters:
job_id: The job to report on.
Raises:
HTTPException: 404 if this process holds no such job.
"""
if (job := registry.get(job_id)) is None:
raise HTTPException(status_code=404, detail="No such import")
return CalibreImportRead.model_validate(job)
@delete(path="imports/{job_id:str}", status_code=HTTP_200_OK)
async def cancel_import(self, job_id: str) -> CalibreImportRead:
"""
Ask an import to stop after the book it is on.
Deliberately not an abort: a book abandoned mid-copy would leave files on disk
with no row describing them. Whatever it has imported stays imported.
Path Parameters:
job_id: The job to stop.
Raises:
HTTPException: 404 if this process holds no such job.
"""
if (job := registry.cancel(job_id)) is None:
raise HTTPException(status_code=404, detail="No such import")
return CalibreImportRead.model_validate(job)
@staticmethod
async def _importable(
library_service: LibraryService, library_id: int
) -> m.Library:
"""
The library, if it can be imported into at all.
Raises:
HTTPException: 404 if there is no such library, 400 if it is read-only —
a read-only library points at a tree Chitai does not own, so copying
books into it would write into somebody else's directory.
"""
library = await library_service.get_one_or_none(m.Library.id == library_id)
if library is None:
raise HTTPException(status_code=404, detail="No such library")
if library.read_only:
raise HTTPException(
status_code=400,
detail="This library is read-only, so nothing can be imported into it",
)
return library
+93 -79
View File
@@ -1,4 +1,3 @@
from chitai.services import dependencies as deps
from chitai.database import models as m
from chitai.services.author import AuthorService
@@ -6,7 +5,15 @@ from chitai.services.filters.author import AuthorLibraryFilter
from chitai.services.filters.publisher import PublisherLibraryFilter
from chitai.services.filters.tags import TagLibraryFilter
from chitai.services.opds.models import Entry, Link, LinkTypes, LinkRelations
from chitai.services.opds.opds import create_acquisition_feed, create_navigation_feed, create_library_navigation_feed, create_collection_navigation_feed, create_pagination_links, create_search_link, get_opensearch_document
from chitai.services.opds.opds import (
create_acquisition_feed,
create_navigation_feed,
create_library_navigation_feed,
create_collection_navigation_feed,
create_pagination_links,
create_search_link,
get_opensearch_document,
)
from chitai.services import BookService, ShelfService, LibraryService
from chitai.services.publisher import PublisherService
from chitai.services.tag import TagService
@@ -14,7 +21,7 @@ from litestar import Controller, Request, Response, get
from litestar.response import File
from litestar.di import Provide
from litestar.exceptions import HTTPException
from advanced_alchemy.filters import CollectionFilter, LimitOffset, OrderBy
from advanced_alchemy.filters import CollectionFilter, LimitOffset, OrderBy
from chitai.middleware.basic_auth import basic_auth_mw
from urllib.parse import urlencode
from typing import Annotated
@@ -22,11 +29,10 @@ from litestar.params import Dependency
from advanced_alchemy.service import FilterTypeT
class OpdsController(Controller):
""" Controller for managing OPDS endpoints """
"""Controller for managing OPDS endpoints"""
middleware=[basic_auth_mw]
middleware = [basic_auth_mw]
dependencies = {
"user": Provide(deps.provide_user_via_basic_auth),
@@ -76,32 +82,30 @@ class OpdsController(Controller):
title=lib.name,
href=f"/opds/library/{lib.id}",
rel=LinkRelations.SUBSECTION,
type=LinkTypes.NAVIGATION
type=LinkTypes.NAVIGATION,
)
]
) for lib in libraries
],
)
for lib in libraries
]
feed = create_navigation_feed(
id="/opds",
title="Root",
self_url="/opds",
links=[
Link(
rel="search",
href="/opds/opensearch",
type="application/opensearchdescription+xml",
title="Search books",
)
],
entries=entries
)
return Response(
feed,
media_type="application/xml"
id="/opds",
title="Root",
self_url="/opds",
links=[
Link(
rel="search",
href="/opds/opensearch",
type="application/opensearchdescription+xml",
title="Search books",
)
],
entries=entries,
)
return Response(feed, media_type="application/xml")
@get("/acquisition")
async def get_acquisition_feed(
self,
@@ -109,8 +113,10 @@ class OpdsController(Controller):
feed_id: str,
feed_title: str,
books_service: BookService,
book_filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = []
book_filters: Annotated[
list[FilterTypeT], Dependency(skip_validation=True)
] = [],
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response:
all_filters = [*filters, *book_filters]
@@ -121,20 +127,22 @@ class OpdsController(Controller):
links = []
# Create pagination links if it is a paginated feed
if request.query_params.get('paginated'):
if request.query_params.get("paginated"):
pagination = create_pagination_links(
request=request,
total=total,
limit=limit,
offset=offset,
feed_title=feed_title,
link_type=LinkTypes.ACQUISITION
link_type=LinkTypes.ACQUISITION,
)
links.extend([link for link in [pagination.next_link, pagination.prev_link] if link])
links.extend(
[link for link in [pagination.next_link, pagination.prev_link] if link]
)
# Add search link if this is a searchable feed
if request.query_params.get('search'):
if request.query_params.get("search"):
links.append(create_search_link(request))
# Create self URL
@@ -150,15 +158,14 @@ class OpdsController(Controller):
return Response(feed, media_type="application/xml")
@get("/opensearch")
async def opensearch(self, user: m.User, request: Request) -> Response:
return Response(
get_opensearch_document(
base_url=f'/opds/search?{urlencode(list(request.query_params.items()), doseq=True)}&'
base_url=f"/opds/search?{urlencode(list(request.query_params.items()), doseq=True)}&"
),
media_type="application/xml"
media_type="application/xml",
)
@get("/library/{library_id:int}")
@@ -168,7 +175,6 @@ class OpdsController(Controller):
return Response(feed, media_type="application/xml")
@get("/library/{library_id:int}/{collection_type:str}")
async def get_library_collection_feed(
self,
@@ -180,34 +186,46 @@ class OpdsController(Controller):
tag_service: TagService,
publisher_service: PublisherService,
request: Request,
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = []
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response:
service_map = {
'shelves': (shelf_service, lambda: shelf_service.list_and_count(
*filters,
CollectionFilter("library_id", values=[library.id]),
OrderBy("name", "asc"),
m.BookList.user_id == user.id
)),
'tags': (tag_service, lambda: tag_service.list_and_count(
*filters,
TagLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True,
)),
'authors': (author_service, lambda: author_service.list_and_count(
*filters,
AuthorLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True
)),
'publishers': (publisher_service, lambda: publisher_service.list_and_count(
*filters,
PublisherLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True
))
"shelves": (
shelf_service,
lambda: shelf_service.list_and_count(
*filters,
CollectionFilter("library_id", values=[library.id]),
OrderBy("name", "asc"),
m.BookList.user_id == user.id,
),
),
"tags": (
tag_service,
lambda: tag_service.list_and_count(
*filters,
TagLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True,
),
),
"authors": (
author_service,
lambda: author_service.list_and_count(
*filters,
AuthorLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True,
),
),
"publishers": (
publisher_service,
lambda: publisher_service.list_and_count(
*filters,
PublisherLibraryFilter(libraries=[library.id]),
OrderBy("name", "asc"),
uniquify=True,
),
),
}
if collection_type not in service_map:
@@ -220,38 +238,37 @@ class OpdsController(Controller):
# Create pagination links if it is a paginated feed
limit, offset = extract_limit_offset(filters)
if request.query_params.get('paginated'):
if request.query_params.get("paginated"):
pagination = create_pagination_links(
request=request,
total=total,
limit=limit,
offset=offset,
feed_title=collection_type,
link_type=LinkTypes.ACQUISITION
link_type=LinkTypes.ACQUISITION,
)
links.extend([link for link in [pagination.next_link, pagination.prev_link] if link])
links.extend(
[link for link in [pagination.next_link, pagination.prev_link] if link]
)
feed = create_collection_navigation_feed(library, collection_type, items, links)
return Response(feed, media_type="application/xml")
@get("/search")
async def search_books(
self, books_service: BookService,
self,
books_service: BookService,
request: Request,
book_filters: Annotated[
list[FilterTypeT], Dependency(skip_validation=True)
] = [],
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = []
filters: Annotated[list[FilterTypeT], Dependency(skip_validation=True)] = [],
) -> Response:
filters = [*filters, *book_filters]
books, total = await books_service.list_and_count(
*filters
)
books, total = await books_service.list_and_count(*filters)
limit, offset = extract_limit_offset(filters)
@@ -262,17 +279,17 @@ class OpdsController(Controller):
limit=limit,
offset=offset,
feed_title="Search Results",
link_type=LinkTypes.ACQUISITION
link_type=LinkTypes.ACQUISITION,
)
links = [link for link in [pagination.next_link, pagination.prev_link] if link]
catalog_xml = create_acquisition_feed(
id=f"/opds/search?q=q",
id="/opds/search?q=q",
title="Search results",
url=f"/opds/search?q=q",
url="/opds/search?q=q",
books=books,
links=links
links=links,
)
return Response(catalog_xml, media_type="application/xml")
@@ -287,10 +304,7 @@ class OpdsController(Controller):
def extract_limit_offset(filters: list[FilterTypeT]) -> tuple[int, int]:
"""Extract page size and offset from filters"""
limit_offset_filter = next(
(f for f in filters if isinstance(f, LimitOffset)),
None
)
limit_offset_filter = next((f for f in filters if isinstance(f, LimitOffset)), None)
if limit_offset_filter:
return limit_offset_filter.limit, limit_offset_filter.offset
+1 -1
View File
@@ -4,7 +4,7 @@
from typing import Annotated
# Third-party libraries
from litestar import Controller, post, get, patch, delete
from litestar import Controller, get
from litestar.params import Dependency
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination
+3 -1
View File
@@ -70,7 +70,9 @@ class BookshelfController(Controller):
filters.append(CollectionFilter("library_id", values=libraries))
filters.append(m.BookList.user_id == current_user.id)
results, total = await shelf_service.list_and_count(*filters, load=[selectinload(m.BookList.book_links)])
results, total = await shelf_service.list_and_count(
*filters, load=[selectinload(m.BookList.book_links)]
)
return shelf_service.to_schema(results, total, filters, schema_type=ShelfRead)
@post()
+1 -1
View File
@@ -4,7 +4,7 @@
from typing import Annotated
# Third-party libraries
from litestar import Controller, post, get, patch, delete
from litestar import Controller, get
from litestar.params import Dependency
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
from advanced_alchemy.service.pagination import OffsetPagination
@@ -1,7 +1,6 @@
from typing import Optional
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import relationship
from advanced_alchemy.base import BigIntAuditBase
@@ -20,5 +19,5 @@ class BookProgress(BigIntAuditBase):
pdf_page: Mapped[Optional[int]]
percentage: Mapped[float]
completed: Mapped[Optional[bool]]
device: Mapped[Optional[str]] # Device that updated the progress
device: Mapped[Optional[str]] # Device that updated the progress
device_id: Mapped[Optional[str]]
@@ -1,12 +1,10 @@
from collections.abc import Hashable
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import ColumnElement, ForeignKey
from sqlalchemy import ColumnElement
from advanced_alchemy.base import BigIntAuditBase
from advanced_alchemy.mixins import UniqueMixin
from .book import Book
class BookSeries(BigIntAuditBase, UniqueMixin):
__tablename__ = "book_series"
@@ -1,9 +1,10 @@
from sqlalchemy import ColumnElement, ForeignKey
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from advanced_alchemy.base import BigIntAuditBase
class KosyncDevice(BigIntAuditBase):
__tablename__ = "devices"
@@ -1,6 +1,4 @@
from litestar import Request, Response, MediaType
from litestar.exceptions import HTTPException
from litestar.status_codes import HTTP_404_NOT_FOUND
from advanced_alchemy.exceptions import NotFoundError
+13 -8
View File
@@ -1,9 +1,9 @@
from base64 import b64decode
from chitai.services.user import UserService
from litestar.middleware import (
AbstractAuthenticationMiddleware,
AuthenticationResult,
DefineMiddleware
AbstractAuthenticationMiddleware,
AuthenticationResult,
DefineMiddleware,
)
from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
@@ -11,19 +11,24 @@ from chitai.config import settings
class BasicAuthenticationMiddleware(AbstractAuthenticationMiddleware):
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult:
"""Given a request, parse the header for Base64 encoded basic auth credentials. """
async def authenticate_request(
self, connection: ASGIConnection
) -> AuthenticationResult:
"""Given a request, parse the header for Base64 encoded basic auth credentials."""
# retrieve the auth header
auth_header = connection.headers.get("Authorization", None)
if not auth_header:
raise NotAuthorizedException()
username, password = b64decode(auth_header.split("Basic ")[1]).decode().split(":")
username, password = (
b64decode(auth_header.split("Basic ")[1]).decode().split(":")
)
try:
db_session = settings.alchemy_config.provide_session(connection.app.state, connection.scope)
db_session = settings.alchemy_config.provide_session(
connection.app.state, connection.scope
)
user_service = UserService(db_session)
user = await user_service.authenticate(username, password)
return AuthenticationResult(user=user, auth=None)
+10 -6
View File
@@ -1,9 +1,9 @@
from chitai.services.user import UserService
from chitai.services.kosync_device import KosyncDeviceService
from litestar.middleware import (
AbstractAuthenticationMiddleware,
AuthenticationResult,
DefineMiddleware
AbstractAuthenticationMiddleware,
AuthenticationResult,
DefineMiddleware,
)
from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
@@ -11,8 +11,10 @@ from chitai.config import settings
class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult:
"""Given a request, parse the header for Base64 encoded basic auth credentials. """
async def authenticate_request(
self, connection: ASGIConnection
) -> AuthenticationResult:
"""Given a request, parse the header for Base64 encoded basic auth credentials."""
# retrieve the auth header
api_key = connection.headers.get("X-AUTH-USER", None)
@@ -20,7 +22,9 @@ class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
raise NotAuthorizedException()
try:
db_session = settings.alchemy_config.provide_session(connection.app.state, connection.scope)
db_session = settings.alchemy_config.provide_session(
connection.app.state, connection.scope
)
user_service = UserService(db_session)
device_service = KosyncDeviceService(db_session)
+5 -3
View File
@@ -30,7 +30,11 @@ class FileMetadataRead(BaseModel):
path: str
hash: str
size: int
content_type: str
# Nullable, though every ingest path now writes one through
# `guess_content_type`. Rows predating it can hold null, and a required field here
# turns one of those into a 500 on a book the reader can otherwise open.
content_type: str | None = None
@computed_field
@property
@@ -269,5 +273,3 @@ class BookProgressCreate(BaseModel):
completed: bool | None = None
device_type: str | None = None
device_id: str | None = None
+56 -3
View File
@@ -1,7 +1,8 @@
from pathlib import Path
from typing import Annotated
from pydantic import BaseModel, Field, computed_field
from advanced_alchemy.utils.text import slugify
from pydantic import BaseModel, ConfigDict, Field, SkipValidation, computed_field
from litestar.datastructures import UploadFile
from advanced_alchemy.utils.text import slugify
class LibraryCreate(BaseModel):
name: Annotated[str, Field(min_length=1)]
@@ -16,6 +17,7 @@ class LibraryCreate(BaseModel):
def slug(self) -> str:
return slugify(self.name)
class LibraryRead(BaseModel):
id: int
name: str
@@ -27,6 +29,57 @@ class LibraryRead(BaseModel):
total: int | None = None
class CalibreArchiveUpload(BaseModel):
"""
A zipped Calibre library.
The only way in through the API: a desktop Calibre install is usually not on the
server at all. Importing from a path the server can already see is a server-side
operation, and stays one — `litestar calibre-import` does that.
"""
archive: Annotated[UploadFile, SkipValidation]
allow_duplicates: bool = False
model_config = ConfigDict(arbitrary_types_allowed=True)
class ImportFailureRead(BaseModel):
"""A book the import could not store."""
model_config = ConfigDict(from_attributes=True)
calibre_id: int
title: str
reason: str
class CalibreImportRead(BaseModel):
"""A running or finished import."""
model_config = ConfigDict(from_attributes=True)
id: str
library_id: int
source: str
state: str
total: int
processed: int
created: int
skipped: int
failed: int
current_title: str | None = None
failures: list[ImportFailureRead] = Field(default_factory=list)
# A count, not the records. The library's duplicates screen is what shows them.
possible_duplicates: int = 0
# Set when the run itself broke, as opposed to individual books failing.
error: str | None = None
class LibraryUpdate(BaseModel):
name: str | None
root_path: str | None
+2 -2
View File
@@ -1,11 +1,11 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, Field, field_validator
class ShelfRead(BaseModel):
id: int
title: str
library_id: int | None = None
total: int | None = None # Number of books in the shelf
total: int | None = None # Number of books in the shelf
class ShelfCreate(BaseModel):
+416 -46
View File
@@ -3,7 +3,6 @@
# Standard library
from __future__ import annotations
from collections import defaultdict
import mimetypes
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, field, replace
@@ -20,7 +19,7 @@ from advanced_alchemy.service import (
SQLAlchemyAsyncRepositoryService,
ModelDictT,
is_dict,
schema_dump
schema_dump,
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.filters import CollectionFilter
@@ -51,6 +50,7 @@ from chitai.database.models import (
Library,
)
from chitai.schemas.book import BooksCreateFromFiles
from chitai.services.calibre import CalibreBook, CalibreLibrary
from chitai.services.filesystem_library import BookPathGenerator
from chitai.services.matching import (
normalize_author,
@@ -58,11 +58,14 @@ from chitai.services.matching import (
normalize_title,
)
from chitai.services.metadata_extractor import Extractor as MetadataExtractor
from chitai.services.metadata_extractor import parse_identifier
from chitai.services.utils import (
cleanup_empty_parent_directories,
copy_file,
delete_file,
fingerprint_file,
fingerprint_upload,
guess_content_type,
move_dir_contents,
move_file,
save_image,
@@ -85,6 +88,10 @@ Fingerprint = tuple[str, int]
# How much of a file is held in memory at a time while it is written to disk.
CHUNK_SIZE = 262144 # 256 KiB
# What Calibre's own `books.uuid` is stored as. Not `uuid`: that name is reserved for
# the per-build identifier an EPUB carries, which duplicate matching ignores.
CALIBRE_UUID = "calibre-uuid"
@dataclass(frozen=True)
class DuplicateFile:
@@ -147,6 +154,58 @@ class ImportResult:
possible_duplicates: list[PossibleDuplicate] = field(default_factory=list)
@dataclass(frozen=True)
class UnimportedBook:
"""A book in a source catalogue that did not become a Chitai book, and why."""
calibre_id: int
title: str
reason: str
# The book already holding its files, when that is why it was left out.
book_id: int | None = None
@dataclass(frozen=True)
class CalibreImportProgress:
"""One book's outcome, as it happens.
Reported per book rather than at the end: an import of a real library takes long
enough that its progress is the only thing worth looking at while it runs.
"""
processed: int
total: int
title: str
outcome: str
"""`created`, `skipped` or `failed`."""
detail: str | None = None
@dataclass
class CalibreImportResult:
"""What importing a Calibre library produced.
Holds ids rather than `Book` objects: a real catalogue runs to thousands of books,
and nothing downstream needs them all in memory at once.
"""
total: int = 0
created: list[int] = field(default_factory=list)
skipped: list[UnimportedBook] = field(default_factory=list)
failed: list[UnimportedBook] = field(default_factory=list)
# Individual files left out of books that were otherwise imported.
duplicate_files: list[DuplicateFile] = field(default_factory=list)
possible_duplicates: list[PossibleDuplicate] = field(default_factory=list)
# The run gave up before reaching the end of the catalogue, on request. What it did
# get through is complete; `processed` is simply short of `total`.
stopped: bool = False
class DuplicateFilesError(Exception):
"""Raised when an import would store files that are already in the library."""
@@ -290,7 +349,9 @@ def _series_position(data: Any) -> tuple[str, str | None]:
return normalize_title(series), (position or "").strip() or None
def _is_different_volume(left: tuple[str, str | None], right: tuple[str, str | None]) -> bool:
def _is_different_volume(
left: tuple[str, str | None], right: tuple[str, str | None]
) -> bool:
"""
Whether two books are numbered entries of one series, and not the same entry.
@@ -405,8 +466,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
repository_type = Repo
async def create_book(
self,
data: ModelDictT[Book],
@@ -588,7 +647,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
return []
statement = (
select(Book).where(or_(*conditions)).options(*self._MATCH_LOADS).order_by(Book.id)
select(Book)
.where(or_(*conditions))
.options(*self._MATCH_LOADS)
.order_by(Book.id)
)
if exclude_book_id is not None:
@@ -665,7 +727,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
ValueError: If fewer than two distinct books were named, one of them does
not exist, or they do not all belong to one library.
"""
merged_ids = [book_id for book_id in dict.fromkeys(merged_ids) if book_id != survivor_id]
merged_ids = [
book_id for book_id in dict.fromkeys(merged_ids) if book_id != survivor_id
]
if not merged_ids:
raise ValueError("A merge needs at least two different books")
@@ -789,12 +853,16 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# Reading progress is per user, and the furthest one is the true answer for a
# reader who has been through the EPUB and not the PDF.
rows = (
await session.execute(
select(BookProgress)
.where(BookProgress.book_id.in_([survivor_id, *merged_ids]))
.order_by(BookProgress.percentage.desc())
(
await session.execute(
select(BookProgress)
.where(BookProgress.book_id.in_([survivor_id, *merged_ids]))
.order_by(BookProgress.percentage.desc())
)
)
).scalars().all()
.scalars()
.all()
)
furthest: dict[int, BookProgress] = {}
for progress in rows:
@@ -843,14 +911,19 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# and only one of them however many books offered it.
held_names = select(Identifier.name).where(Identifier.book_id == survivor_id)
incoming = (
await session.execute(
select(Identifier)
.where(
Identifier.book_id.in_(merged_ids), Identifier.name.notin_(held_names)
(
await session.execute(
select(Identifier)
.where(
Identifier.book_id.in_(merged_ids),
Identifier.name.notin_(held_names),
)
.order_by(Identifier.book_id, Identifier.id)
)
.order_by(Identifier.book_id, Identifier.id)
)
).scalars().all()
.scalars()
.all()
)
taken: set[str] = set()
for identifier in incoming:
@@ -873,15 +946,19 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
merged = set(merged_ids)
rows = (
await session.execute(
select(DuplicateDismissal).where(
or_(
DuplicateDismissal.book_a_id.in_(merged_ids),
DuplicateDismissal.book_b_id.in_(merged_ids),
(
await session.execute(
select(DuplicateDismissal).where(
or_(
DuplicateDismissal.book_a_id.in_(merged_ids),
DuplicateDismissal.book_b_id.in_(merged_ids),
)
)
)
)
).scalars().all()
.scalars()
.all()
)
existing = await self._dismissed_pairs()
doomed: list[int] = []
@@ -966,7 +1043,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# copies meet as long as they agree on any one of them.
for title in titles:
for author in authors:
buckets[(MATCHED_ON_TITLE_AUTHOR, f"{title}\x00{author}")].append(book_id)
buckets[(MATCHED_ON_TITLE_AUTHOR, f"{title}\x00{author}")].append(
book_id
)
dismissed = await self._dismissed_pairs()
series = {book_id: _series_position(book) for book_id, book in books.items()}
@@ -1012,8 +1091,14 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
raise ValueError("A book cannot be dismissed against itself")
found = (
await self.repository.session.execute(select(Book.id).where(Book.id.in_(pair)))
).scalars().all()
(
await self.repository.session.execute(
select(Book.id).where(Book.id.in_(pair))
)
)
.scalars()
.all()
)
if len(set(found)) != 2:
raise ValueError("No such book")
@@ -1246,12 +1331,14 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
**kwargs,
)
result.books.append(book)
await self._record_possible_duplicates(result, book, library)
await self._record_possible_duplicates(
result.possible_duplicates, book, library
)
return result
async def _record_possible_duplicates(
self, result: ImportResult, book: Book, library: Library
self, into: list[PossibleDuplicate], book: Book, library: Library
) -> None:
"""
Note anything the library already holds that this book might be a copy of.
@@ -1262,7 +1349,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
the same import is a candidate too.
Args:
result: The import being assembled, appended to in place.
into: The collection to append to, in place. Takes the list rather than the
whole result so that every ingest path can use it whatever its own
result type is.
book: The book that was just created.
library: The library it went into.
"""
@@ -1271,7 +1360,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
)
if candidates:
result.possible_duplicates.append(
into.append(
PossibleDuplicate(
book_id=book.id, title=book.title, candidates=candidates
)
@@ -1283,7 +1372,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
consume_path: Path,
library: Library,
allow_duplicates: bool = False,
**kwargs
**kwargs,
) -> ImportResult:
"""
Import files that are already on disk, from the consume directory.
@@ -1307,7 +1396,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
result = ImportResult()
file_groups: dict[Path, list[Path]] = defaultdict(list)
for file_path in file_paths:
rel_path = file_path.relative_to(consume_path)
parent_rel = rel_path.parent
@@ -1322,7 +1410,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# Add to appropriate group
file_groups[group_key].append(file_path)
# For each grouping
for group, files in file_groups.items():
# Fingerprinted before anything moves, since the paths are about to change.
@@ -1344,7 +1431,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
cleanup_empty_parent_directories(consume_path / group, consume_path)
continue
data: dict[str, Any] = {'files': accepted}
data: dict[str, Any] = {"files": accepted}
await self._parse_metadata_from_files(data, root_path=consume_path)
await self._save_cover_image(data)
@@ -1357,7 +1444,6 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
for file in accepted:
file_hash, file_size = fingerprints[file.name]
content_type, _ = mimetypes.guess_type(file)
filename = path_gen.generate_filename(data, Path(file.name))
@@ -1366,7 +1452,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
path=str(filename),
size=file_size,
hash=file_hash,
content_type=content_type,
content_type=guess_content_type(file),
)
)
@@ -1386,12 +1472,276 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
book = await super().create(data)
result.books.append(book)
await self._record_possible_duplicates(result, book, library)
await self._record_possible_duplicates(
result.possible_duplicates, book, library
)
await self.repository.session.commit()
return result
async def create_many_from_calibre(
self,
source: CalibreLibrary,
library: Library,
allow_duplicates: bool = False,
on_progress: Callable[[CalibreImportProgress], None] | None = None,
should_stop: Callable[[], bool] | None = None,
) -> CalibreImportResult:
"""
Import a Calibre library, **copying** its files rather than taking them.
Calibre's catalogue is curated and its filenames are truncated, so the metadata
is taken from `metadata.db` and the extractors are not run at all — the one
ingest path here that trusts what it is given. The title is stored verbatim for
the same reason: no edition is split out of it and no subtitle is guessed, since
the field is one somebody maintained by hand.
Files are **copied**, never moved. `metadata.db` would go on pointing at files
that were gone, which quietly ruins a library the reader still uses; the source
tree is left byte-for-byte alone.
Re-running is safe and needs no bookkeeping: the same bytes are recognised
wherever they sit, so a second pass over one library skips all of it.
Args:
source: An open `CalibreLibrary`.
library: The Chitai library to import into.
allow_duplicates: Import books whose files are already stored.
on_progress: Called once per book, as it is decided.
should_stop: Asked before each book whether to give up. Checked between
books rather than during one, so a cancelled import leaves whole books
behind and never half of one.
Returns:
What was created, left out and flagged. A cancelled run returns what it got
through — every book in `created` is committed and complete.
"""
books = await source.books()
result = CalibreImportResult(total=len(books))
for processed, entry in enumerate(books, start=1):
if should_stop is not None and should_stop():
result.stopped = True
break
try:
outcome, detail = await self._import_calibre_book(
entry, library, result, allow_duplicates
)
except Exception as exc:
# One book must never cost the rest of the import. The session is left
# unusable by a failed flush, so it is rolled back before the next book
# touches it.
await self.repository.session.rollback()
outcome, detail = "failed", f"{type(exc).__name__}: {exc}"
result.failed.append(
UnimportedBook(
calibre_id=entry.calibre_id, title=entry.title, reason=detail
)
)
if on_progress is not None:
on_progress(
CalibreImportProgress(
processed=processed,
total=result.total,
title=entry.title,
outcome=outcome,
detail=detail,
)
)
return result
async def _import_calibre_book(
self,
entry: CalibreBook,
library: Library,
result: CalibreImportResult,
allow_duplicates: bool,
) -> tuple[str, str | None]:
"""
Import one Calibre book, or say why it was left out.
Args:
entry: The book as Calibre describes it.
library: The library to import into.
result: The import being assembled, appended to in place.
allow_duplicates: Store files the library already holds.
Returns:
The outcome — `created` or `skipped` — and a detail for the progress report.
Raises:
Exception: Anything the write path raises, after the files this book had
already copied are removed again. An orphaned directory would make the
next attempt reserve `title (2)` and look like it had succeeded.
"""
# What the catalogue claims and what is on disk can disagree: Calibre keeps the
# row when a file is moved away behind its back.
present = [
file.path for file in entry.files if await aios.path.isfile(file.path)
]
if not present:
reason = "no files on disk" if entry.files else "no files in the catalogue"
result.skipped.append(
UnimportedBook(
calibre_id=entry.calibre_id, title=entry.title, reason=reason
)
)
return "skipped", reason
fingerprints = {path.name: await fingerprint_file(path) for path in present}
if allow_duplicates:
accepted, rejected = present, []
else:
known = await self.find_duplicate_files(fingerprints.values(), library)
accepted, rejected = self._screen_for_duplicates(
present, fingerprints, known, library
)
if not accepted:
held_by = next(
(duplicate.book_id for _, duplicate in rejected if duplicate.book_id),
None,
)
result.skipped.append(
UnimportedBook(
calibre_id=entry.calibre_id,
title=entry.title,
reason="already stored",
book_id=held_by,
)
)
return "skipped", "already stored"
# Files this book already has under another name are reported but do not stop
# the formats that are new from being imported.
result.duplicate_files.extend(duplicate for _, duplicate in rejected)
data = self._calibre_metadata(entry, library)
path_gen = BookPathGenerator(library.root_path)
parent = await self._reserve_book_path(path_gen.generate_path(data))
data["path"] = str(parent)
copied: list[Path] = []
try:
await self._attach_calibre_cover(entry, data)
file_metadata = []
for path in accepted:
filename = path_gen.generate_filename(data, Path(path.name))
destination = _unused_path(parent / filename)
await copy_file(path, destination)
copied.append(destination)
file_hash, file_size = fingerprints[path.name]
file_metadata.append(
FileMetadata(
path=destination.name,
size=file_size,
hash=file_hash,
content_type=guess_content_type(path),
)
)
data["files"] = file_metadata
book = await self.create(data)
result.created.append(book.id)
await self._record_possible_duplicates(
result.possible_duplicates, book, library
)
await self.repository.session.commit()
except Exception:
for path in copied:
await delete_file(path)
cleanup_empty_parent_directories(parent, Path(library.root_path))
raise
return "created", None
def _calibre_metadata(self, entry: CalibreBook, library: Library) -> dict[str, Any]:
"""
Turn one `CalibreBook` into the payload `create` takes.
Args:
entry: The book as Calibre describes it.
library: The library it is going into.
Returns:
The metadata dict, with no files or path in it yet.
"""
# Folded onto Chitai's schemes here rather than in the reader, which reports what
# Calibre wrote. `parse_identifier` maps `amazon` and `mobi-asin` both onto
# `asin`, and a book carrying both would otherwise breach the unique
# `(name, book_id)` constraint — so the first one wins, as it does everywhere
# else identifiers are merged.
identifiers: dict[str, str] = {}
for name, value in entry.identifiers.items():
if parsed := parse_identifier(value, scheme=name):
identifiers.setdefault(*parsed)
# Deliberately not passed through `parse_identifier`, which would recognise the
# shape and file it under `uuid` — a name `normalize_identifier` refuses, since
# an EPUB's uuid is regenerated per build. Calibre's is stable for the life of
# the row, so it is the one durable link back to the source and worth matching
# on when the same library is imported twice.
if entry.uuid:
identifiers.setdefault(CALIBRE_UUID, entry.uuid)
return {
"library_id": library.id,
"title": entry.title or "Unknown",
"authors": list(entry.authors),
"description": entry.description,
"published_date": entry.published_date,
"series": entry.series,
"series_position": entry.series_position,
"tags": list(entry.tags),
"publisher": entry.publisher,
"language": entry.language,
"identifiers": identifiers,
"pages": entry.pages,
}
async def _attach_calibre_cover(self, entry: CalibreBook, data: dict) -> None:
"""
Put Calibre's `cover.jpg` on the payload, for `_save_cover_image` to convert.
Taken from the file Calibre already rendered rather than extracted again from
the book: it is the cover the reader chose, and opening every EPUB and rendering
the first page of every PDF is the slowest thing an import could do.
Args:
entry: The book as Calibre describes it.
data: The payload, modified in place.
"""
if entry.cover is None or not await aios.path.isfile(entry.cover):
return
try:
# Copied out of the context manager: the image is saved later, by which time
# the file this was opened from is closed.
with Image.open(entry.cover) as cover:
data["cover_image"] = cover.copy()
except OSError:
# A truncated or malformed cover.jpg is not a reason to refuse the book. It
# is the least important thing in the directory, and one can be added later
# from the book page; the files cannot.
data.pop("cover_image", None)
return
await self._save_cover_image(data)
@staticmethod
async def _quarantine_duplicate(
file: Path, consume_path: Path, library: Library
@@ -1479,7 +1829,12 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
if not await aios.path.isfile(path):
raise ValueError("The file is missing")
return File(path, media_type=file.content_type)
# Rows written before `guess_content_type` existed can hold null here, and
# so can `guess_content_type` itself. Litestar fills a null in from the
# filename, falling back to `application/octet-stream` on the response.
return File(
path, media_type=file.content_type or guess_content_type(file.path)
)
raise ValueError("No such file for the given book")
@@ -1585,9 +1940,13 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
# TODO: Move only the files associated with the book instead of the whole directory
await move_dir_contents(book.path, updated_path)
data["path"] = str(updated_path)
cleanup_empty_parent_directories(Path(book.path), Path(library.root_path))
cleanup_empty_parent_directories(
Path(book.path), Path(library.root_path)
)
return await super().update(data, item_id=book_id, execution_options={"populate_existing": True})
return await super().update(
data, item_id=book_id, execution_options={"populate_existing": True}
)
async def add_files(
self,
@@ -1646,7 +2005,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
data["files"] = files
new_files = await self._save_book_files(library, data, fingerprints)
book.files.extend(new_files)
await self.update_book(book.id, {"files": [file for file in book.files]}, library)
await self.update_book(
book.id, {"files": [file for file in book.files]}, library
)
@staticmethod
async def _restore_file(
@@ -1749,7 +2110,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
return data
async def _populate_with_unique_relationships(self, data: ModelDictT[Book]) -> ModelDictT[Book]:
async def _populate_with_unique_relationships(
self, data: ModelDictT[Book]
) -> ModelDictT[Book]:
"""
Ensure relationship entities (authors, series, tags, etc.) are unique in the database.
@@ -1964,14 +2327,19 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
path=str(filename),
size=file_size,
hash=fingerprint[0] if fingerprint else hasher.hexdigest(),
content_type=file.content_type,
# The name outranks what the browser said: it posts
# `application/octet-stream` for every format it does not know,
# which is most of them.
content_type=guess_content_type(file, fallback=file.content_type),
)
)
data["files"] = file_metadata
return data["files"]
async def _parse_metadata_from_files(self, data: dict, root_path: Path | None = None) -> dict:
async def _parse_metadata_from_files(
self, data: dict, root_path: Path | None = None
) -> dict:
"""
Extract metadata (title, author, etc.) from book files.
@@ -1983,7 +2351,9 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
Returns:
The data with extracted metadata populated in empty fields.
"""
extracted_metadata = await MetadataExtractor.extract_metadata(data["files"], root_path)
extracted_metadata = await MetadataExtractor.extract_metadata(
data["files"], root_path
)
# Add missing fields and update empty (falsey) fields with extracted metadata
for attr in extracted_metadata.keys():
-5
View File
@@ -1,13 +1,8 @@
# src/chitai/services/bookshelf.py
# Third-party libraries
from typing import Any, Sequence
from advanced_alchemy.exceptions import ErrorMessages
from advanced_alchemy.filters import StatementFilter
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.utils.dataclass import Empty, EmptyType
from sqlalchemy import ColumnElement, Select, delete
# Local imports
from chitai.database.models.book_list import BookList, BookListLink
+585
View File
@@ -0,0 +1,585 @@
# src/chitai/services/calibre.py
"""
Read a Calibre library.
This knows about `metadata.db` and the tree beside it, and nothing about `Book`,
`BookService` or a database session it is a file-format reader, and it is testable
without Postgres or an app. Interpretation belongs to whoever imports what it returns:
identifiers come back exactly as Calibre wrote them, not folded onto Chitai's schemes.
Things about Calibre that are load-bearing here:
- **Never query the views.** `meta` and the `tag_browser_*` family call SQLite functions
Calibre registers from Python at connection time, so `SELECT * FROM meta` fails with
`no such function: sortconcat`. Only base tables are touched below.
- **An unknown date is a sentinel, not a null** `0101-01-01`, Calibre's
`UNDEFINED_DATE`. It parses fine as a date, so nothing complains; it just makes every
book without a publication date look like it was published in the year 101.
- **`data.name` is not the title.** It is the on-disk stem, truncated to Calibre's
filename limit and sanitised, so the file is `The Project Gutenberg eBook #33283_
Calcul - Silvanus Phillips Thompson.pdf` for a book titled `The Project Gutenberg
eBook #33283: Calculus Made Easy, 2nd Edition`. Names locate files; the database
carries the metadata.
- **`authors.name` escapes a comma as `|`**, which Calibre reverses on read
(`AuthorsTable.unserialize` in its `db/tables.py`).
- **`series_index` defaults to 1.0 whether or not the book is in a series**, so a
position is only meaningful alongside a series.
- **`books_pages_link` is recent and often empty.** It is treated as optional both ways:
the table may not exist, and where it does the rows are frequently `pages = 0` with
`needs_scan = 1`.
Nothing walks the tree: every file is located through `books.path`, which is why
`.caltrash` where Calibre keeps deleted books, still on disk cannot be picked up by
accident.
"""
from __future__ import annotations
import asyncio
import shutil
import sqlite3
import tempfile
import zipfile
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import date, datetime
from html.parser import HTMLParser
from pathlib import Path, PurePosixPath
METADATA_DB = "metadata.db"
COVER_FILENAME = "cover.jpg"
# Calibre writes `0101-01-01` for "no date". Any year this early is that sentinel rather
# than a publication date somebody meant.
EARLIEST_REAL_YEAR = 1000
# The sidecars a WAL-mode database keeps beside itself. Copied along with it so the
# snapshot can be recovered, since Calibre may be running while this reads.
_DATABASE_SIDECARS = ("-wal", "-shm")
class CalibreLibraryError(Exception):
"""The library cannot be read at all — wrong directory, or no catalogue in it."""
# How far down an archive to look for `metadata.db`. Zipping a Calibre library gives
# either the directory itself or its contents, and a file manager may add a wrapper
# folder on top, so two levels of nesting is normal and more is somebody's backup tree.
_ARCHIVE_SEARCH_DEPTH = 3
# Extraction is refused unless the destination has the uncompressed size plus this
# much headroom. Filling the disk would take the whole application down, not just the
# import.
_DISK_HEADROOM = 256 * 1024 * 1024 # 256 MiB
def _archive_members(archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
"""
The entries worth extracting, refusing any that would escape the destination.
`ZipFile.extract` does sanitise names, but relying on that silently is how the next
person to swap the extraction call reintroduces zip-slip. An archive naming
`../../etc/anything` is malformed or hostile, and either way there is nothing to
salvage by continuing.
Raises:
CalibreLibraryError: If any entry points outside the archive root.
"""
members = []
for member in archive.infolist():
if member.is_dir():
continue
name = PurePosixPath(member.filename)
if name.is_absolute() or ".." in name.parts:
raise CalibreLibraryError(
f"The archive contains an entry outside itself: {member.filename!r}"
)
members.append(member)
return members
async def extract_calibre_archive(archive: Path, destination: Path) -> Path:
"""
Unpack a zipped Calibre library and find the catalogue inside it.
Args:
archive: The `.zip` to unpack.
destination: An empty directory to unpack into. The caller owns it and is
responsible for removing it.
Returns:
The directory holding `metadata.db`, which is what `CalibreLibrary` takes.
Raises:
CalibreLibraryError: If the file is not a zip, names an entry outside itself,
would not fit on disk, or holds no `metadata.db`.
"""
return await asyncio.to_thread(_extract_calibre_archive, archive, destination)
def _extract_calibre_archive(archive: Path, destination: Path) -> Path:
if not zipfile.is_zipfile(archive):
raise CalibreLibraryError(
"That is not a zip file. A Calibre library has to be zipped, not tarred."
)
with zipfile.ZipFile(archive) as opened:
members = _archive_members(opened)
if not any(
PurePosixPath(member.filename).name == METADATA_DB for member in members
):
raise CalibreLibraryError(
f"The archive holds no {METADATA_DB}, so it is not a Calibre library"
)
# Checked before writing rather than discovered part-way through: a full disk
# takes the whole application down, and the number is in the archive already.
needed = sum(member.file_size for member in members)
free = shutil.disk_usage(destination).free
if needed + _DISK_HEADROOM > free:
raise CalibreLibraryError(
f"Unpacking needs {needed // (1024 * 1024)} MiB and only "
f"{free // (1024 * 1024)} MiB is free"
)
opened.extractall(destination, members=members)
return _find_catalogue(destination)
def _find_catalogue(root: Path) -> Path:
"""The shallowest directory under `root` holding a `metadata.db`."""
candidates = sorted(
(path.parent for path in root.rglob(METADATA_DB) if path.is_file()),
key=lambda path: len(path.relative_to(root).parts),
)
for candidate in candidates:
if len(candidate.relative_to(root).parts) <= _ARCHIVE_SEARCH_DEPTH:
return candidate
raise CalibreLibraryError(
f"No {METADATA_DB} within {_ARCHIVE_SEARCH_DEPTH} levels of the archive root"
)
@dataclass(frozen=True)
class CalibreFile:
"""One row of Calibre's `data` table: a book in one format."""
path: Path
"""Absolute path, resolved against the library root. Not checked for existence."""
format: str
"""As Calibre stores it, upper case: `EPUB`, `PDF`, `AZW3`."""
size: int
"""`data.uncompressed_size` — Calibre's claim, not a fresh stat."""
@dataclass(frozen=True)
class CalibreBook:
"""One book, with everything Chitai has a column for and nothing it does not."""
calibre_id: int
uuid: str
title: str
authors: list[str] = field(default_factory=list)
description: str | None = None
published_date: date | None = None
series: str | None = None
series_position: str | None = None
tags: list[str] = field(default_factory=list)
publisher: str | None = None
language: str | None = None
identifiers: dict[str, str] = field(default_factory=dict)
"""Keyed by `identifiers.type` verbatim — `isbn`, `mobi-asin`, `amazon`."""
pages: int | None = None
cover: Path | None = None
files: list[CalibreFile] = field(default_factory=list)
class CalibreLibrary:
"""
A Calibre library on disk, opened for reading.
The catalogue is **copied** before it is read. Calibre may be running and writing,
and opening the live file either sees a torn state or needs to recover a write-ahead
log, which read-only access cannot do. The copy is small hundreds of kilobytes for
a handful of books, single-digit megabytes for thousands so this costs nothing and
removes the question. The original is never opened by SQLite at all.
"""
def __init__(self, root: Path | str) -> None:
self.root = Path(root)
self._connection: sqlite3.Connection | None = None
self._workspace: Path | None = None
# Every query runs in a worker thread, and `asyncio.to_thread` hands out
# whichever one is free — so the connection outlives the thread that opened it
# and `check_same_thread` has to be off. The lock is what makes that safe: it
# keeps two queries off the connection at once, which is the thing that check
# was standing in for.
self._lock = asyncio.Lock()
@property
def database(self) -> Path:
return self.root / METADATA_DB
async def open(self) -> None:
"""
Copy the catalogue aside and connect to the copy.
Raises:
CalibreLibraryError: If there is no `metadata.db` under the root.
"""
if self._connection is not None:
return
if not await asyncio.to_thread(self.database.is_file):
raise CalibreLibraryError(
f"No {METADATA_DB} in '{self.root}' — that is not a Calibre library"
)
self._workspace = Path(await asyncio.to_thread(tempfile.mkdtemp))
copy = self._workspace / METADATA_DB
await asyncio.to_thread(self._copy_database, copy)
# Read-write on our own copy, deliberately: that is what lets SQLite recover a
# write-ahead log the source may have been mid-way through.
self._connection = sqlite3.connect(str(copy), check_same_thread=False)
def _copy_database(self, destination: Path) -> None:
shutil.copy2(self.database, destination)
for suffix in _DATABASE_SIDECARS:
sidecar = self.database.with_name(self.database.name + suffix)
if sidecar.is_file():
shutil.copy2(sidecar, destination.with_name(destination.name + suffix))
async def close(self) -> None:
"""
Disconnect and remove the copy. Safe to call more than once.
The copy is removed even if closing the connection fails otherwise a failure
here leaves a catalogue-sized file in the temp directory, and the caller that
failed is exactly the one that will not come back to tidy up.
"""
try:
if self._connection is not None:
async with self._lock:
self._connection.close()
self._connection = None
finally:
if self._workspace is not None:
await asyncio.to_thread(shutil.rmtree, self._workspace, True)
self._workspace = None
async def __aenter__(self) -> CalibreLibrary:
await self.open()
return self
async def __aexit__(self, *_exception: object) -> None:
await self.close()
async def count(self) -> int:
"""How many books the catalogue holds, without reading any of them."""
rows = await self._in_thread(
lambda: self._execute("SELECT count(*) FROM books")
)
return int(rows[0][0])
async def books(self) -> list[CalibreBook]:
"""
Read the whole catalogue.
One query per table and the joining done in Python, rather than a per-book query
across ten tables. Everything Chitai stores about a book is small, so a
self-hosted catalogue fits in memory comfortably.
Returns:
Every book, in Calibre id order.
"""
return await self._in_thread(self._read_books)
async def _in_thread[T](self, work: Callable[[], T]) -> T:
"""Run one unit of SQLite work off the event loop, and only one at a time."""
async with self._lock:
return await asyncio.to_thread(work)
def _execute(self, statement: str) -> list[tuple]:
if self._connection is None:
raise CalibreLibraryError("The library is not open")
return self._connection.execute(statement).fetchall()
def _has_table(self, name: str) -> bool:
return bool(
self._execute(
f"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '{name}'"
)
)
def _read_books(self) -> list[CalibreBook]:
authors = self._grouped(
"SELECT bal.book, a.name FROM books_authors_link bal "
"JOIN authors a ON a.id = bal.author ORDER BY bal.id"
)
tags = self._grouped(
"SELECT btl.book, t.name FROM books_tags_link btl "
"JOIN tags t ON t.id = btl.tag ORDER BY t.name"
)
# Calibre's link tables are unique per book for these two, so the last write
# wins and there is nothing to choose between.
series = self._mapped(
"SELECT bsl.book, s.name FROM books_series_link bsl "
"JOIN series s ON s.id = bsl.series"
)
publishers = self._mapped(
"SELECT bpl.book, p.name FROM books_publishers_link bpl "
"JOIN publishers p ON p.id = bpl.publisher"
)
# A book can carry several languages; Chitai holds one, so the first wins.
languages = self._grouped(
"SELECT bll.book, l.lang_code FROM books_languages_link bll "
"JOIN languages l ON l.id = bll.lang_code ORDER BY bll.item_order"
)
descriptions = self._mapped("SELECT book, text FROM comments")
identifiers: dict[int, dict[str, str]] = defaultdict(dict)
for book_id, name, value in self._execute(
"SELECT book, type, val FROM identifiers"
):
if name and value:
identifiers[book_id][str(name)] = str(value)
files: dict[int, list[tuple[str, str, int]]] = defaultdict(list)
for book_id, format, name, size in self._execute(
"SELECT book, format, name, uncompressed_size FROM data ORDER BY id"
):
files[book_id].append((str(format), str(name), int(size or 0)))
pages: dict[int, int] = {}
if self._has_table("books_pages_link"):
pages = {
book_id: int(count)
for book_id, count in self._execute(
"SELECT book, pages FROM books_pages_link WHERE pages > 0"
)
}
books = []
for row in self._execute(
"SELECT id, title, pubdate, series_index, path, uuid, has_cover "
"FROM books ORDER BY id"
):
book_id, title, pubdate, series_index, path, uuid, has_cover = row
directory = self.root / Path(str(path))
in_series = series.get(book_id)
books.append(
CalibreBook(
calibre_id=book_id,
uuid=str(uuid or ""),
title=str(title or ""),
authors=[
unescape_author(name) for name in authors.get(book_id, [])
],
description=strip_html(descriptions.get(book_id)),
published_date=parse_date(pubdate),
series=in_series,
# Meaningless without a series: Calibre defaults the index to 1.0 for
# every book, in a series or not.
series_position=(
format_series_index(series_index) if in_series else None
),
tags=tags.get(book_id, []),
publisher=publishers.get(book_id),
language=next(iter(languages.get(book_id, [])), None),
identifiers=dict(identifiers.get(book_id, {})),
pages=pages.get(book_id),
cover=directory / COVER_FILENAME if has_cover else None,
files=[
CalibreFile(
path=directory / f"{name}.{format.lower()}",
format=format,
size=size,
)
for format, name, size in files.get(book_id, [])
],
)
)
return books
def _grouped(self, statement: str) -> dict[int, list[str]]:
"""Run a `(book, value)` query into one list per book, keeping row order."""
grouped: dict[int, list[str]] = defaultdict(list)
for book_id, value in self._execute(statement):
if value is not None:
grouped[book_id].append(str(value))
return grouped
def _mapped(self, statement: str) -> dict[int, str]:
"""Run a `(book, value)` query into one value per book."""
return {
book_id: str(value)
for book_id, value in self._execute(statement)
if value is not None
}
def unescape_author(name: str) -> str:
"""
Undo Calibre's comma escaping.
`authors.name` stores a comma as `|`, and Calibre reverses it on the way out. Left
alone, `Doyle, Sir Arthur Conan` comes back as `Doyle| Sir Arthur Conan`.
"""
return name.replace("|", ",").strip()
def parse_date(value: object) -> date | None:
"""
Read one of Calibre's timestamps, discarding its "unknown" sentinel.
Args:
value: The stored column, which is text in practice but need not be.
Returns:
The date, or None for a null, an unparseable value, or Calibre's
`0101-01-01` placeholder.
"""
if value is None:
return None
if isinstance(value, datetime):
parsed = value.date()
elif isinstance(value, date):
parsed = value
else:
text = str(value).strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(text).date()
except ValueError:
try:
parsed = date.fromisoformat(text[:10])
except ValueError:
return None
return parsed if parsed.year >= EARLIEST_REAL_YEAR else None
def format_series_index(index: object) -> str | None:
"""
Render `series_index` as the string `Book.series_position` holds.
Calibre stores a REAL, so volume seven arrives as `7.0` which would be stored
verbatim and then compared as a string against the `7` everything else writes.
Fractional positions are real and are kept: `1.5` is a novella between two novels.
"""
if index is None:
return None
try:
number = float(index)
except (TypeError, ValueError):
return None
return str(int(number)) if number.is_integer() else f"{number:g}"
class _TextExtractor(HTMLParser):
"""Flatten markup to text, keeping the line breaks that carried meaning."""
# Tags whose boundaries are a line break rather than nothing at all. Without these
# a description of three paragraphs comes out as one run-on sentence.
_BREAKS = frozenset(
{
"p",
"br",
"div",
"li",
"tr",
"blockquote",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
}
)
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self._parts: list[str] = []
def handle_data(self, data: str) -> None:
self._parts.append(data)
def handle_starttag(self, tag: str, _attrs: object) -> None:
self._break(tag)
def handle_endtag(self, tag: str) -> None:
self._break(tag)
def _break(self, tag: str) -> None:
"""
End the current line, once.
Both halves of `</p><p>` are a boundary, and the open tag of the very first
block is not one at all so emitting a newline per tag turns two paragraphs
into two blank-line-separated ones with a leading gap. One break per boundary
is what the plain text wants.
"""
if tag in self._BREAKS and self._parts and not self._parts[-1].endswith("\n"):
self._parts.append("\n")
@property
def text(self) -> str:
lines = [line.strip() for line in "".join(self._parts).splitlines()]
return "\n".join(line for line in lines if line).strip()
def strip_html(html: str | None) -> str | None:
"""
Turn Calibre's `comments` into plain text.
`comments.text` is always HTML, and `Book.description` is rendered as text so the
tags would show literally on the book page.
Args:
html: The stored comment, if there is one.
Returns:
The text, or None when there was nothing or nothing survived.
"""
if not html:
return None
parser = _TextExtractor()
parser.feed(html)
parser.close()
return parser.text or None
@@ -0,0 +1,239 @@
# src/chitai/services/calibre_import.py
"""
Run a Calibre import in the background and report on it.
The import outlives its request a real library takes minutes to hours so the handler
starts a task and hands back a handle to poll. The work itself is
`BookService.create_many_from_calibre`; everything here is lifecycle: state, progress,
cancellation, and a session of its own.
**This registry lives in memory, and therefore assumes one worker process.** That holds
today: the production `CMD` is `litestar run`, which is single-process, and the consume
watcher is already an in-process singleton with the same constraint. `TODO.md` records
that the production image should move to uvicorn with a worker count the day that
happens, a poll can land on a worker that has never heard of the job, and this needs an
`import_jobs` table instead. It is written down here because nothing else will say so.
"""
from __future__ import annotations
import asyncio
import shutil
import uuid
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from chitai.config import settings
from chitai.database.models import Library
from chitai.services.book import (
BookService,
CalibreImportProgress,
CalibreImportResult,
UnimportedBook,
)
from chitai.services.calibre import CalibreLibrary
class ImportState(StrEnum):
"""Where a job has got to."""
RUNNING = "running"
FINISHED = "finished"
# Stopped on request. What it imported is complete.
CANCELLED = "cancelled"
# The run itself broke — an unreadable catalogue, a missing library. Distinct from
# individual books failing, which `failures` carries and which never stops the run.
FAILED = "failed"
@dataclass
class ImportJob:
"""One import, running or finished."""
id: str
library_id: int
source: str
state: ImportState = ImportState.RUNNING
total: int = 0
processed: int = 0
created: int = 0
skipped: int = 0
failed: int = 0
current_title: str | None = None
failures: list[UnimportedBook] = field(default_factory=list)
# Books imported that look like something the library already had. A count, not the
# records: the duplicates screen is what shows them, and a big import would make
# this the largest thing in the response for no benefit.
possible_duplicates: int = 0
# Why the whole run stopped, when `state` is FAILED.
error: str | None = None
# The directory the job owns and must delete when it ends: what the uploaded archive
# was unpacked into.
workspace: Path | None = None
_stop: bool = False
@property
def finished(self) -> bool:
return self.state is not ImportState.RUNNING
def absorb(self, result: CalibreImportResult) -> None:
"""Take the final counts from a finished run."""
self.total = result.total
self.created = len(result.created)
self.skipped = len(result.skipped)
self.failed = len(result.failed)
self.failures = list(result.failed)
self.possible_duplicates = len(result.possible_duplicates)
self.current_title = None
self.state = ImportState.CANCELLED if result.stopped else ImportState.FINISHED
class CalibreImportRegistry:
"""
The imports this process knows about.
One instance, held at module scope below. Jobs are kept after they finish so the
screen that started one can still read its result; nothing evicts them, which is
fine for a handful of one-time migrations and is the other reason a table would be
the answer if this ever needed to be durable.
"""
def __init__(self) -> None:
self._jobs: dict[str, ImportJob] = {}
# Held only to keep the tasks referenced. Without this the event loop is free to
# garbage-collect a running task mid-import.
self._tasks: set[asyncio.Task] = set()
def get(self, job_id: str) -> ImportJob | None:
return self._jobs.get(job_id)
def running_for(self, library_id: int) -> ImportJob | None:
"""The unfinished import for a library, if it has one."""
return next(
(
job
for job in self._jobs.values()
if job.library_id == library_id and not job.finished
),
None,
)
def cancel(self, job_id: str) -> ImportJob | None:
"""
Ask a job to stop after the book it is on.
Not `task.cancel()`: that would abandon a book mid-copy, leaving files on disk
with no row describing them. The flag is read between books.
"""
job = self._jobs.get(job_id)
if job is not None and not job.finished:
job._stop = True
return job
def start(
self,
library: Library,
source: Path,
workspace: Path,
label: str,
allow_duplicates: bool = False,
) -> ImportJob:
"""
Begin importing, and return the handle to poll.
Args:
library: The library to import into.
source: The unpacked Calibre library's directory.
workspace: A directory the job owns and deletes when it ends what the
uploaded archive was unpacked into. The books have been copied into the
library by then, so nothing is lost with it.
label: What to report as the source. `source` is a temp directory that would
mean nothing to the reader, so this is the archive's own name.
allow_duplicates: Import books whose files are already stored.
Returns:
The job, already running.
"""
job = ImportJob(
id=str(uuid.uuid4()),
library_id=library.id,
source=label,
workspace=workspace,
)
self._jobs[job.id] = job
task = asyncio.create_task(self._run(job, library.id, source, allow_duplicates))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
return job
async def _run(
self, job: ImportJob, library_id: int, source: Path, allow_duplicates: bool
) -> None:
"""
Do the import, recording everything on the job.
Opens a **session of its own**: the request that started this is long gone, and
its session was closed with it.
"""
from chitai.services.library import LibraryService
def on_progress(progress: CalibreImportProgress) -> None:
job.total = progress.total
job.processed = progress.processed
job.current_title = progress.title
if progress.outcome == "created":
job.created += 1
elif progress.outcome == "skipped":
job.skipped += 1
else:
job.failed += 1
catalogue = CalibreLibrary(source)
try:
await catalogue.open()
async with settings.alchemy_config.get_session() as session:
library = await LibraryService(session=session).get(library_id)
result = await BookService(session=session).create_many_from_calibre(
catalogue,
library,
allow_duplicates=allow_duplicates,
on_progress=on_progress,
should_stop=lambda: job._stop,
)
job.absorb(result)
except Exception as exc:
job.state = ImportState.FAILED
job.error = f"{type(exc).__name__}: {exc}"
finally:
await catalogue.close()
# An unpacked archive is a second copy of the whole library, and the books
# worth keeping have been copied into the library proper by now. Removed
# even when the run failed — especially then, since nothing will come back
# for it.
if job.workspace is not None:
await asyncio.to_thread(shutil.rmtree, job.workspace, True)
registry = CalibreImportRegistry()
+8 -4
View File
@@ -4,14 +4,20 @@ from collections import defaultdict
from chitai.config import settings
from chitai.database.models.library import Library
from chitai.services import BookService, LibraryService
from chitai.services.metadata_extractor import Extractor
from chitai.services.utils import create_directory
from watchfiles import awatch, Change
class ConsumeDirectoryWatcher:
"""Watches a directory and batch processes files by their relative path."""
def __init__(self, watch_path: str, library_service: LibraryService, book_service: BookService, batch_delay: float = 3.0):
def __init__(
self,
watch_path: str,
library_service: LibraryService,
book_service: BookService,
batch_delay: float = 3.0,
):
"""
Initialize the file watcher.
@@ -111,7 +117,6 @@ class ConsumeDirectoryWatcher:
async def _process_batch(self, file_paths: set[Path], library_slug: str):
"""Process a batch of files."""
try:
result = await self.book_service.create_many_from_existing_files(
list(file_paths),
self.watch_path / Path(library_slug),
@@ -138,7 +143,6 @@ class ConsumeDirectoryWatcher:
f"already be in the library as: {names}"
)
except Exception as e:
print(f"Error processing batch: {e}")
raise e
+2 -5
View File
@@ -2,7 +2,7 @@
# Standard library
from __future__ import annotations
from typing import Any, AsyncGenerator, Callable, NotRequired, Optional
from typing import Any, AsyncGenerator, Optional
# Third-party libraries
from advanced_alchemy.extensions.litestar.providers import (
@@ -13,7 +13,6 @@ from advanced_alchemy.extensions.litestar.providers import (
)
from advanced_alchemy.exceptions import NotFoundError
from advanced_alchemy.filters import CollectionFilter, StatementFilter
from advanced_alchemy.service import FilterTypeT
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from litestar import Request
@@ -25,7 +24,6 @@ from litestar.di import Provide
from advanced_alchemy.extensions.litestar.providers import create_filter_dependencies
# Local imports
from chitai import schemas as s
from chitai.database import models as m
from chitai.services import (
UserService,
@@ -154,7 +152,6 @@ def create_book_filter_dependencies(
# OVERRIDE: Custom search filter with trigram search
if config.get("search"):
search_fields = config.get("search")
def provide_trigram_search_filter(
search_string: str | None = Parameter(
@@ -370,6 +367,7 @@ def provide_optional_user(request: Request[m.User, Token, Any]) -> m.User | None
return None
async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.User:
return request.user
@@ -381,4 +379,3 @@ async def provide_user_via_kosync_auth(request: Request[m.User, None, Any]) -> m
provide_kosync_device_service = create_service_provider(KosyncDeviceService)
provide_kosync_progress_service = create_service_provider(KosyncProgressService)
@@ -4,9 +4,7 @@ from pathlib import Path
import re
from jinja2 import Template
from advanced_alchemy.service import ModelDictT
import chitai.database.models as m
# TODO: Replace Jinja2 templates with a simpler custom templating system.
# Current Jinja2 implementation is overly complex for basic path generation.
@@ -16,6 +14,43 @@ import chitai.database.models as m
# - Auto-handle missing values (e.g., skip {series}/ if series is empty)
# Characters that cannot survive being interpolated into a path. The forward slash is
# the one that matters: titles legitimately contain it — "AC/DC", "Him/Her" — and the
# template writes the title straight into a directory name, so an unsanitised one
# silently adds a level and puts the book somewhere `book.path` does not describe.
# Calibre strips these from its own on-disk names and keeps the real title in its
# database, which is how an import surfaces them.
_UNSAFE_IN_PATH = re.compile(r"[/\\\x00-\x1f]")
def sanitize_path_component(value: str) -> str:
"""Make one metadata value safe to use as a single directory or file name."""
return _UNSAFE_IN_PATH.sub("_", value).strip()
def _safe_components(book_data: dict) -> dict:
"""
A shallow copy of the metadata with the values a path is built from sanitised.
Only strings are touched, and only the fields the default template interpolates. A
caller's own template can reach anything else in the dict, which is a reason to keep
this conservative rather than to walk the whole structure.
"""
safe = dict(book_data)
for key in ("title", "series", "series_position"):
if isinstance(safe.get(key), str):
safe[key] = sanitize_path_component(safe[key])
if isinstance(safe.get("authors"), list):
safe["authors"] = [
sanitize_path_component(author) if isinstance(author, str) else author
for author in safe["authors"]
]
return safe
default_path_template = """
/{{book.authors[0] if book.authors else 'Unknown'}}
{%- if book.series -%}
@@ -101,7 +136,12 @@ class BookPathGenerator:
"""
result = self.root_path / Path(self.path_template.render(book=book_data))
# Sanitised per value, never on the rendered result: the separators the template
# puts *between* author, series and title are the whole point of it, and only the
# values interpolated into it must not contribute any of their own.
result = self.root_path / Path(
self.path_template.render(book=_safe_components(book_data))
)
# Clean up
result = re.sub(r"/+", "/", str(result)) # Remove consecutive backslashes
@@ -1,4 +1,4 @@
from typing import Any, Optional
from typing import Optional
from dataclasses import dataclass
from advanced_alchemy.filters import (
+9 -5
View File
@@ -3,11 +3,10 @@ from typing import Any, Optional
from dataclasses import dataclass, field
from sqlalchemy.orm import aliased
from sqlalchemy import Select, and_, desc, func, or_, text
from sqlalchemy import Select, and_, func, or_, text
from advanced_alchemy.filters import (
StatementTypeT,
StatementFilter,
CollectionFilter,
ModelT,
)
@@ -135,7 +134,7 @@ class ProgressFilter(StatementFilter):
status_conditions.append(
and_(
or_(
m.BookProgress.completed == False,
m.BookProgress.completed.is_(False),
m.BookProgress.completed.is_(None),
),
m.BookProgress.percentage > 0,
@@ -143,7 +142,7 @@ class ProgressFilter(StatementFilter):
)
if ProgressStatus.READ in self.statuses:
status_conditions.append(m.BookProgress.completed == True)
status_conditions.append(m.BookProgress.completed.is_(True))
if ProgressStatus.UNREAD in self.statuses:
status_conditions.append(m.BookProgress.id.is_(None))
@@ -154,6 +153,7 @@ class ProgressFilter(StatementFilter):
@dataclass
class FileFilter(StatementFilter):
"""Filter books that are related to the given files."""
file_ids: list[int]
def append_to_statement(
@@ -165,17 +165,21 @@ class FileFilter(StatementFilter):
return super().append_to_statement(statement, model, *args, **kwargs)
@dataclass
class FileHashFilter(StatementFilter):
file_hashes: list[str]
def append_to_statement(self, statement: StatementTypeT, model: type[ModelT], *args, **kwargs) -> StatementTypeT:
def append_to_statement(
self, statement: StatementTypeT, model: type[ModelT], *args, **kwargs
) -> StatementTypeT:
statement = statement.where(
m.Book.files.any(m.FileMetadata.hash.in_(self.file_hashes))
)
return super().append_to_statement(statement, model, *args, **kwargs)
@dataclass
class CustomOrderBy(StatementFilter):
"""Order by filter with support for 'random' and 'last accessed' orderings."""
+9 -5
View File
@@ -1,16 +1,21 @@
from __future__ import annotations
import secrets
from chitai.database.models.kosync_device import KosyncDevice
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService, ModelDictT, schema_dump
from advanced_alchemy.service import (
SQLAlchemyAsyncRepositoryService,
ModelDictT,
schema_dump,
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
"""Service for managing KOReader devices."""
API_KEY_LENGTH_IN_BYTES = 8
class Repo(SQLAlchemyAsyncRepository[KosyncDevice]):
""" Repository for KosyncDevice entities."""
"""Repository for KosyncDevice entities."""
model_type = KosyncDevice
@@ -18,18 +23,17 @@ class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
async def create(self, data: ModelDictT[KosyncDevice], **kwargs) -> KosyncDevice:
data = schema_dump(data)
data['api_key'] = self._generate_api_key()
data["api_key"] = self._generate_api_key()
return await super().create(data, **kwargs)
async def get_by_api_key(self, api_key: str) -> KosyncDevice:
return await self.get_one(KosyncDevice.api_key == api_key)
async def regenerate_api_key(self, device_id: int) -> KosyncDevice:
async def regenerate_api_key(self, device_id: int) -> KosyncDevice:
device = await self.get(device_id)
api_key = self._generate_api_key()
device.api_key = api_key
return await self.update(device)
def _generate_api_key(self) -> str:
return secrets.token_hex(self.API_KEY_LENGTH_IN_BYTES)
@@ -16,7 +16,9 @@ class KosyncProgressService(SQLAlchemyAsyncRepositoryService[KosyncProgress]):
repository_type = Repo
async def get_by_document_hash(self, user_id: int, document: str) -> KosyncProgress | None:
async def get_by_document_hash(
self, user_id: int, document: str
) -> KosyncProgress | None:
"""Get progress for a specific document and user."""
return await self.get_one_or_none(
KosyncProgress.user_id == user_id,
+2 -6
View File
@@ -5,7 +5,6 @@ from pathlib import Path
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy import service
from advanced_alchemy.utils.text import slugify
# Local imports
from chitai.database.models.library import Library
@@ -18,6 +17,7 @@ from chitai.services.utils import (
from chitai.config import settings
class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
"""Service for managing libraries and their configuration."""
@@ -48,7 +48,7 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
"""
# TODO: What if a library root_path is a child of an existing library?
if existing := await self.list(Library.root_path == library.root_path):
if await self.list(Library.root_path == library.root_path):
raise ValueError(f"Library already exists at '{library.root_path}'")
if library.read_only:
@@ -58,7 +58,6 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
f"Root directory '{library.root_path}' must exist for a read-only library"
)
# TODO: Verify the read-only library has read permissions
created_library = await super().create(
service.schema_dump(library, exclude_unset=False), **kwargs
@@ -72,9 +71,6 @@ class LibraryService(SQLAlchemyAsyncRepositoryService[Library]):
return created_library
# TODO: Implement library deletion and optional file deletion
async def delete(
self, item_id: int, delete_files: bool = False, **kwargs
@@ -103,8 +103,18 @@ def parse_identifier(value: str, scheme: str | None = None) -> tuple[str, str] |
# Numbered editions, in the forms covers and catalogue records actually use:
# "3rd Edition", "2E", "4e", "8_e", "/6e", "(2nd edition)", "Third International Edition".
_ORDINAL_WORDS = {
"first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5, "sixth": 6,
"seventh": 7, "eighth": 8, "ninth": 9, "tenth": 10, "eleventh": 11, "twelfth": 12,
"first": 1,
"second": 2,
"third": 3,
"fourth": 4,
"fifth": 5,
"sixth": 6,
"seventh": 7,
"eighth": 8,
"ninth": 9,
"tenth": 10,
"eleventh": 11,
"twelfth": 12,
}
# Words that sit between the number and "Edition" and belong to the same statement.
@@ -161,7 +171,9 @@ def split_edition(title: str | None) -> tuple[str | None, int | None]:
stripped = _EDITION.sub(" ", title)
stripped = re.sub(r"\s{2,}", " ", stripped)
stripped = re.sub(r"\s+([,;:.!?])", r"\1", stripped) # "Works : What" → "Works: What"
stripped = re.sub(
r"\s+([,;:.!?])", r"\1", stripped
) # "Works : What" → "Works: What"
stripped = stripped.strip(" ,;:-–—/")
# A title that is only an edition statement is not improved by having none.
@@ -178,7 +190,9 @@ class FileExtractor(Protocol):
) -> dict[str, Any]: ...
@classmethod
async def extract_text(cls, input: UploadFile | BinaryIO | bytes | Path | str) -> str: ...
async def extract_text(
cls, input: UploadFile | BinaryIO | bytes | Path | str
) -> str: ...
class Extractor:
@@ -187,7 +201,9 @@ class Extractor:
format_priorities = {"epub": 1, "pdf": 2}
@classmethod
async def extract_metadata(cls, files: list[UploadFile] | list[Path], root_path: Path | None = None) -> dict[str, Any]:
async def extract_metadata(
cls, files: list[UploadFile] | list[Path], root_path: Path | None = None
) -> dict[str, Any]:
metadata = {}
# Sort based on file priority
@@ -235,7 +251,7 @@ class Extractor:
metadata = FilepathExtractor.extract_metadata(files[0], root_path) | metadata
# format the title
if metadata.get('title', None):
if metadata.get("title", None):
# Before the subtitle split, so the edition cannot be mistaken for one:
# "How Linux Works, 3rd Edition: What Every Superuser Should Know" has to
# lose the edition first for the colon count to mean anything.
@@ -282,7 +298,7 @@ class Extractor:
file_ext = get_file_extension(filename)
if file_ext is None:
return float('inf')
return float("inf")
return Extractor.format_priorities.get(file_ext, float("inf"))
@@ -618,7 +634,9 @@ class FilepathExtractor(FileExtractor):
"""Extracts metadata from the filepath."""
@classmethod
def extract_metadata(cls, input: UploadFile | Path | str, root_path: Path | None = None) -> dict[str, Any]:
def extract_metadata(
cls, input: UploadFile | Path | str, root_path: Path | None = None
) -> dict[str, Any]:
if isinstance(input, UploadFile):
path = Path(input.filename).parent
@@ -633,27 +651,28 @@ class FilepathExtractor(FileExtractor):
if len(parts) == 3:
# Format: Author/Series/Part - Title/filename
metadata['author'] = parts[0]
metadata["author"] = parts[0]
# Extract part number and title from directory name (parts[2])
dirname = parts[2]
match = re.match(r'^([\d.]+)\s*-\s*(.+)$', dirname)
match = re.match(r"^([\d.]+)\s*-\s*(.+)$", dirname)
if match:
metadata['series_position'] = match.group(1) # Keep as string
metadata['series'] = parts[1]
metadata['title'] = match.group(2).strip()
metadata["series_position"] = match.group(1) # Keep as string
metadata["series"] = parts[1]
metadata["title"] = match.group(2).strip()
else:
metadata['series'] = parts[1]
metadata['title'] = path.stem
metadata["series"] = parts[1]
metadata["title"] = path.stem
elif len(parts) == 2:
# Format: Author/Title
metadata['author'] = parts[0]
metadata['title'] = path.stem # Remove extension
metadata["author"] = parts[0]
metadata["title"] = path.stem # Remove extension
return metadata
class FilenameExtractor(FileExtractor):
"""Extracts metadata from the filename."""
+35 -21
View File
@@ -4,43 +4,57 @@ from typing import Any, Literal, Optional, Sequence
from pydantic import BaseModel, Field
from datetime import datetime
class LinkTypes(StrEnum):
NAVIGATION = "application/atom+xml;profile=opds-catalog;kind=navigation"
ACQUISITION = "application/atom+xml;profile=opds-catalog;kind=acquisition"
OPEN_SEARCH = "application/opensearchdescription+xml"
class AcquisitionRelations(StrEnum):
_BASE = "http://opds-spec.org/acquisition"
ACQUISITION = _BASE # A generic relation that indicates that the entry may be retrieved
OPEN_ACCESS = f"{_BASE}/open-access" # Entry may be retrieved without any requirement
BORROW = f"{_BASE}/borrow" # Entry may be retrieved as part of a lending transaction
BUY = f"{_BASE}/buy" # Entry may be retrieved as part of a purchase
SAMPLE = f"{_BASE}/sample" # Subset of the entry may be retrieved
PREVIEW = f"{_BASE}/preview" # Subset of the entry may be retrieved
SUBSCRIBE = f"{_BASE}/subscribe" # Entry my be retrieved as a part of a subscription
ACQUISITION = (
_BASE # A generic relation that indicates that the entry may be retrieved
)
OPEN_ACCESS = (
f"{_BASE}/open-access" # Entry may be retrieved without any requirement
)
BORROW = (
f"{_BASE}/borrow" # Entry may be retrieved as part of a lending transaction
)
BUY = f"{_BASE}/buy" # Entry may be retrieved as part of a purchase
SAMPLE = f"{_BASE}/sample" # Subset of the entry may be retrieved
PREVIEW = f"{_BASE}/preview" # Subset of the entry may be retrieved
SUBSCRIBE = (
f"{_BASE}/subscribe" # Entry my be retrieved as a part of a subscription
)
class NavigationRelations(StrEnum):
_BASE = ""
class LinkRelations(StrEnum):
""" Link types for OPDSv1.2 related resources
"""Link types for OPDSv1.2 related resources
https://specs.opds.io/opds-1.2.html#6-additional-link-relations
"""
_BASE = "http://opds-spec.org"
START = "start" # The OPDS catalog root
SUBSECTION = "subsection" # an OPDS feed not better described by any of the below relations
SHELF = f"{_BASE}/shelf" # Entries acquired by the euser
SUBSCRIPTIONS = f"{_BASE}/subscriptions" # Entries available with users's subscription
NEW = f"{_BASE}/sort/new" # Newest entries
POPULAR = f"{_BASE}/sort/popular" # Most popular entries
FEATURED = f"{_BASE}/featured" # Entries selected for promotion
RECOMMENDED = f"{_BASE}/recommended" # Entries recommended to the specific user
START = "start" # The OPDS catalog root
SUBSECTION = (
"subsection" # an OPDS feed not better described by any of the below relations
)
SHELF = f"{_BASE}/shelf" # Entries acquired by the euser
SUBSCRIPTIONS = (
f"{_BASE}/subscriptions" # Entries available with users's subscription
)
NEW = f"{_BASE}/sort/new" # Newest entries
POPULAR = f"{_BASE}/sort/popular" # Most popular entries
FEATURED = f"{_BASE}/featured" # Entries selected for promotion
RECOMMENDED = f"{_BASE}/recommended" # Entries recommended to the specific user
class Feed(BaseModel): # OPDS Catalog root element
@@ -117,9 +131,8 @@ class AcquisitionFeedLink(Link):
class NavigationFeedLink(Link):
type: str = Field(
default=LinkTypes.NAVIGATION, serialization_alias="@type"
)
type: str = Field(default=LinkTypes.NAVIGATION, serialization_alias="@type")
class Content(BaseModel):
type: Literal["text"] = Field(default="text", serialization_alias="@type")
@@ -170,6 +183,7 @@ class Entry(BaseModel):
data = super().model_dump(**kwargs)
return {"entry": data}
@dataclass
class PaginationResult:
next_link: Optional[Link]
+51 -59
View File
@@ -1,4 +1,3 @@
from typing import Any, Callable, Sequence
from urllib.parse import quote_plus, urlencode
from litestar import Request
@@ -16,9 +15,10 @@ from .models import (
AcquisitionFeedLink,
NavigationFeed,
NavigationFeedLink,
PaginationResult
PaginationResult,
)
def get_opensearch_document(base_url: str = "/opds/search?") -> str:
search = {
"OpenSearchDescription": {
@@ -47,8 +47,13 @@ def convert_book_to_entry(book: m.Book) -> Entry:
link=[
ImageLink(href=f"/{book.cover_image}", type="image/webp"),
*[
# The only place a content type has to be a string: `Link.type` is
# required, and a null fails the whole feed rather than one entry.
# `application/octet-stream` is the registered way to say "opaque
# bytes", which is exactly what an unnamed format is.
AcquisitionLink(
href=f"/opds/download/{book.id}/{file.id}", type=file.content_type
href=f"/opds/download/{book.id}/{file.id}",
type=file.content_type or "application/octet-stream",
)
for file in book.files
],
@@ -115,19 +120,20 @@ def create_navigation_feed(
pretty=True,
)
def create_library_navigation_feed(library: m.Library) -> str:
entries = [
Entry(
id=f"/opds/library/{library.id}/all-books",
title='All Books',
title="All Books",
link=[
AcquisitionFeedLink(
rel="subsection",
href=f"/opds/acquisition?libraries={library.id}&paginated=1&pageSize=50&feed_title=AllBooks&feed_id=/opds/library/{library.id}/all-books",
title="All Books",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/recently-added",
@@ -136,9 +142,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
rel="http://opds-spec.org/sort/new",
href=f"/opds/acquisition?libraries={library.id}&orderBy=created_at&pageSize=50&feed_title=RecentlyAdded&feed_id=/opds/library/{library.id}/recently-added",
title="Recently Added"
title="Recently Added",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/shelves",
@@ -147,9 +153,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
rel="subsection",
href=f"/opds/library/{library.id}/shelves?paginated=1&pageSize=10",
title="Bookshelves"
title="Bookshelves",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/tags",
@@ -158,9 +164,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
rel="subsection",
href=f"/opds/library/{library.id}/tags?paginated=1&pageSize=10",
title="Tags"
title="Tags",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/authors",
@@ -169,9 +175,9 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
rel="subsection",
href=f"/opds/library/{library.id}/authors?paginated=1&pageSize=10",
title="Authors"
title="Authors",
)
]
],
),
Entry(
id=f"/opds/library/{library.id}/publishers",
@@ -180,32 +186,32 @@ def create_library_navigation_feed(library: m.Library) -> str:
NavigationFeedLink(
rel="subsection",
href=f"/opds/library/{library.id}/publishers?paginated=1&pageSize=10",
title="Publishers"
title="Publishers",
)
]
],
),
]
feed = create_navigation_feed(
id=f'/library/{library.id}',
id=f"/library/{library.id}",
title=library.name,
self_url=f'/opds/library/{library.id}',
links=[
],
entries=entries
self_url=f"/opds/library/{library.id}",
links=[],
entries=entries,
)
return feed
def create_collection_navigation_feed(
library: m.Library,
collection_type: str,
items: Sequence[m.BookList | m.Tag | m.Author | m.Publisher | m.BookSeries],
links: list[Link] = list(),
# Title is usually derived from the model's name or title
get_title: Callable[[Any], str] = lambda x: getattr(x, 'title', getattr(x, 'name', str(x)))
get_title: Callable[[Any], str] = lambda x: getattr(
x, "title", getattr(x, "name", str(x))
),
) -> str:
entries = [
@@ -217,52 +223,42 @@ def create_collection_navigation_feed(
href=f"/opds/acquisition?{collection_type}={item.id}&pageSize=50&paginated=1&feed_title={quote_plus(get_title(item))}&feed_id=/opds/library/{library.id}/{collection_type}/{item.id}&search=True",
title=get_title(item),
)
]
) for item in items
],
)
for item in items
]
return create_navigation_feed(
id=f"/opds/library/{library.id}/{collection_type}",
title=collection_type.title(),
self_url=f'/opds/library/{library.id}/{collection_type}',
self_url=f"/opds/library/{library.id}/{collection_type}",
entries=entries,
links=links
links=links,
)
def create_next_paginated_link(
request: Request,
total: int,
current_count: int,
offset: int,
feed_title: str
) -> Link | None:
if total <= current_count + offset:
return None
request: Request, total: int, current_count: int, offset: int, feed_title: str
) -> Link | None:
if total <= current_count + offset:
return None
params = dict(request.query_params)
params['currentPage'] = params.get('currentPage', 1) + 1
params = dict(request.query_params)
params["currentPage"] = params.get("currentPage", 1) + 1
next_url = f"{request.url.path}?{urlencode(list(params.items()), doseq=True)}"
next_url = f"{request.url.path}?{urlencode(list(params.items()), doseq=True)}"
return Link(rel="next", href=next_url, title=feed_title, type=LinkTypes.NAVIGATION)
return Link(
rel="next",
href=next_url,
title=feed_title,
type=LinkTypes.NAVIGATION
)
def create_search_link(
request: Request,
exclude_params: set[str] | None = None
request: Request, exclude_params: set[str] | None = None
) -> Link:
"""Create search link with current filters applied"""
if exclude_params is None:
exclude_params = {'currentPage', 'feed_title', 'feed_id', 'search', 'paginated'}
exclude_params = {"currentPage", "feed_title", "feed_id", "search", "paginated"}
params = {
k: v for k, v in request.query_params.items()
if k not in exclude_params
}
params = {k: v for k, v in request.query_params.items() if k not in exclude_params}
return Link(
rel="search",
@@ -272,7 +268,6 @@ def create_search_link(
)
def create_pagination_links(
request: Request,
total: int,
@@ -291,14 +286,11 @@ def create_pagination_links(
params = dict(request.query_params)
# Calculate next page number
current_page = (offset // limit) + 1
params['currentPage'] = current_page + 1
params["currentPage"] = current_page + 1
next_url = f"{request.url.path}?{urlencode(params, doseq=True)}"
next_link = Link(
rel="next",
href=next_url,
title=f"{feed_title} - Next",
type=link_type
rel="next", href=next_url, title=f"{feed_title} - Next", type=link_type
)
# Create previous link if not on first page
@@ -306,14 +298,14 @@ def create_pagination_links(
params = dict(request.query_params)
# Calculate previous page number
current_page = (offset // limit) + 1
params['currentPage'] = max(1, current_page - 1)
params["currentPage"] = max(1, current_page - 1)
prev_url = f"{request.url.path}?{urlencode(params, doseq=True)}"
prev_link = Link(
rel="previous",
href=prev_url,
title=f"{feed_title} - Previous",
type=link_type
type=link_type,
)
return PaginationResult(next_link, prev_link, offset, total)
+86
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import errno
import hashlib
import mimetypes
from pathlib import Path
import shutil
from typing import BinaryIO
@@ -212,6 +213,7 @@ async def create_directory(dir_path: Path | str) -> None:
await aios.makedirs(dir_path, exist_ok=True)
async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None:
"""
Move a file from source to destination asynchronously.
@@ -238,6 +240,30 @@ async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None:
# all configured separately, so they can easily be separate mounts.
shutil.move(str(src_path), str(dest_path))
async def copy_file(src_path: Path, dest_path: Path, create_dirs: bool = True) -> None:
"""
Copy a file, streaming it rather than reading it whole.
`shutil.copy` would block the event loop for as long as the read takes, which for a
40 MB ebook and a few thousand of them in a row is not acceptable.
Args:
src_path: The file to copy. Left exactly as it is.
dest_path: Where the copy goes.
create_dirs: Create the destination's parent directories first.
"""
if create_dirs and dest_path.parent:
await aios.makedirs(dest_path.parent, exist_ok=True)
async with (
aiofiles.open(src_path, "rb") as source,
aiofiles.open(dest_path, "wb") as destination,
):
while chunk := await source.read(HASH_CHUNK_SIZE):
await destination.write(chunk)
async def move_dir_contents(source_dir: Path | str, target_dir: Path | str) -> None:
"""
Move all contents from source directory to target directory.
@@ -459,6 +485,66 @@ def get_filename(file: Path | str, ext: bool = True) -> str:
return filename.stem
# Content types for the ebook formats `mimetypes` does not know. Python's built-in map
# covers `.epub`, `.pdf`, `.azw3`, `.cbz`, `.cbr` and `.djvu`, and answers `None` for
# every format below — so a library imported from elsewhere, which is where MOBI and
# AZW files come from, stores nothing for them.
#
# That matters downstream because an OPDS acquisition link is how a reader app decides
# whether it can open a file at all.
# What a client sends when it does not know either. Treated as an absence rather than
# an answer: storing it would be indistinguishable from having determined a format, and
# it is the value browsers post for every extension they do not recognise.
_UNSPECIFIED = "application/octet-stream"
EBOOK_CONTENT_TYPES = {
"mobi": "application/x-mobipocket-ebook",
"prc": "application/x-mobipocket-ebook",
"azw": "application/vnd.amazon.ebook",
"fb2": "application/x-fictionbook+xml",
"fbz": "application/x-zip-compressed-fb2",
"lit": "application/x-ms-reader",
"lrf": "application/x-sony-bbeb",
"cb7": "application/x-cb7",
}
def guess_content_type(
file: Path | str | UploadFile, fallback: str | None = None
) -> str | None:
"""
Name a file's format from its extension.
The extension is trusted ahead of anything a client said: a browser posts
`application/octet-stream` for every format it does not recognise, which is most
ebook formats, and that answer is worth less than the `.mobi` on the end of the
name.
Args:
file: The file to name, as a path or an upload.
fallback: What to use when neither table knows the extension a client-supplied
content type, if there is one. `application/octet-stream` is discarded: it
is the client saying it does not know, which is not information.
Returns:
The content type, or None when nothing can name it. Null is the honest answer
and the column is nullable: a caller that structurally needs a string should
substitute one where it needs it, rather than have an invented value stored.
"""
extension = get_file_extension(file)
if known := EBOOK_CONTENT_TYPES.get(extension):
return known
guessed, _ = mimetypes.guess_type(get_filename(file))
if fallback == _UNSPECIFIED:
fallback = None
return guessed or fallback
###############################
# ISBN Validation utilities #
###############################
+282
View File
@@ -0,0 +1,282 @@
"""
Build a Calibre library on disk, for tests to read.
Generated rather than committed as a binary `metadata.db`, because the rows worth
testing are the awkward ones the year-101 pubdate, a `|` in an author name, a REAL
series index, HTML in a comment and those are clearer written out in Python than
hidden inside a blob.
The schema below is Calibre's own, copied from a real library's `sqlite_master`, reduced
to the tables the reader touches. `books_pages_link` is created separately by
`add_pages`: it is recent, and a library made by an older Calibre will not have it.
"""
from __future__ import annotations
import shutil
import sqlite3
from pathlib import Path
SCHEMA = """
CREATE TABLE books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'Unknown' COLLATE NOCASE,
sort TEXT COLLATE NOCASE,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
pubdate TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
series_index REAL NOT NULL DEFAULT 1.0,
author_sort TEXT COLLATE NOCASE,
path TEXT NOT NULL DEFAULT '',
uuid TEXT,
has_cover BOOL DEFAULT 0,
last_modified TIMESTAMP NOT NULL DEFAULT '2000-01-01 00:00:00+00:00'
);
CREATE TABLE authors (
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
sort TEXT COLLATE NOCASE, link TEXT NOT NULL DEFAULT '', UNIQUE(name)
);
CREATE TABLE books_authors_link (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, author INTEGER NOT NULL,
UNIQUE(book, author)
);
CREATE TABLE publishers (
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
sort TEXT COLLATE NOCASE, link TEXT NOT NULL DEFAULT '', UNIQUE(name)
);
CREATE TABLE books_publishers_link (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, publisher INTEGER NOT NULL,
UNIQUE(book)
);
CREATE TABLE tags (
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
link TEXT NOT NULL DEFAULT '', UNIQUE (name)
);
CREATE TABLE books_tags_link (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, tag INTEGER NOT NULL,
UNIQUE(book, tag)
);
CREATE TABLE series (
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
sort TEXT COLLATE NOCASE, link TEXT NOT NULL DEFAULT '', UNIQUE (name)
);
CREATE TABLE books_series_link (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, series INTEGER NOT NULL,
UNIQUE(book)
);
CREATE TABLE languages (
id INTEGER PRIMARY KEY, lang_code TEXT NOT NULL COLLATE NOCASE,
link TEXT NOT NULL DEFAULT '', UNIQUE(lang_code)
);
CREATE TABLE books_languages_link (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, lang_code INTEGER NOT NULL,
item_order INTEGER NOT NULL DEFAULT 0, UNIQUE(book, lang_code)
);
CREATE TABLE comments (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL,
text TEXT NOT NULL COLLATE NOCASE, UNIQUE(book)
);
CREATE TABLE identifiers (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL,
type TEXT NOT NULL DEFAULT 'isbn' COLLATE NOCASE,
val TEXT NOT NULL COLLATE NOCASE, UNIQUE(book, type)
);
CREATE TABLE data (
id INTEGER PRIMARY KEY, book INTEGER NOT NULL,
format TEXT NOT NULL COLLATE NOCASE, uncompressed_size INTEGER NOT NULL,
name TEXT NOT NULL, UNIQUE(book, format)
);
"""
# Calibre's own "no date". Stored, never null, and a valid date — which is exactly why
# it has to be recognised rather than parsed.
UNDEFINED_DATE = "0101-01-01 00:00:00+00:00"
# What a `cover.jpg` that PIL cannot read looks like. Real libraries hold these, from
# an interrupted download or a failed conversion.
CORRUPT_COVER = b"\xff\xd8\xff\xe0 not really a jpeg"
def write_cover(path: Path) -> None:
"""
Write a real, readable JPEG.
Generated with PIL rather than embedded as a hex blob: a hand-rolled JPEG that is
subtly malformed fails inside the import as an unrelated error, which is exactly the
confusion this avoids.
"""
from PIL import Image
Image.new("RGB", (2, 3), (10, 20, 30)).save(path, "JPEG")
class CalibreFixture:
"""A Calibre library being assembled under `root`."""
def __init__(self, root: Path) -> None:
self.root = root
self.root.mkdir(parents=True, exist_ok=True)
self.connection = sqlite3.connect(self.root / "metadata.db")
self.connection.executescript(SCHEMA)
def add_pages_table(self) -> None:
"""Add `books_pages_link`, which only a recent Calibre creates."""
self.connection.executescript(
"""
CREATE TABLE books_pages_link (
book INTEGER PRIMARY KEY,
pages INTEGER DEFAULT 0 NOT NULL,
algorithm INTEGER DEFAULT 0 NOT NULL,
format TEXT DEFAULT '' NOT NULL COLLATE NOCASE,
format_size INTEGER DEFAULT 0 NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
needs_scan INTEGER NOT NULL DEFAULT 0
);
"""
)
def add_book(
self,
book_id: int,
title: str,
*,
authors: list[str] | None = None,
pubdate: str = UNDEFINED_DATE,
series: str | None = None,
series_index: float = 1.0,
tags: list[str] | None = None,
publisher: str | None = None,
languages: list[str] | None = None,
comment: str | None = None,
identifiers: dict[str, str] | None = None,
uuid: str | None = None,
pages: int | None = None,
cover: bool = False,
corrupt_cover: bool = False,
formats: dict[str, Path] | None = None,
directory: str | None = None,
) -> Path:
"""
Add one book, with its files laid out the way Calibre lays them out.
Args:
formats: Format name (`EPUB`) to a real file to copy in. Its on-disk stem is
Calibre's, not the title — that is the point of the `data` table.
directory: Override the `books.path` value, for testing a row whose
directory is not where the convention would put it.
Returns:
The book's directory.
"""
author_names = authors or ["Unknown"]
relative = directory or f"{author_names[0]}/{title} ({book_id})"
book_directory = self.root / relative
book_directory.mkdir(parents=True, exist_ok=True)
self.connection.execute(
"INSERT INTO books (id, title, pubdate, series_index, path, uuid, has_cover) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
book_id,
title,
pubdate,
series_index,
relative,
uuid or f"uuid-{book_id}",
int(cover or corrupt_cover),
),
)
for name in author_names:
self._link("authors", "books_authors_link", "author", book_id, name)
for name in tags or []:
self._link("tags", "books_tags_link", "tag", book_id, name)
if series:
self._link("series", "books_series_link", "series", book_id, series)
if publisher:
self._link(
"publishers", "books_publishers_link", "publisher", book_id, publisher
)
for order, code in enumerate(languages or []):
language_id = self._lookup("languages", "lang_code", code)
self.connection.execute(
"INSERT INTO books_languages_link (book, lang_code, item_order) "
"VALUES (?, ?, ?)",
(book_id, language_id, order),
)
if comment is not None:
self.connection.execute(
"INSERT INTO comments (book, text) VALUES (?, ?)", (book_id, comment)
)
for name, value in (identifiers or {}).items():
self.connection.execute(
"INSERT INTO identifiers (book, type, val) VALUES (?, ?, ?)",
(book_id, name, value),
)
if pages is not None:
self.connection.execute(
"INSERT INTO books_pages_link (book, pages) VALUES (?, ?)",
(book_id, pages),
)
if corrupt_cover:
(book_directory / "cover.jpg").write_bytes(CORRUPT_COVER)
elif cover:
write_cover(book_directory / "cover.jpg")
for format, origin in (formats or {}).items():
# Calibre's on-disk stem: sanitised, truncated, and not the title.
stem = f"{title[:40]} - {author_names[0]}".replace(":", "_")
destination = book_directory / f"{stem}.{format.lower()}"
shutil.copy(origin, destination)
self.connection.execute(
"INSERT INTO data (book, format, uncompressed_size, name) "
"VALUES (?, ?, ?, ?)",
(book_id, format, destination.stat().st_size, stem),
)
return book_directory
def add_missing_format(self, book_id: int, format: str, stem: str) -> None:
"""Record a file in the catalogue without putting one on disk."""
self.connection.execute(
"INSERT INTO data (book, format, uncompressed_size, name) VALUES (?, ?, ?, ?)",
(book_id, format, 1234, stem),
)
def _link(
self, table: str, link_table: str, column: str, book_id: int, name: str
) -> None:
item_id = self._lookup(table, "name", name)
self.connection.execute(
f"INSERT INTO {link_table} (book, {column}) VALUES (?, ?)",
(book_id, item_id),
)
def _lookup(self, table: str, column: str, value: str) -> int:
row = self.connection.execute(
f"SELECT id FROM {table} WHERE {column} = ?", (value,)
).fetchone()
if row:
return int(row[0])
cursor = self.connection.execute(
f"INSERT INTO {table} ({column}) VALUES (?)", (value,)
)
return int(cursor.lastrowid or 0)
def commit(self) -> Path:
"""Finish writing and return the library root."""
self.connection.commit()
self.connection.close()
return self.root
+2 -1
View File
@@ -2,6 +2,8 @@ from pathlib import Path
from uuid import uuid4
import pytest
from chitai import services
from advanced_alchemy.base import UUIDAuditBase
from litestar.testing import AsyncTestClient
from sqlalchemy import text
@@ -155,7 +157,6 @@ async def other_authenticated_client(
# Service fixtures
from chitai import services
@pytest.fixture
+130 -30
View File
@@ -1,6 +1,7 @@
import pytest
from httpx import AsyncClient
from pathlib import Path
from litestar.status_codes import HTTP_400_BAD_REQUEST
@pytest.mark.parametrize(
@@ -218,7 +219,9 @@ async def test_list_books_by_id(populated_authenticated_client: AsyncClient) ->
compare a bigint primary key against them. Nothing called it until a screen needed
to fetch a handful of books by id.
"""
response = await populated_authenticated_client.get("/books?ids=1&ids=2&pageSize=10")
response = await populated_authenticated_client.get(
"/books?ids=1&ids=2&pageSize=10"
)
assert response.status_code == 200
assert sorted(book["id"] for book in response.json()["items"]) == [1, 2]
@@ -228,7 +231,7 @@ async def test_get_book_by_id(populated_authenticated_client: AsyncClient) -> No
"""Test retrieving a specific book by ID."""
# Retrieve the book
response = await populated_authenticated_client.get(f"/books/1")
response = await populated_authenticated_client.get("/books/1")
assert response.status_code == 200
book_data = response.json()
@@ -300,13 +303,13 @@ async def test_delete_book_metadata_only(
# Delete book without deleting files
response = await populated_authenticated_client.delete(
f"/books?book_ids=3&delete_files=false&library_id=1"
"/books?book_ids=3&delete_files=false&library_id=1"
)
assert response.status_code == 204
# Verify book is deleted
get_response = await populated_authenticated_client.get(f"/books/3")
get_response = await populated_authenticated_client.get("/books/3")
assert get_response.status_code == 404
@@ -317,7 +320,7 @@ async def test_delete_book_with_files(
# Delete book and files
response = await populated_authenticated_client.delete(
f"/books?book_ids=3&delete_files=true&library_id=1"
"/books?book_ids=3&delete_files=true&library_id=1"
)
assert response.status_code == 204
@@ -330,7 +333,7 @@ async def test_delete_specific_book_files(
# Delete specific file
response = await populated_authenticated_client.delete(
f"/books/1/files?file_ids=1",
"/books/1/files?file_ids=1",
)
assert response.status_code == 204
@@ -347,7 +350,7 @@ async def test_update_reading_progress(
}
response = await populated_authenticated_client.post(
f"/books/progress/1",
"/books/progress/1",
json=progress_data,
)
@@ -423,7 +426,9 @@ async def test_create_books_groups_formats_within_one_folder(
) -> None:
"""Picking a book's own folder yields one book with both formats, not two books."""
epub = Path("tests/data_files/Metamorphosis - Franz Kafka.epub").read_bytes()
pdf = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf").read_bytes()
pdf = Path(
"tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"
).read_bytes()
files = [
("files", ("Metamorphosis/Metamorphosis.epub", epub, "application/epub+zip")),
@@ -534,7 +539,9 @@ class TestDuplicateHandling:
"files",
(
"war.epub",
Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(),
Path(
"tests/data_files/The Art of War - Sun Tzu.epub"
).read_bytes(),
"application/epub+zip",
),
)
@@ -550,7 +557,9 @@ class TestDuplicateHandling:
"files",
(
"war.epub",
Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(),
Path(
"tests/data_files/The Art of War - Sun Tzu.epub"
).read_bytes(),
"application/epub+zip",
),
)
@@ -736,7 +745,9 @@ class TestDuplicateBooks:
assert len(merged["files"]) == 2
# The folded record is gone, and the group it formed with it.
assert (await authenticated_client.get(f"/books/{fold['id']}")).status_code == 404
assert (
await authenticated_client.get(f"/books/{fold['id']}")
).status_code == 404
groups = await authenticated_client.get("/books/duplicate-books?library_id=1")
assert groups.json() == []
@@ -786,16 +797,6 @@ class TestDuplicateBooks:
# async def test_edit_book_metadata(authenticated_client: AsyncClient) -> None:
# raise NotImplementedError()
import pytest
import aiofiles
from httpx import AsyncClient
from pathlib import Path
from litestar.status_codes import HTTP_400_BAD_REQUEST
import pytest
from httpx import AsyncClient
from datetime import date
class TestMetadataUpdates:
@pytest.mark.parametrize(
@@ -849,8 +850,10 @@ class TestMetadataUpdates:
(
"authors", # Update with new authors
["New Author 1", "New Author 2"],
lambda data: {a["name"] for a in data["authors"]}
== {"New Author 1", "New Author 2"},
lambda data: (
{a["name"] for a in data["authors"]}
== {"New Author 1", "New Author 2"}
),
),
(
"authors", # Clear authors
@@ -860,8 +863,9 @@ class TestMetadataUpdates:
(
"tags", # Update with new tags
["Tag 1", "Tag 2", "Tag 3"],
lambda data: {t["name"] for t in data["tags"]}
== {"Tag 1", "Tag 2", "Tag 3"},
lambda data: (
{t["name"] for t in data["tags"]} == {"Tag 1", "Tag 2", "Tag 3"}
),
),
(
"tags", # Clear tags
@@ -881,8 +885,10 @@ class TestMetadataUpdates:
(
"identifiers", # Update with new identifiers
{"isbn-13": "978-1234567890", "doi": "10.example/id"},
lambda data: data["identifiers"]
== {"isbn-13": "978-1234567890", "doi": "10.example/id"},
lambda data: (
data["identifiers"]
== {"isbn-13": "978-1234567890", "doi": "10.example/id"}
),
),
(
"identifiers", # Clear identifiers
@@ -1053,7 +1059,7 @@ class TestMetadataUpdates:
result = response.json()
assert result[updated_field] == None
assert result[updated_field] is None
@pytest.mark.parametrize(
("updated_field"),
@@ -1215,7 +1221,9 @@ class TestFileManagement:
pytest.skip("Book has no files")
file_id = book_data["files"][0]["id"]
filename = book_data["files"][0].get("path")
# TODO: this test asserts only the 204 and never checks the disk, so the flag it is
# named for is untested. See TODO.md; drop the noqa when the assertion lands.
filename = book_data["files"][0].get("path") # noqa: F841
# Remove file without deleting from filesystem
response = await populated_authenticated_client.delete(
@@ -1240,7 +1248,8 @@ class TestFileManagement:
book_data = add_response.json()
file_id = book_data["files"][-1]["id"]
file_path = book_data["files"][-1].get("path")
# TODO: as above -- the file is never checked for removal from disk.
file_path = book_data["files"][-1].get("path") # noqa: F841
# Remove file with deletion from filesystem
response = await populated_authenticated_client.delete(
@@ -1272,3 +1281,94 @@ class TestFileManagement:
)
# Should succeed (idempotent)
assert response2.status_code == 204
@pytest.mark.asyncio
class TestUnnameableFormats:
"""
A file whose format nothing can name must still round-trip.
`mimetypes.guess_type` answers None for `.mobi`, `.azw`, `.fb2` and `.lit`, which
is most of what a library imported from elsewhere carries alongside its EPUBs.
`FileMetadataRead.content_type` used to be a required string, so such a book was
created and then failed serialisation on its way back out a 500 on a book the
reader can otherwise download.
"""
def upload(self, name: str) -> list[tuple[str, tuple]]:
# `application/octet-stream` is what a browser posts for these, and it is not
# an answer — the extension is what names the format.
return [("files", (name, b"BOOKMOBI\x00 payload", "application/octet-stream"))]
async def test_a_mobi_is_named_from_its_extension(
self, authenticated_client: AsyncClient
) -> None:
response = await authenticated_client.post(
"/books?library_id=1",
files=self.upload("Dune.mobi"),
data={"library_id": 1},
)
assert response.status_code == 201
book = response.json()
assert book["files"][0]["content_type"] == "application/x-mobipocket-ebook"
detail = await authenticated_client.get(f"/books/{book['id']}")
assert detail.status_code == 200
async def test_an_unknown_extension_stores_no_content_type(
self, authenticated_client: AsyncClient
) -> None:
"""Null, not a placeholder — and the book still serialises either way."""
response = await authenticated_client.post(
"/books?library_id=1",
files=self.upload("Notes.xyzzy"),
data={"library_id": 1},
)
assert response.status_code == 201
book = response.json()
assert book["files"][0]["content_type"] is None
detail = await authenticated_client.get(f"/books/{book['id']}")
assert detail.status_code == 200
assert detail.json()["files"][0]["content_type"] is None
async def test_the_file_downloads(self, authenticated_client: AsyncClient) -> None:
"""Litestar supplies its own media type when the row carries none."""
created = await authenticated_client.post(
"/books?library_id=1",
files=self.upload("Notes.xyzzy"),
data={"library_id": 1},
)
book = created.json()
response = await authenticated_client.get(
f"/books/download/{book['id']}/{book['files'][0]['id']}"
)
assert response.status_code == 200
assert response.headers["content-type"] == "application/octet-stream"
async def test_the_opds_feed_survives_a_null_content_type(
self, authenticated_client: AsyncClient
) -> None:
"""
The one place the type has to be a string.
`Link.type` is required, so a null fails the whole feed rather than one entry.
OPDS clients speak Basic, not the JWT the rest of the API uses.
"""
await authenticated_client.post(
"/books?library_id=1",
files=self.upload("Notes.xyzzy"),
data={"library_id": 1},
)
feed = await authenticated_client.get(
"/opds/acquisition?feed_id=all&feed_title=All+Books",
auth=("user1@example.com", "password123"),
)
assert feed.status_code == 200
assert 'type="application/octet-stream"' in feed.text
@@ -1,4 +1,3 @@
import pytest
from httpx import AsyncClient
@@ -200,7 +199,6 @@ async def test_remove_books_from_shelf(
"/books", params={"shelves": shelf_id}
)
assert books_response.status_code == 200
assert books_response.json()["total"] == 2
@@ -0,0 +1,405 @@
"""
Tests for the Calibre import endpoints.
The API takes a zipped library and nothing else a desktop Calibre install is usually
not on the server, and importing from a path the server can already see stays a
server-side operation (`litestar calibre-import`).
"""
import asyncio
import tempfile
import zipfile
from pathlib import Path
import pytest
from httpx import AsyncClient
from chitai.services.calibre_import import registry
from tests.calibre_fixtures import CalibreFixture
EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
OTHER_EPUB = Path("tests/data_files/The Art of War - Sun Tzu.epub")
@pytest.fixture(autouse=True)
def _clear_registry():
"""The registry is a module-level singleton, so it leaks between tests."""
registry._jobs.clear()
yield
registry._jobs.clear()
@pytest.fixture(name="source")
def fx_source(tmp_path: Path) -> Path:
fixture = CalibreFixture(tmp_path / "calibre")
fixture.add_book(
1,
"The Metamorphosis",
authors=["Franz Kafka"],
tags=["Fiction"],
cover=True,
formats={"EPUB": EPUB},
)
fixture.add_book(
2, "The Art of War", authors=["Sun Tzu"], formats={"EPUB": OTHER_EPUB}
)
fixture.add_book(3, "Metadata Only", authors=["Nobody"])
return fixture.commit()
def zip_of(root: Path, into: Path, prefix: str = "") -> Path:
"""Zip a directory the way a file manager would."""
into.mkdir(parents=True, exist_ok=True)
archive = into / "library.zip"
with zipfile.ZipFile(archive, "w") as writing:
for path in sorted(root.rglob("*")):
if path.is_file():
writing.write(path, f"{prefix}{path.relative_to(root)}")
return archive
async def upload(
client: AsyncClient,
archive: Path,
library_id: int = 1,
allow_duplicates: bool = False,
) -> tuple[int, dict]:
response = await client.post(
f"/libraries/{library_id}/imports/calibre/upload",
files=[("archive", (archive.name, archive.read_bytes(), "application/zip"))],
data={"allow_duplicates": str(allow_duplicates).lower()},
)
return response.status_code, response.json()
async def wait_for(client: AsyncClient, job_id: str) -> dict:
"""Poll until the job is no longer running, the way the screen does."""
for _ in range(200):
response = await client.get(f"/libraries/imports/{job_id}")
assert response.status_code == 200
job = response.json()
if job["state"] != "running":
return job
await asyncio.sleep(0.05)
raise AssertionError("the import never finished")
async def test_an_uploaded_library_imports(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
status, job = await upload(
authenticated_client,
zip_of(source, tmp_path / "out", prefix="Calibre Library/"),
)
assert status == 202
assert job["state"] == "running"
assert job["library_id"] == 1
# The archive's name, not the temp directory it was unpacked into.
assert job["source"] == "library.zip"
finished = await wait_for(authenticated_client, job["id"])
assert finished["state"] == "finished"
assert finished["total"] == 3
assert finished["created"] == 2
assert finished["skipped"] == 1
assert finished["failed"] == 0
assert finished["error"] is None
assert finished["current_title"] is None
listed = await authenticated_client.get("/books?library_id=1")
titles = [book["title"] for book in listed.json()["items"]]
assert "The Metamorphosis" in titles
assert "The Art of War" in titles
async def test_a_library_zipped_without_a_wrapping_folder(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
"""Zipping the contents is as common as zipping the folder."""
status, job = await upload(authenticated_client, zip_of(source, tmp_path / "out"))
assert status == 202
finished = await wait_for(authenticated_client, job["id"])
assert finished["created"] == 2
async def test_the_unpacked_copy_is_cleaned_up(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
"""
An unpacked archive is a second copy of the whole library.
The books worth keeping have been copied into the library by the time the job ends,
so nothing is lost with it and nothing will come back for it.
"""
status, job = await upload(authenticated_client, zip_of(source, tmp_path / "out"))
assert status == 202
workspace = registry.get(job["id"]).workspace
assert workspace is not None
await wait_for(authenticated_client, job["id"])
assert not workspace.exists()
async def test_uploading_the_same_library_twice_imports_nothing_new(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
"""Re-running is safe, which is what makes an interrupted import resumable."""
archive = zip_of(source, tmp_path / "out")
for _ in range(2):
_, job = await upload(authenticated_client, archive)
finished = await wait_for(authenticated_client, job["id"])
assert finished["created"] == 0
assert finished["skipped"] == 3
async def test_allow_duplicates_stores_the_files_again(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
"""
The one option the screen offers, and it has to reach the import.
Without it the second pass skips everything, which is the previous test.
"""
archive = zip_of(source, tmp_path / "out")
_, first = await upload(authenticated_client, archive)
await wait_for(authenticated_client, first["id"])
_, second = await upload(authenticated_client, archive, allow_duplicates=True)
finished = await wait_for(authenticated_client, second["id"])
assert finished["created"] == 2
assert finished["skipped"] == 1 # still the book with no files
async def test_two_imports_into_one_library_conflict(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
archive = zip_of(source, tmp_path / "out")
first_status, first = await upload(authenticated_client, archive)
assert first_status == 202
second_status, second = await upload(authenticated_client, archive)
assert second_status == 409
assert second["extra"]["job_id"] == first["id"]
await wait_for(authenticated_client, first["id"])
async def test_a_finished_import_does_not_block_the_next_one(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
archive = zip_of(source, tmp_path / "out")
_, first = await upload(authenticated_client, archive)
await wait_for(authenticated_client, first["id"])
status, second = await upload(authenticated_client, archive)
assert status == 202
await wait_for(authenticated_client, second["id"])
async def test_cancelling_stops_after_the_current_book(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
"""
Cancelling is not aborting: a book abandoned mid-copy would leave files with no row.
Whether this cancels before any book, after one, or after the lot is a race the
catalogue is three books long. What must hold either way is that the state is
terminal and every book it did import is complete.
"""
_, job = await upload(authenticated_client, zip_of(source, tmp_path / "out"))
cancelled = await authenticated_client.delete(f"/libraries/imports/{job['id']}")
assert cancelled.status_code == 200
final = await wait_for(authenticated_client, job["id"])
assert final["state"] in {"cancelled", "finished"}
listed = await authenticated_client.get("/books?library_id=1")
for book in listed.json()["items"]:
assert book["files"]
async def test_failures_are_reported_on_the_job(
authenticated_client: AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""One book failing is recorded and does not stop the run."""
fixture = CalibreFixture(tmp_path / "calibre")
fixture.add_book(1, "Fine", authors=["A"], formats={"EPUB": EPUB})
fixture.add_book(2, "Doomed", authors=["B"], formats={"EPUB": OTHER_EPUB})
root = fixture.commit()
from chitai.services.book import BookService
original = BookService.create
async def fail_on_the_second(self, data, *args, **kwargs):
if isinstance(data, dict) and data.get("title") == "Doomed":
raise RuntimeError("no room on the shelf")
return await original(self, data, *args, **kwargs)
monkeypatch.setattr(BookService, "create", fail_on_the_second)
_, job = await upload(authenticated_client, zip_of(root, tmp_path / "out"))
finished = await wait_for(authenticated_client, job["id"])
assert finished["state"] == "finished"
assert finished["created"] == 1
assert finished["failed"] == 1
assert finished["failures"][0]["calibre_id"] == 2
assert "no room on the shelf" in finished["failures"][0]["reason"]
async def test_a_second_copy_is_counted_as_a_possible_duplicate(
authenticated_client: AsyncClient, tmp_path: Path
) -> None:
"""A count, not the records — the duplicates screen is what shows them."""
padded = tmp_path / "padded.epub"
padded.write_bytes(EPUB.read_bytes() + b"\0" * 64)
fixture = CalibreFixture(tmp_path / "calibre")
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()
_, job = await upload(authenticated_client, zip_of(root, tmp_path / "out"))
finished = await wait_for(authenticated_client, job["id"])
assert finished["created"] == 2
assert finished["possible_duplicates"] == 1
async def test_polling_an_unknown_job(authenticated_client: AsyncClient) -> None:
response = await authenticated_client.get("/libraries/imports/not-a-job")
assert response.status_code == 404
async def test_cancelling_an_unknown_job(authenticated_client: AsyncClient) -> None:
response = await authenticated_client.delete("/libraries/imports/not-a-job")
assert response.status_code == 404
async def test_importing_into_a_library_that_does_not_exist(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
status, _ = await upload(
authenticated_client, zip_of(source, tmp_path / "out"), library_id=999
)
assert status == 404
async def test_importing_into_a_read_only_library(
authenticated_client: AsyncClient, source: Path, tmp_path: Path
) -> None:
"""A read-only library points at a tree Chitai does not own."""
root = tmp_path / "read-only"
root.mkdir()
created = await authenticated_client.post(
"/libraries",
json={"name": "Read Only", "root_path": str(root), "read_only": True},
)
assert created.status_code == 201
status, body = await upload(
authenticated_client,
zip_of(source, tmp_path / "out"),
library_id=created.json()["id"],
)
assert status == 400
assert "read-only" in body["detail"]
async def test_an_import_needs_authentication(
client: AsyncClient, source: Path, tmp_path: Path
) -> None:
status, _ = await upload(client, zip_of(source, tmp_path / "out"))
assert status == 401
class TestRefusedArchives:
"""Everything wrong with an archive is answered now, not as a job that fails later."""
async def test_a_hostile_archive(
self, authenticated_client: AsyncClient, tmp_path: Path
) -> None:
"""Zip slip."""
archive = tmp_path / "hostile.zip"
with zipfile.ZipFile(archive, "w") as writing:
writing.writestr("metadata.db", "not really")
writing.writestr("../../escaped.txt", "gotcha")
status, body = await upload(authenticated_client, archive)
assert status == 400
assert "outside itself" in body["detail"]
assert registry._jobs == {}
async def test_something_that_is_not_a_zip(
self, authenticated_client: AsyncClient, tmp_path: Path
) -> None:
archive = tmp_path / "notes.txt"
archive.write_bytes(b"just some text")
status, body = await upload(authenticated_client, archive)
assert status == 400
assert "not a zip" in body["detail"]
async def test_an_archive_with_no_catalogue(
self, authenticated_client: AsyncClient, tmp_path: Path
) -> None:
archive = tmp_path / "books.zip"
with zipfile.ZipFile(archive, "w") as writing:
writing.writestr("Some Book.epub", "content")
status, body = await upload(authenticated_client, archive)
assert status == 400
assert "no metadata.db" in body["detail"]
async def test_a_refusal_leaves_no_temp_files(
self, authenticated_client: AsyncClient, tmp_path: Path
) -> None:
"""Every refusal path removes the workspace it had already made."""
before = set(Path(tempfile.gettempdir()).glob("tmp*"))
archive = tmp_path / "books.zip"
with zipfile.ZipFile(archive, "w") as writing:
writing.writestr("Some Book.epub", "content")
await upload(authenticated_client, archive)
assert set(Path(tempfile.gettempdir()).glob("tmp*")) == before
+12 -3
View File
@@ -8,7 +8,9 @@ from pathlib import Path
# Known KOReader hashes for test files
TEST_FILES = {
"Moby Dick; Or, The Whale - Herman Melville.epub": {
"path": Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"),
"path": Path(
"tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"
),
"hash": "ceeef909ec65653ba77e1380dff998fb",
"content_type": "application/epub+zip",
},
@@ -59,7 +61,9 @@ async def test_add_file_to_book_generates_correct_hash(
first_book = TEST_FILES["Moby Dick; Or, The Whale - Herman Melville.epub"]
first_content = first_book["path"].read_bytes()
files = [("files", (first_book["path"].name, first_content, first_book["content_type"]))]
files = [
("files", (first_book["path"].name, first_content, first_book["content_type"]))
]
data = {"library_id": "1"}
create_response = await authenticated_client.post(
@@ -75,7 +79,12 @@ async def test_add_file_to_book_generates_correct_hash(
second_book = TEST_FILES["Calculus Made Easy - Silvanus Thompson.pdf"]
second_content = second_book["path"].read_bytes()
add_files = [("data", (second_book["path"].name, second_content, second_book["content_type"]))]
add_files = [
(
"data",
(second_book["path"].name, second_content, second_book["content_type"]),
)
]
add_response = await authenticated_client.post(
f"/books/{book_id}/files",
+1 -1
View File
@@ -40,5 +40,5 @@ async def test_create_library(
assert result["name"] == "Test Library"
assert result["root_path"] == f"{tmp_path}/books"
assert result["path_template"] == "{author}/{title}"
assert result["read_only"] == False
assert result["read_only"] is False
assert result["description"] is None
+392
View File
@@ -0,0 +1,392 @@
import zipfile
from datetime import date
from pathlib import Path
from types import SimpleNamespace
import pytest
from chitai.services.calibre import (
CalibreLibrary,
CalibreLibraryError,
extract_calibre_archive,
format_series_index,
parse_date,
strip_html,
unescape_author,
)
from tests.calibre_fixtures import UNDEFINED_DATE, CalibreFixture
EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
@pytest.fixture(name="library_root")
def fx_library_root(tmp_path: Path) -> Path:
"""A small Calibre library covering the rows that are easy to read wrongly."""
fixture = CalibreFixture(tmp_path / "Calibre Library")
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", "eng"],
comment="<p>A travelling salesman.</p><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},
)
# Volume seven of a series, and no publication date — the two values most likely to
# be carried through verbatim when they should not be.
fixture.add_book(
2,
"Persepolis Rising",
# Calibre escapes the comma and nothing else, so the space after it is stored
# as-is: `Corey, Jr.` is written `Corey| Jr.`.
authors=["Corey| Jr., James S. A."],
series="The Expanse",
series_index=7.0,
formats={"EPUB": EPUB},
)
# A novella between two novels: a fractional position is real and must survive.
fixture.add_book(
3, "Strange Dogs", series="The Expanse", series_index=6.5, formats={"PDF": PDF}
)
# Every row Calibre will happily hold and Chitai cannot use: no files at all.
fixture.add_book(4, "Metadata Only")
# A catalogue row whose file is not on disk.
fixture.add_book(5, "Lost Book")
fixture.add_missing_format(5, "EPUB", "Lost Book - Unknown")
return fixture.commit()
async def test_reads_a_book_whole(library_root: Path) -> None:
async with CalibreLibrary(library_root) as library:
assert await library.count() == 5
books = await library.books()
book = books[0]
assert book.calibre_id == 1
assert book.title == "The Metamorphosis"
assert book.authors == ["Franz Kafka"]
assert book.published_date == date(1915, 10, 15)
assert book.tags == ["Absurdist", "Fiction"]
assert book.publisher == "Kurt Wolff Verlag"
assert book.pages == 201
assert book.uuid == "11111111-2222-3333-4444-555555555555"
# One language, and the one Calibre put first.
assert book.language == "deu"
# Reported as Calibre wrote them: folding `amazon` onto `asin` is the importer's
# job, not the reader's.
assert book.identifiers == {"isbn": "978-0-486-29030-0", "amazon": "B01N5IB20Q"}
assert book.cover is not None
assert book.cover.is_file()
assert len(book.files) == 1
assert book.files[0].format == "EPUB"
assert book.files[0].path.is_file()
# The stem is Calibre's, truncated and sanitised — never the title.
assert book.files[0].path.name != f"{book.title}.epub"
async def test_the_undefined_date_is_not_a_date(library_root: Path) -> None:
"""`0101-01-01` parses fine, which is exactly the problem."""
async with CalibreLibrary(library_root) as library:
books = {book.calibre_id: book for book in await library.books()}
assert books[2].published_date is None
async def test_series_position_is_a_plain_string(library_root: Path) -> None:
async with CalibreLibrary(library_root) as library:
books = {book.calibre_id: book for book in await library.books()}
assert books[2].series == "The Expanse"
assert books[2].series_position == "7"
assert books[3].series_position == "6.5"
# `series_index` defaults to 1.0 for every book, so a position without a series
# would invent a volume one out of nothing.
assert books[1].series is None
assert books[1].series_position is None
async def test_author_commas_are_unescaped(library_root: Path) -> None:
async with CalibreLibrary(library_root) as library:
books = {book.calibre_id: book for book in await library.books()}
assert books[2].authors == ["Corey, Jr., James S. A."]
async def test_comments_come_back_as_text(library_root: Path) -> None:
async with CalibreLibrary(library_root) as library:
books = {book.calibre_id: book for book in await library.books()}
assert books[1].description == "A travelling salesman.\nHe wakes up changed."
assert books[2].description is None
async def test_files_are_reported_whether_or_not_they_exist(library_root: Path) -> None:
"""
The reader says what the catalogue says. Whether the bytes are there is a question
for whoever is about to copy them, which stats them anyway.
"""
async with CalibreLibrary(library_root) as library:
books = {book.calibre_id: book for book in await library.books()}
assert books[4].files == []
assert len(books[5].files) == 1
assert not books[5].files[0].path.exists()
async def test_a_library_without_the_pages_table_still_reads(tmp_path: Path) -> None:
"""`books_pages_link` is recent; an older library simply does not have it."""
fixture = CalibreFixture(tmp_path / "Old Library")
fixture.add_book(1, "Old Book", formats={"EPUB": EPUB})
root = fixture.commit()
async with CalibreLibrary(root) as library:
books = await library.books()
assert books[0].pages is None
async def test_the_original_is_never_opened(library_root: Path) -> None:
"""
The catalogue is copied before it is read, and the copy goes away afterwards.
Calibre may be running and writing; this is what keeps a live library out of it.
"""
before = (library_root / "metadata.db").read_bytes()
library = CalibreLibrary(library_root)
await library.open()
workspace = library._workspace
assert workspace is not None and (workspace / "metadata.db").is_file()
await library.close()
assert not workspace.exists()
assert (library_root / "metadata.db").read_bytes() == before
async def test_closing_twice_is_harmless(library_root: Path) -> None:
library = CalibreLibrary(library_root)
await library.open()
await library.close()
await library.close()
async def test_a_directory_that_is_not_a_calibre_library(tmp_path: Path) -> None:
with pytest.raises(CalibreLibraryError, match="not a Calibre library"):
await CalibreLibrary(tmp_path).open()
@pytest.mark.parametrize(
("stored", "expected"),
[
("2017-12-04 04:00:00+00:00", date(2017, 12, 4)),
("2001-07-02 00:00:00+00:00", date(2001, 7, 2)),
("1999-01-31", date(1999, 1, 31)),
# Calibre's sentinel, and anything else implausibly early.
(UNDEFINED_DATE, None),
("0101-01-01", None),
(None, None),
("", None),
("not a date", None),
],
)
def test_parse_date(stored: str | None, expected: date | None) -> None:
assert parse_date(stored) == expected
@pytest.mark.parametrize(
("index", "expected"),
[
(7.0, "7"),
(1.0, "1"),
(6.5, "6.5"),
(0.0, "0"),
(12.25, "12.25"),
(None, None),
],
)
def test_format_series_index(index: float | None, expected: str | None) -> None:
assert format_series_index(index) == expected
@pytest.mark.parametrize(
("stored", "expected"),
[
("Doyle| Sir Arthur Conan", "Doyle, Sir Arthur Conan"),
("Franz Kafka", "Franz Kafka"),
(" Herman Melville ", "Herman Melville"),
],
)
def test_unescape_author(stored: str, expected: str) -> None:
assert unescape_author(stored) == expected
@pytest.mark.parametrize(
("html", "expected"),
[
("<p>One.</p><p>Two.</p>", "One.\nTwo."),
("Plain text", "Plain text"),
("<div>A<br>B</div>", "A\nB"),
("<p>Caf&eacute; &amp; bar</p>", "Café & bar"),
("<ul><li>One</li><li>Two</li></ul>", "One\nTwo"),
# Markup carrying no text at all is nothing, not an empty description.
("<p></p>", None),
("", None),
(None, None),
],
)
def test_strip_html(html: str | None, expected: str | None) -> None:
assert strip_html(html) == expected
class TestArchives:
"""A Calibre library that arrives zipped rather than as a path."""
def zipped(self, root: Path, into: Path, prefix: str = "") -> Path:
"""Zip a directory the way a file manager would."""
archive = into / "library.zip"
with zipfile.ZipFile(archive, "w") as writing:
for path in sorted(root.rglob("*")):
if path.is_file():
writing.write(path, f"{prefix}{path.relative_to(root)}")
return archive
async def test_a_library_zipped_at_its_root(
self, library_root: Path, tmp_path: Path
) -> None:
archive = self.zipped(library_root, tmp_path)
destination = tmp_path / "unpacked"
destination.mkdir()
catalogue = await extract_calibre_archive(archive, destination)
assert catalogue == destination
async with CalibreLibrary(catalogue) as library:
assert await library.count() == 5
async def test_a_library_zipped_inside_a_folder(
self, library_root: Path, tmp_path: Path
) -> None:
"""Zipping the folder itself is at least as common as zipping its contents."""
archive = self.zipped(library_root, tmp_path, prefix="Calibre Library/")
destination = tmp_path / "unpacked"
destination.mkdir()
catalogue = await extract_calibre_archive(archive, destination)
assert catalogue == destination / "Calibre Library"
async with CalibreLibrary(catalogue) as library:
assert await library.count() == 5
async def test_an_entry_pointing_outside_the_archive_is_refused(
self, tmp_path: Path
) -> None:
"""
Zip slip. `ZipFile.extract` sanitises names itself, but relying on that silently
is how the next person to change the extraction call reintroduces it.
"""
archive = tmp_path / "hostile.zip"
with zipfile.ZipFile(archive, "w") as writing:
writing.writestr("metadata.db", "not really")
writing.writestr("../../escaped.txt", "gotcha")
destination = tmp_path / "unpacked"
destination.mkdir()
with pytest.raises(CalibreLibraryError, match="outside itself"):
await extract_calibre_archive(archive, destination)
assert not (tmp_path.parent / "escaped.txt").exists()
async def test_something_that_is_not_a_zip(self, tmp_path: Path) -> None:
archive = tmp_path / "not.zip"
archive.write_bytes(b"PK-ish, but no")
destination = tmp_path / "unpacked"
destination.mkdir()
with pytest.raises(CalibreLibraryError, match="not a zip file"):
await extract_calibre_archive(archive, destination)
async def test_an_archive_with_no_catalogue(self, tmp_path: Path) -> None:
archive = tmp_path / "books.zip"
with zipfile.ZipFile(archive, "w") as writing:
writing.writestr("Some Book.epub", "content")
destination = tmp_path / "unpacked"
destination.mkdir()
with pytest.raises(CalibreLibraryError, match="no metadata.db"):
await extract_calibre_archive(archive, destination)
# Refused before anything was written.
assert list(destination.iterdir()) == []
async def test_a_catalogue_buried_too_deep(self, tmp_path: Path) -> None:
"""Somebody's whole backup tree is not a library, however much it contains one."""
archive = tmp_path / "backup.zip"
with zipfile.ZipFile(archive, "w") as writing:
writing.writestr("backups/2026/january/library/metadata.db", "not really")
destination = tmp_path / "unpacked"
destination.mkdir()
with pytest.raises(CalibreLibraryError, match="within 3 levels"):
await extract_calibre_archive(archive, destination)
async def test_an_archive_too_big_for_the_disk(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
Checked before writing, not discovered part-way through.
A full disk takes the whole application down, and the size is in the archive
already.
"""
archive = tmp_path / "huge.zip"
with zipfile.ZipFile(archive, "w") as writing:
writing.writestr("metadata.db", "not really")
destination = tmp_path / "unpacked"
destination.mkdir()
monkeypatch.setattr(
"chitai.services.calibre.shutil.disk_usage",
lambda _path: SimpleNamespace(total=1024, used=1024, free=0),
)
with pytest.raises(CalibreLibraryError, match="only"):
await extract_calibre_archive(archive, destination)
assert list(destination.iterdir()) == []
+64
View File
@@ -0,0 +1,64 @@
from pathlib import Path
import pytest
from chitai.services.utils import guess_content_type
@pytest.mark.parametrize(
("filename", "expected"),
[
# What `mimetypes` already knows, kept here so a host with a thin
# /etc/mime.types cannot change the answer without a test noticing.
("Frankenstein.epub", "application/epub+zip"),
("Calculus.pdf", "application/pdf"),
("Persepolis.azw3", "application/vnd.amazon.mobi8-ebook"),
("Watchmen.cbz", "application/vnd.comicbook+zip"),
# What it does not, and where a Calibre library's older formats live.
("Dune.mobi", "application/x-mobipocket-ebook"),
("Dune.prc", "application/x-mobipocket-ebook"),
("Dune.azw", "application/vnd.amazon.ebook"),
("Voyna i Mir.fb2", "application/x-fictionbook+xml"),
("Voyna i Mir.fbz", "application/x-zip-compressed-fb2"),
("Reader.lit", "application/x-ms-reader"),
("Reader.lrf", "application/x-sony-bbeb"),
("Watchmen.cb7", "application/x-cb7"),
# Case is not part of the answer, and Calibre writes formats uppercase.
("Dune.MOBI", "application/x-mobipocket-ebook"),
# Nothing can name these, and None is the answer rather than a placeholder.
("Notes.xyzzy", None),
("README", None),
],
)
def test_guess_content_type(filename: str, expected: str | None) -> None:
assert guess_content_type(Path(filename)) == expected
# A str and a Path must agree, and an upload's `filename` carries its relative
# path, so a name with directories in front of it has to resolve the same way.
assert guess_content_type(filename) == expected
assert guess_content_type(f"Some Author/Some Book/{filename}") == expected
def test_fallback_is_used_only_when_the_extension_says_nothing() -> None:
"""A client's claim fills a gap; it never overrides the name."""
assert (
guess_content_type(Path("Dune.mobi"), fallback="application/pdf")
== "application/x-mobipocket-ebook"
)
assert (
guess_content_type(Path("Notes.xyzzy"), fallback="application/epub+zip")
== "application/epub+zip"
)
def test_an_unspecified_fallback_is_not_an_answer() -> None:
"""
`application/octet-stream` from a client is it saying it does not know.
Browsers post exactly that for every extension they do not recognise, which is most
ebook formats. Storing it would be indistinguishable from having determined a
format, so it is discarded and the column keeps its null.
"""
assert (
guess_content_type(Path("Notes.xyzzy"), fallback="application/octet-stream")
is None
)
@@ -0,0 +1,84 @@
"""Tests for BookPathGenerator."""
from pathlib import Path
from chitai.services.filesystem_library import (
BookPathGenerator,
sanitize_path_component,
)
ROOT = Path("/library")
def path_for(**book) -> Path:
return BookPathGenerator(ROOT).generate_path(book)
def test_author_and_title() -> None:
assert path_for(title="Dune", authors=["Frank Herbert"]) == (
ROOT / "Frank Herbert" / "Dune"
)
def test_a_book_with_no_authors() -> None:
assert path_for(title="Beowulf", authors=[]) == ROOT / "Unknown" / "Beowulf"
def test_a_series_adds_a_level_and_pads_the_position() -> None:
assert (
path_for(
title="Persepolis Rising",
authors=["James S. A. Corey"],
series="The Expanse",
series_position="7",
)
== ROOT / "James S. A. Corey" / "The Expanse" / "07 - Persepolis Rising"
)
def test_a_slash_in_a_title_does_not_add_a_directory() -> None:
"""
The separators in the path come from the template, never from the metadata.
A title with a slash in it "AC/DC", "Him/Her" would otherwise put the book one
level below where `book.path` says it is, which is what deletes, moves and file
lookups all act on. Calibre keeps the real title in its database and strips this
from its own directory names, so an import is where they surface.
"""
generated = path_for(title="Back in Black: AC/DC", authors=["Murray Engleheart"])
assert generated == ROOT / "Murray Engleheart" / "Back in Black: AC_DC"
assert generated.relative_to(ROOT).parts == (
"Murray Engleheart",
"Back in Black: AC_DC",
)
def test_a_slash_in_an_author_or_series_is_handled_too() -> None:
assert path_for(title="Split", authors=["A/B Collective"]) == (
ROOT / "A_B Collective" / "Split"
)
assert (
path_for(
title="Volume One",
authors=["Someone"],
series="Either/Or",
series_position="1",
)
== ROOT / "Someone" / "Either_Or" / "01 - Volume One"
)
def test_control_characters_are_removed() -> None:
assert path_for(title="Line\nBreak", authors=["Someone"]) == (
ROOT / "Someone" / "Line_Break"
)
def test_sanitize_path_component() -> None:
assert sanitize_path_component("AC/DC") == "AC_DC"
assert sanitize_path_component("back\\slash") == "back_slash"
assert sanitize_path_component(" padded ") == "padded"
# Colons and other punctuation are legal in a path and are left alone.
assert sanitize_path_component("Title: Subtitle") == "Title: Subtitle"
+13 -5
View File
@@ -54,7 +54,8 @@ class TestNormalizeTitle:
assert normalize_title("The") == "the"
@pytest.mark.parametrize(
"title", ["Catch 22", "Fahrenheit 451", "Blade Runner 2049", "1984", "Apollo 13"]
"title",
["Catch 22", "Fahrenheit 451", "Blade Runner 2049", "1984", "Apollo 13"],
)
def test_a_number_is_not_an_edition(self, title: str) -> None:
"""Edition stripping keys on the `e`; a bare number is part of the title."""
@@ -163,8 +164,13 @@ class TestNormalizeIdentifier:
def test_uuids_are_refused(self) -> None:
"""Generated per build, so they only re-find what the hash check catches."""
assert normalize_identifier("uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None
assert normalize_identifier("urn:uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None
assert (
normalize_identifier("uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None
)
assert (
normalize_identifier("urn:uuid", "3f2b1c4e-1111-2222-3333-444455556666")
is None
)
def test_other_schemes_keep_their_own_key(self) -> None:
assert normalize_identifier("asin", "B000FC0PDA") == "asin:b000fc0pda"
@@ -187,7 +193,9 @@ class TestIsbnConversion:
assert isbn10_to_isbn13("043942089X") == "9780439420891"
@pytest.mark.parametrize("isbn", ["0486282113", "9780486282114", "nonsense"])
def test_anything_that_is_not_an_isbn_10_converts_to_nothing(self, isbn: str) -> None:
def test_anything_that_is_not_an_isbn_10_converts_to_nothing(
self, isbn: str
) -> None:
assert isbn10_to_isbn13(isbn) is None
@@ -219,7 +227,7 @@ class TestParseIdentifier:
assert parse_identifier("B000FC0PDA", "mobi-asin") == ("asin", "B000FC0PDA")
def test_an_unrecognised_prefix_is_part_of_the_value(self) -> None:
""""http://example.com/book" is not an identifier called "http"."""
""" "http://example.com/book" is not an identifier called "http"."""
assert parse_identifier("http://www.gutenberg.org/5200") == (
"id",
"http://www.gutenberg.org/5200",
+13 -5
View File
@@ -22,7 +22,6 @@ class TestEpubExtractor:
assert metadata["published_date"] == date(year=2001, month=7, day=1)
EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
@@ -31,7 +30,9 @@ PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
class TestIdentifierMerging:
"""A book's formats each contribute identifiers; none of them replaces the rest."""
async def test_every_format_contributes(self, monkeypatch: pytest.MonkeyPatch) -> None:
async def test_every_format_contributes(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
Identifiers are a collection, not a single value.
@@ -47,7 +48,9 @@ class TestIdentifierMerging:
}
async def pdf(_file):
return {"identifiers": {"isbn-13": "9781718500402", "isbn-10": "1593270356"}}
return {
"identifiers": {"isbn-13": "9781718500402", "isbn-10": "1593270356"}
}
monkeypatch.setattr(EpubExtractor, "extract_metadata", epub)
monkeypatch.setattr(PdfExtractor, "extract_metadata", pdf)
@@ -133,7 +136,9 @@ class TestSplitEdition:
),
],
)
def test_editions_are_split_out(self, title: str, stripped: str, edition: int) -> None:
def test_editions_are_split_out(
self, title: str, stripped: str, edition: int
) -> None:
assert split_edition(title) == (stripped, edition)
@pytest.mark.parametrize(
@@ -171,7 +176,10 @@ class TestEditionFromFiles:
"""The PDF fixture calls itself a 2nd edition in its own metadata title."""
metadata = await Extractor.extract_metadata([PDF])
assert metadata["title"] == "The Project Gutenberg eBook #33283: Calculus Made Easy"
assert (
metadata["title"]
== "The Project Gutenberg eBook #33283: Calculus Made Easy"
)
assert metadata["edition"] == 2
@@ -25,7 +25,9 @@ PDF = DATA_FILES / "Calculus Made Easy - Silvanus Thompson.pdf"
def upload(path: Path, name: str | None = None) -> UploadFile:
"""An uploaded file carrying the bytes of one of the test fixtures."""
return UploadFile(
content_type="application/pdf" if path.suffix == ".pdf" else "application/epub+zip",
content_type="application/pdf"
if path.suffix == ".pdf"
else "application/epub+zip",
filename=name or path.name,
file_data=path.read_bytes(),
)
@@ -67,7 +69,7 @@ class TestBookServiceCRUD:
# Add path manually as it won't be generated (not using the create function, but manually inserting into db)
book.path = f"{test_library.root_path}/J.R.R Tolkien/The Fellowship of the Ring"
await aios.makedirs(book.path) # type: ignore[arg-type]
await aios.makedirs(book.path) # type: ignore[arg-type]
books_service.repository.session.add(book)
await books_service.repository.session.commit()
@@ -538,9 +540,7 @@ class TestBookPathCollisions:
assert original.path != forced.path
paths = {
Path(book.path) / book.files[0].path for book in (original, forced)
}
paths = {Path(book.path) / book.files[0].path for book in (original, forced)}
assert len(paths) == 2
assert all(path.is_file() for path in paths)
@@ -581,7 +581,10 @@ class TestBookPathCollisions:
# Renamed onto the first book's author and title.
await books_service.update_book(
second.books[0].id,
{"title": original.title, "authors": [author.name for author in original.authors]},
{
"title": original.title,
"authors": [author.name for author in original.authors],
},
test_library,
)
@@ -743,7 +746,8 @@ class TestDuplicateBooks:
)
matches = await books_service.find_duplicate_books(
{"title": "Building Microservices", "authors": ["Newman, Sam;"]}, test_library
{"title": "Building Microservices", "authors": ["Newman, Sam;"]},
test_library,
)
assert [match.book_id for match in matches] == [stored.id]
@@ -834,16 +838,22 @@ class TestDuplicateBooks:
"series": "Foundation",
}
assert await books_service.find_duplicate_books(
incoming | {"series_position": "2"}, test_library
) == []
assert (
await books_service.find_duplicate_books(
incoming | {"series_position": "2"}, test_library
)
== []
)
# The same volume, written a little differently, still matches.
assert len(
await books_service.find_duplicate_books(
incoming | {"series_position": "1.0"}, test_library
assert (
len(
await books_service.find_duplicate_books(
incoming | {"series_position": "1.0"}, test_library
)
)
) == 1
== 1
)
async def test_a_book_is_not_its_own_duplicate(
self, books_service: BookService, test_library: m.Library
@@ -909,7 +919,10 @@ class TestAuthorNames:
existing row and then collides with it on the unique index.
"""
first = await store_book(
books_service, test_library, title="Building Microservices", authors=["Sam Newman"]
books_service,
test_library,
title="Building Microservices",
authors=["Sam Newman"],
)
second = await store_book(
books_service,
@@ -953,7 +966,9 @@ class TestAuthorNames:
"Franz Kafka.epub" is not a person.
"""
result = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB, "Some Unknown Book - Ada Lovelace.epub")]),
BooksCreateFromFiles(
files=[upload(EPUB, "Some Unknown Book - Ada Lovelace.epub")]
),
test_library,
)
@@ -1014,7 +1029,10 @@ class TestMergeBooks:
) -> None:
"""The survivor keeps its own fields unless the caller says otherwise."""
keep = await store_book(
books_service, test_library, title="Building Microservices", authors=["Sam Newman"]
books_service,
test_library,
title="Building Microservices",
authors=["Sam Newman"],
)
fold = await store_book(
books_service,
@@ -1030,7 +1048,11 @@ class TestMergeBooks:
assert merged.publisher is None
other = await store_book(
books_service, test_library, title="Monolith", authors=["Sam Newman"], edition=3
books_service,
test_library,
title="Monolith",
authors=["Sam Newman"],
edition=3,
)
merged = await books_service.merge_books(
keep.id, [other.id], test_library, metadata={"edition": 3}
@@ -1096,10 +1118,14 @@ class TestMergeBooks:
await books_service.merge_books(keep.id, [fold.id], test_library)
rows = (
await books_service.repository.session.execute(
select(m.BookProgress).where(m.BookProgress.book_id == keep.id)
(
await books_service.repository.session.execute(
select(m.BookProgress).where(m.BookProgress.book_id == keep.id)
)
)
).scalars().all()
.scalars()
.all()
)
assert [row.percentage for row in rows] == [0.6]
@@ -1112,13 +1138,23 @@ class TestMergeBooks:
) -> None:
"""Both books on one shelf must not leave the survivor linked to it twice."""
keep = await store_book(
books_service, test_library, title="A", authors=["X"], tags=["Shared", "Only Keep"]
books_service,
test_library,
title="A",
authors=["X"],
tags=["Shared", "Only Keep"],
)
fold = await store_book(
books_service, test_library, title="B", authors=["X"], tags=["Shared", "Only Fold"]
books_service,
test_library,
title="B",
authors=["X"],
tags=["Shared", "Only Fold"],
)
shelf = m.BookList(title="Later", user_id=test_user.id, library_id=test_library.id)
shelf = m.BookList(
title="Later", user_id=test_user.id, library_id=test_library.id
)
session.add(shelf)
await session.commit()
session.add_all(
@@ -1131,13 +1167,21 @@ class TestMergeBooks:
merged = await books_service.merge_books(keep.id, [fold.id], test_library)
assert sorted(tag.name for tag in merged.tags) == ["Only Fold", "Only Keep", "Shared"]
assert sorted(tag.name for tag in merged.tags) == [
"Only Fold",
"Only Keep",
"Shared",
]
links = (
await books_service.repository.session.execute(
select(m.BookListLink).where(m.BookListLink.book_id == keep.id)
(
await books_service.repository.session.execute(
select(m.BookListLink).where(m.BookListLink.book_id == keep.id)
)
)
).scalars().all()
.scalars()
.all()
)
assert len(links) == 1
async def test_an_identifier_moves_only_under_a_name_the_survivor_lacks(
@@ -2,19 +2,12 @@
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from chitai.services import ShelfService
from chitai.database import models as m
import pytest
from sqlalchemy import select
from chitai.services.bookshelf import ShelfService
from chitai.services import BookService
from chitai.database.models.book_list import BookList, BookListLink
from chitai.database import models as m
@pytest.fixture
@@ -0,0 +1,423 @@
"""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"
@@ -34,8 +34,8 @@ class TestLibraryServiceCRUD:
assert library.name == "Test Library"
assert library.root_path == library_path
assert library.path_template == "{author}/{title}"
assert library.description == None
assert library.read_only == False
assert library.description is None
assert library.read_only is False
# Check if directory was created
assert Path(library.root_path).is_dir()
@@ -56,8 +56,8 @@ class TestLibraryServiceCRUD:
read_only=False,
)
with pytest.raises(PermissionError) as exc_info:
library = await library_service.create(library_data)
with pytest.raises(PermissionError):
await library_service.create(library_data)
# Check if directory was created
assert not Path(library_path).exists()
@@ -86,8 +86,8 @@ class TestLibraryServiceCRUD:
assert library.name == "Test Library"
assert library.root_path == library_path
assert library.path_template == "{author}/{title}"
assert library.description == None
assert library.read_only == True
assert library.description is None
assert library.read_only is True
async def test_create_library_read_only_nonexistent_path(
self, library_service: LibraryService, tmp_path: Path
@@ -138,7 +138,7 @@ class TestLibraryServiceCRUD:
assert library.root_path == "./books"
assert library.path_template == "{author}/{title}"
assert library.description is None
assert library.read_only == False
assert library.read_only is False
# async def test_delete_library_keep_files(
# self, session: AsyncSession, library_service: LibraryService
@@ -21,7 +21,7 @@ class TestUserServiceAuthentication:
# Create a user with a known password
password = "password123"
user = m.User(email=f"test@example.com", password=password)
user = m.User(email="test@example.com", password=password)
session.add(user)
await session.commit()
@@ -52,7 +52,7 @@ class TestUserServiceAuthentication:
# Create user
password = "password123"
user = m.User(email=f"test@example.com", password=password)
user = m.User(email="test@example.com", password=password)
session.add(user)
await session.commit()
@@ -85,7 +85,7 @@ class TestUserServiceCRUD:
) -> None:
"""Test getting user by email."""
user = m.User(email=f"test@example.com", password="password123")
user = m.User(email="test@example.com", password="password123")
session.add(user)
await session.commit()
@@ -102,12 +102,12 @@ class TestUserServiceCRUD:
"""Test creating a new user with a duplicate email."""
# Create first user
user = m.User(email=f"test@example.com", password="password123")
user = m.User(email="test@example.com", password="password123")
session.add(user)
await session.commit()
# Create second user
user = m.User(email=f"test@example.com", password="password12345")
user = m.User(email="test@example.com", password="password12345")
with pytest.raises(IntegrityError) as exc_info:
session.add(user)
+27
View File
@@ -261,6 +261,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-databases", extra = ["postgres"] },
{ name = "ruff" },
]
[package.metadata]
@@ -286,6 +287,7 @@ dev = [
{ name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
{ name = "pytest-databases", extras = ["postgres"], specifier = ">=0.15.0" },
{ name = "ruff", specifier = "==0.15.14" },
]
[[package]]
@@ -1277,6 +1279,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" },
]
[[package]]
name = "ruff"
version = "0.15.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
{ url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
{ url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
{ url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
{ url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
{ url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
{ url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
{ url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
{ url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
]
[[package]]
name = "six"
version = "1.17.0"
+9
View File
@@ -24,6 +24,10 @@ services:
backend:
image: git.jaroszew.ski/patrick/chitai-backend:${CHITAI_VERSION:-latest}
# Only used when the image above is not present locally. Run `docker compose build` to
# build from source instead of pulling a release.
build: ./backend
networks:
@@ -53,6 +57,8 @@ services:
condition: service_healthy
frontend:
image: git.jaroszew.ski/patrick/chitai-frontend:${CHITAI_VERSION:-latest}
build: ./frontend
networks:
@@ -66,6 +72,9 @@ services:
environment:
VITE_BACKEND_API_URL: ${CHITAI_API_URL}
# Must match the URL the browser uses, or SvelteKit rejects form POSTs as cross-origin.
ORIGIN: ${CHITAI_ORIGIN:-http://localhost:3000}
depends_on:
backend:
condition: service_healthy
+407
View File
@@ -0,0 +1,407 @@
# 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.name`
**not** `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
```python
@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 `SELECT`s 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
```bash
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.
+302
View File
@@ -0,0 +1,302 @@
# Building release images in CI
Design for publishing `chitai-backend` and `chitai-frontend` container images from a tagged
release, on Gitea Actions, to the Gitea package registry, for `linux/amd64`.
## What exists today
- `origin` is Gitea 1.27 at `git.jaroszew.ski`. The repo is **public**, with `has_actions` and
`has_packages` both true. There is no `.gitea/` or `.github/` directory and no CI of any kind.
- There are **no tags** in the repository. `backend/pyproject.toml` says `0.1.0`,
`frontend/package.json` says `0.0.1`; nothing reads either.
- `backend/Dockerfile` and `frontend/Dockerfile` are both multi-stage and both already build a
runnable image. `docker-compose.yml` builds them from source with `build: ./backend` and
`build: ./frontend` — there is no `image:` key, so there is nothing for a user to pull.
So the work is not "make the images build" — they build. It is "make an image built on one machine
correct on another", then automate producing one per tag.
## The blocker: the frontend image hardcodes the backend URL
This has to be fixed before publishing an image is meaningful.
`frontend/src/lib/server/config.ts` reads the backend URL through `import.meta.env`:
```ts
export const BACKEND_API_URL = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:8000';
```
Vite replaces `import.meta.env.VITE_*` **at build time**, including in the SSR bundle. The current
build output in `frontend/build/` shows exactly what that produces:
```js
// frontend/build/server/chunks/config-BvKh7uym.js
const BACKEND_API_URL = "http://localhost:8000";
```
`frontend/Dockerfile` sets no `VITE_BACKEND_API_URL` before `pnpm run build`, so the string baked
into any image built from it is `http://localhost:8000`. The `VITE_BACKEND_API_URL: ${CHITAI_API_URL}`
entry under the compose `frontend` service is therefore **dead** — it sets a process environment
variable that nothing reads, in a process whose value was decided at build time. Inside the
container `localhost:8000` is the frontend's own port, not the backend.
Two ways out, and only one of them is right for a published image:
- **Build arg.** `ARG VITE_BACKEND_API_URL` in the build stage. This works, but it makes the image
specific to one deployment's topology — CI would bake `http://backend:8000` and anyone whose
service is named differently gets an image that cannot be repointed. Wrong for a release artifact.
- **Runtime env, recommended.** Read it through SvelteKit's dynamic env, which is `process.env` at
request time:
```ts
import { env } from '$env/dynamic/private';
export const BACKEND_API_URL = env.VITE_BACKEND_API_URL || 'http://localhost:8000';
```
`$lib/server/config.ts` is server-only and its one consumer (`$lib/server/api.ts`) is too, so
`$env/dynamic/private` is available everywhere it is used. The compose entry then starts working
as written, and the same image serves any deployment.
Worth renaming the variable to `CHITAI_API_URL` at the same time — the `VITE_` prefix now means
the opposite of what it does — but that is a follow-up, not a prerequisite. If you do rename it,
the fallback in `.env.prod-example` and the compose `environment:` block move with it.
Same class of problem, same fix window: `frontend/Dockerfile` sets `ENV ORIGIN=http://localhost:3000`.
That one *is* read at runtime by `adapter-node`, so it can be overridden — but nothing overrode it,
and adapter-node rejects cross-origin form POSTs when `ORIGIN` does not match the browser's, so
every deployment behind a real domain 403s on its first login. Now set as
`ORIGIN: ${CHITAI_ORIGIN:-http://localhost:3000}` on the compose `frontend` service, with a
documented `CHITAI_ORIGIN` in `.env.prod-example`.
**Both are done.** The built module now reads `private_env.VITE_BACKEND_API_URL`, and the published
image was verified by running it with `VITE_BACKEND_API_URL` pointed at a throwaway listener: the
container's `GET /access/me` arrived there rather than at `localhost:8000`.
## Toolchain pinning
Two reproducibility gaps that a release pipeline turns from cosmetic into real, because CI builds
from a clean container every time and your laptop does not.
- **pnpm has no pin.** `frontend/package.json` has no `packageManager` field, so `corepack enable`
followed by `pnpm install` resolves to whatever version corepack considers current on the day the
build runs. `pnpm-lock.yaml` is `lockfileVersion: 9.0`, i.e. pnpm 9/10; a future pnpm 11 could
refuse it, and `--frozen-lockfile` would fail a release for reasons unrelated to the release. Add
`"packageManager": "pnpm@<version from your nix shell>"` and let corepack honour it.
- **`pnpm-workspace.yaml` is never copied into the image.** It carries `onlyBuiltDependencies`
(`esbuild`, `@tailwindcss/oxide`), and pnpm 10 blocks postinstall scripts that are not listed
there. Both install stages in `frontend/Dockerfile` copy only `pnpm-lock.yaml` and `package.json`,
so the container install runs under different rules than the local one. Copy it alongside
`package.json` in the `prod-deps` and `build` stages so the two agree.
Both are one-line changes and both belong before the first tag, not after. **Both are done**
`packageManager` is pinned to the dev shell's `pnpm@11.20.0`, and `pnpm-workspace.yaml` is copied
into the `prod-deps` and `build` stages. `docker compose build` was re-run against the change.
## Prerequisites on the Gitea instance
Verify these before writing the workflow; each one fails the job in a way that looks like a bug in
the workflow.
1. **A registered `act_runner`.** Gitea Actions is enabled instance-side but does nothing without a
runner. Register one against the repo or the instance with the label `ubuntu-latest`.
2. **The runner needs a Docker daemon, at an address the job can reach.** `act_runner` in docker
mode runs each job inside a container with no daemon of its own. Either run a `docker:dind`
sidecar next to the runner and set `DOCKER_HOST=tcp://docker:2376` (with TLS certs shared over a
volume), or run the runner in host mode. The dind sidecar is the safer of the two — mounting the
host socket into job containers gives any workflow root on the runner host.
**Reachability is a separate question from availability, and it bit us.** A containerised job
with a working daemon still fails `pytest tests/` with
`Service 'pytest_databases_postgres' failed to come online`: the database container starts
fine, but its published port lands on the daemon's network namespace while the test process
looks for it on the job container's loopback.
Two variables control two different things, and both must be set:
| Variable | Decides | Read by |
| --- | --- | --- |
| `DOCKER_HOST` | which daemon the container is created on | `_service.py` `get_docker_host()` |
| `POSTGRES_HOST` | the address the test then connects to | `docker/postgres.py` `postgres_host`, default `127.0.0.1` |
Setting only `DOCKER_HOST` is not enough — `DockerService.run()` takes `container_host` as a
plain argument defaulting to `127.0.0.1`, and the postgres fixture fills it from
`POSTGRES_HOST`. (There *is* a `DOCKER_HOST`-parsing helper in `pytest_databases`, but it is
`_get_docker_ip()` on the docker-compose class in `docker/__init__.py` and no part of this
path uses it. Do not be misled by it, as I was.)
Until the runner grows its own sidecar, `ci.yml`'s backend job carries a `docker:dind` service
of its own with `DOCKER_HOST: tcp://docker:2375`. That needs the runner to permit
`--privileged`. The same treatment is still owed to `release.yml` — its `quality` job runs the
same tests, and its `smoke` job talks to compose, where the published ports would move to the
dind host too, so `curl http://localhost:3000` becomes `curl http://docker:3000`. Configuring
the runner once (option 1) avoids all of that.
3. **Action resolution.** A bare `uses: docker/build-push-action@v6` does not mean github.com here.
Gitea resolves it against `[actions] DEFAULT_ACTIONS_URL`, which defaults to `https://gitea.com`.
That is fine as it stands — `actions/checkout@v4`, `docker/setup-buildx-action@v3`,
`docker/login-action@v3`, `docker/metadata-action@v5` and `docker/build-push-action@v6` are all
mirrored on gitea.com at those tags (verified 2026-08-17). Worth knowing because it is where an
action reference resolves from if that setting is ever changed; `DEFAULT_ACTIONS_URL = github`
in `app.ini` is the fix if so. Writing full `https://` URLs in `uses:` also works on Gitea but
is invalid syntax on GitHub Actions, so it would cost portability for no gain.
4. **A registry credential.** Gitea auto-injects `secrets.GITEA_TOKEN`, but whether it carries
package-write scope has varied across versions. Try it first; if the push 401s, create a personal
access token with `write:package` and store it as the repo secret `REGISTRY_TOKEN`. The workflow
below reads `REGISTRY_TOKEN` with a fallback to the automatic token.
5. **Package visibility.** Gitea ties package visibility to the owner rather than offering a
per-package toggle. The repo is public, so anonymous pulls should work — confirm with a
`docker pull` from a logged-out machine after the first release, because the README will tell
people to do exactly that.
## The workflow
Written as **`.gitea/workflows/release.yml`**, triggered by tags matching `v*`. Notes on the choices
made there:
- **`github.*` context, not `gitea.*`.** Both exist on Gitea; the third-party actions read the
`GITHUB_*` environment anyway, and using it keeps the file portable if the repo is ever mirrored.
`github.repository` is `patrick/chitai`, so the images are
`git.jaroszew.ski/patrick/chitai-backend` and `…/chitai-frontend`.
- **Tags produced from `v1.2.3`:** `1.2.3`, `1.2`, `1`, and `latest`. `metadata-action`'s default
`latest=auto` flavour adds `latest` only for a non-prerelease semver, so `v0.2.0-rc.1` publishes
`0.2.0-rc.1` and leaves `latest` where it was. That is what makes release-candidate tags a safe
way to exercise the pipeline.
- **`fail-fast: false`** so a frontend failure does not cancel a backend build that was going to
succeed. The two images are independent artifacts; a half-published release is easier to reason
about than a cancelled one.
- **`type=gha` cache** relies on `act_runner`'s built-in cache server exporting
`ACTIONS_CACHE_URL` / `ACTIONS_RUNTIME_TOKEN`. If your runner has caching disabled, buildx warns
and continues, or errors depending on version — just delete the two `cache-*` lines. The
Dockerfiles' `--mount=type=cache` blocks do nothing across ephemeral runners either way, and a
cold build of both images is a few minutes.
## Gating: formatting, linting, types and tests
`ci.yml` runs these on every push and pull request, and `release.yml`'s `quality` job runs the
blocking half again on the tagged commit, so a release cannot publish an image whose tests fail.
My first draft of this document argued for keeping all of it out of the release path, on the
grounds that everything was red. Measuring rather than trusting `TODO.md` showed that was mostly
wrong:
| Check | Before | After the sweep | Gate |
| --- | --- | --- | --- |
| `pytest tests/` | **411 passed** in 3 min | unchanged | blocking |
| `ruff format --check src/` | 27 of 66 files | clean | blocking |
| `ruff check src/` | 110 errors | clean | blocking |
| `prettier --check .` | 48 files | clean | blocking |
| `eslint .` | 1804 → **87 real** | 87 | non-blocking |
| `pnpm check` | 30 errors | 30 | non-blocking |
The test suite was green the whole time, so gating on it costs nothing. `ruff check`'s 110 were
104 unused imports, and **all 64 that survived `--fix` were in `__init__.py`** — deliberate
re-exports, so the fix is `per-file-ignores` in `pyproject.toml`, not deleting them. Of eslint's
1804, **1717 were the vendored pdf.js under `static/`**, which the config never ignored the way it
ignores `src/lib/vendor/`; adding it leaves 87 real ones.
Two things the sweep turned up that are worth remembering:
- **ruff's suggested fix for `E712` would have broken the query.** `services/filters/book.py` had
`m.BookProgress.completed == True`, and ruff proposes `if m.BookProgress.completed:` — Python
truthiness on a SQLAlchemy Column, which does not generate SQL at all. The correct idiom is
`.is_(True)`, which the adjacent line already used. Never run `ruff check --fix --unsafe-fixes`
over query-building code without reading every hunk.
- **Prettier reformatted the generated `schema.d.ts`**, which was 3109 of the sweep's 3946 changed
lines. `openapi-typescript` writes its own style, so that file would have churned by thousands of
lines on every regeneration — and then failed the very gate being added. It is in
`.prettierignore` now.
`eslint` and `pnpm check` run with `continue-on-error: true`. That is an honest weak gate: it
reports without failing, so the counts stay visible and cannot grow silently unnoticed, but nobody
is blocked by 117 pre-existing problems they did not create. Drop the line from each step as its
count reaches zero.
The other thing worth gating on is that the images actually start, which is the failure mode a release
introduces and which nothing else catches. That is the `smoke` job in the same workflow: it copies
`.env.prod-example`, pins `CHITAI_VERSION` to the tag, `docker compose pull`s the images that were
just pushed and brings the stack up with `--wait`, then curls both healthchecks.
This is cheap and it covers the three things that break a release image: migrations failing to apply
from `entrypoint.sh`, the frontend being unable to reach the backend (the baked-URL bug above —
`--wait` fails because the frontend never goes healthy), and a missing runtime env var. It tests the
artifact that was pushed, not a rebuild of it.
`--wait` depends on healthchecks. `db` declares one inline in `docker-compose.yml`, and the backend
image carries a `HEALTHCHECK` in its Dockerfile which compose inherits. The frontend had neither, so
one was added to `frontend/Dockerfile` — in the image rather than in compose, matching the backend
and covering anyone running the image without compose. It probes `/login` with **node's global
`fetch`, not curl**, because `node:24-slim` ships no curl and adding one for a healthcheck is a
package and a CVE surface for nothing. It reads `PORT` so it keeps working if the port is
overridden.
Note the smoke job runs `cp .env.prod-example .env`, which is destructive on a developer machine —
it is safe only because a CI checkout has no `.env`. Don't run those lines locally.
## Making the images consumable
The images are pointless if `docker-compose.yml` still builds from source. Both services now carry
`image:` **and** keep `build:` — compose pulls when the image is absent, and `docker compose build`
still builds from source and tags the result under the same name:
```yaml
backend:
image: git.jaroszew.ski/patrick/chitai-backend:${CHITAI_VERSION:-latest}
build: ./backend
```
`CHITAI_VERSION` and `CHITAI_ORIGIN` are documented in `.env.prod-example`, and the README's install
steps now copy and edit `.env` *before* pulling — the pull cannot resolve `${CHITAI_VERSION}` until
that file exists, so the old ordering would have silently fetched `latest`.
## Versioning
Adopt `vMAJOR.MINOR.PATCH`. The tag is the source of truth; `metadata-action` derives everything
from it and the OCI labels record it. There is no tag today, so the first release is a decision —
`v0.1.0` matches `backend/pyproject.toml` and is the honest number for an app with an open
"any authenticated user can delete any library" item.
Do not automate bumping `pyproject.toml` and `package.json` from the tag. It requires CI to commit
back to the repo, and neither version is read by anything. Either keep a two-line manual checklist
(bump both, commit, tag) or delete `frontend/package.json`'s version field and treat the backend's
as the product version. Bumping by hand is the smaller cost.
## Order of work
- [x] **Runtime config.** `frontend/src/lib/server/config.ts``$env/dynamic/private`; `ORIGIN` on
the compose frontend service; `CHITAI_ORIGIN` in `.env.prod-example`.
- [x] **Toolchain pins.** `packageManager` in `frontend/package.json`; `pnpm-workspace.yaml` copied
in both `frontend/Dockerfile` install stages.
- [x] **The workflow.** `.gitea/workflows/release.yml`, both jobs.
- [x] **Consumable images.** `image:` keys in `docker-compose.yml`, frontend `HEALTHCHECK`,
`CHITAI_VERSION`, README install steps.
- [ ] **Stand up `act_runner` with a working Docker daemon.** Not something the repo can carry.
Prove it with a throwaway workflow running `docker version` before trusting the release one.
- [ ] **Tag `v0.1.0-rc.1`.** Confirm two images land in the registry, that `latest` was *not* moved,
and that the smoke job goes green.
- [ ] **Confirm an anonymous `docker pull`** from a logged-out machine, since the README tells people
to do exactly that.
- [ ] **Tag `v0.1.0`.**
Everything the repository can hold is in place; what remains is instance-side and needs a real tag
to exercise. Verified locally along the way: `docker compose build` succeeds against both changed
Dockerfiles, `docker compose config` resolves the image names, and the built frontend image
honours `VITE_BACKEND_API_URL` at runtime and reports `healthy` to Docker.
## Deferred, deliberately
- **Multi-arch.** amd64 only for now. Adding `linux/arm64` under QEMU roughly triples the job and the
Python and pnpm installs are exactly the workload emulation is worst at; a native arm runner and a
fan-out/merge workflow is the answer if it is ever needed.
- **Building on `main`.** An `edge` tag from every push to `main` is a two-line addition to the same
workflow (`on: push: branches: [main]` plus `type=raw,value=edge,enable={{is_default_branch}}`).
Left out because it doubles registry churn for a deployment target that does not exist yet.
- **PR CI.** Formatting, lint, type and test gates are a separate workflow on a separate trigger and
should not be mixed into the release path — see the gating section.
- **Gitea Releases.** A job creating a release object with generated notes is nice-to-have and can
be added once the tags mean something.
- **Signing / SBOM / provenance.** `build-push-action` can emit provenance and an SBOM, and cosign
can sign the digests. Worth it if the images are ever consumed by anyone other than you; not worth
the key management before then.
- **`backend/.dockerignore` is thin.** It excludes `.git`, `__pycache__` and the ruff cache, but not
`.venv/`, `.postgres/`, `libraries/`, `covers/` or `tests/`. None of those reach the image — the
Dockerfile copies specific paths — and none exist in a CI checkout since they are gitignored, so
this does not affect the pipeline. It does make local builds slower than they need to be.
+5
View File
@@ -8,9 +8,14 @@ bun.lockb
# Ignore artifacts:
build
coverage
.pytest_cache
# Miscellaneous
/static/
# Vendored third-party source, copied verbatim by scripts/vendor-foliate.sh
/src/lib/vendor/
# Generated by openapi-typescript, which has its own formatting. Reformatting it here would
# make every regeneration a several-thousand-line diff.
/src/lib/schema/openapi/schema.d.ts
+19 -9
View File
@@ -157,18 +157,28 @@ but take a baseline first, because neither is clean (see below).
Observed in the current tree — don't mistake these for intentional patterns to copy:
- `pnpm check` is not clean. **Baseline as of 2026-08-13: 30 errors, 1 warning, 8 files**, most of
them in `src/routes/api/[...path]/+server.ts` (see below). Get your own baseline before assuming
an error is yours. `src/lib/schema/openapi/schema.d.ts` was regenerated on that date and is
current; regenerate it again after any backend API change, with
- **`pnpm check` is clean as of 2026-08-17 and CI blocks on it** — 0 errors, 0 warnings. Any error
you see is yours. `src/lib/schema/openapi/schema.d.ts` is
current; regenerate it after any backend API change, with
`pnpm exec openapi-typescript http://localhost:8000/schema/openapi.json -o src/lib/schema/openapi/schema.d.ts`
against a backend running **your** branch — a stale server silently writes a stale file.
- `pnpm lint` does not pass either — 131 pre-existing ESLint errors, 35 of them
`svelte/no-navigation-without-resolve` on plain `href`s, plus ~59 files Prettier would rewrite
(mostly vendored shadcn components). Check the files you touched, not the whole tree.
- **Prettier is clean and CI blocks on it** — run `pnpm format` before finishing. Two things it
must not touch are in `.prettierignore`: the vendored foliate-js, and
`src/lib/schema/openapi/schema.d.ts`, which `openapi-typescript` regenerates in its own style.
- `pnpm exec eslint .` reports **2 errors as of 2026-08-17**, both `svelte/no-at-html-tags` in
`collapsible-text.svelte`. They are a genuine XSS hole, not a lint nit — see `TODO.md`. CI runs
eslint non-blocking (`continue-on-error`) only until that is fixed. `static/pdfjs/` is ignored
alongside `src/lib/vendor/` — it is vendored too, and linting it produced 1717 further errors.
- Two rules are off for `**/*.svelte` in `eslint.config.js` because they predate runes and
misread them: `no-useless-assignment` (every `$bindable()` default) and
`@typescript-eslint/no-unused-expressions` (a bare `book;` declaring an `$effect` dependency).
A leading underscore marks an intentionally unused binding.
- `src/routes/api/[...path]/+server.ts` — all four handlers are annotated `RequestHandler` while the
import of that type is commented out at line 4. It also buffers whole responses with
`arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed.
import of that type is commented out at line 4. It also buffers whole **responses** with
`arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed. **Requests**
are streamed — POST and PATCH pass `request.body` through with `duplex: 'half'` (see `bodyOf`),
because a zipped Calibre library upload cannot be held in this process. The response side is
still buffered; see `TODO.md`.
- `src/app.d.ts``App.Locals["user"]` is typed from `lucide-svelte`'s `User` _icon_ component
rather than the `User` interface in `$lib/server/auth`.
- No CSP, which foliate's README asks for because EPUBs can carry scripts. See `TODO.md` for why it
+7 -2
View File
@@ -11,7 +11,9 @@ WORKDIR /app
FROM base AS prod-deps
COPY pnpm-lock.yaml ./
# pnpm-workspace.yaml carries onlyBuiltDependencies; without it pnpm blocks the postinstall
# scripts it lists, so the install here would not match a local one.
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,target=/pnpm/store \
pnpm fetch --frozen-lockfile
@@ -25,7 +27,7 @@ RUN --mount=type=cache,target=/pnpm/store \
FROM base AS build
COPY pnpm-lock.yaml package.json ./
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
RUN --mount=type=cache,target=/pnpm/store \
pnpm install --frozen-lockfile
@@ -54,4 +56,7 @@ EXPOSE 3000
WORKDIR /app
HEALTHCHECK --interval=10s --timeout=3s --retries=5 --start-period=10s \
CMD ["node", "-e", "fetch(`http://127.0.0.1:${process.env.PORT || 3000}/login`).then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
CMD [ "node", "build" ]
+29 -2
View File
@@ -13,7 +13,9 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
// Vendored third-party source. Tracked, so .gitignore does not cover it.
{ ignores: ['src/lib/vendor/**'] },
// static/pdfjs is the pdf.js viewer, vendored the same way as src/lib/vendor/foliate-js;
// linting it produced 1717 of the 1804 errors this config used to report.
{ ignores: ['src/lib/vendor/**', 'static/pdfjs/**'] },
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
@@ -26,7 +28,18 @@ export default defineConfig(
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
'no-undef': 'off',
// A leading underscore marks a binding that exists to hold a position — a callback
// parameter the signature requires, or the discarded half of a destructure.
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_'
}
]
}
},
{
@@ -35,6 +48,20 @@ export default defineConfig(
files: ['src/lib/components/ui/**'],
rules: { 'svelte/no-navigation-without-resolve': 'off' }
},
{
// no-useless-assignment joined eslint:recommended in ESLint 10, and its flow analysis
// does not model runes: it reads `let { ref = $bindable(null) } = $props()` as a value
// that is never read. Deleting the default, as it suggests, breaks the binding.
//
// no-unused-expressions is off for the same reason: a bare `book;` inside an $effect is
// how a reactive dependency is declared when the read would otherwise be untracked.
// Removing the statement stops the effect re-running.
files: ['**/*.svelte'],
rules: {
'no-useless-assignment': 'off',
'@typescript-eslint/no-unused-expressions': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
+28 -27
View File
@@ -2,6 +2,7 @@
"name": "chitai-web",
"private": true,
"version": "0.0.1",
"packageManager": "pnpm@11.20.0",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -14,42 +15,42 @@
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.4",
"@iconify/svelte": "^5.2.1",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^22.19.15",
"bits-ui": "^2.16.3",
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
"@iconify/svelte": "^5.2.2",
"@internationalized/date": "^3.12.3",
"@lucide/svelte": "^1.31.0",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.2.0",
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"eslint": "^9.39.4",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.15.0",
"globals": "^16.5.0",
"jsrepo": "^2.5.2",
"eslint-plugin-svelte": "^3.23.0",
"globals": "^17.11.0",
"jsrepo": "^3.8.1",
"openapi-typescript": "^7.13.0",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.2",
"prettier": "^3.9.6",
"prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.1",
"svelte": "^5.53.7",
"svelte-check": "^4.4.5",
"tailwind-merge": "^3.5.0",
"svelte": "^5.56.9",
"svelte-check": "^4.7.6",
"tailwind-merge": "^3.6.0",
"tailwind-scrollbar": "^4.0.2",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.1",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0",
"vite": "^8.2.1"
},
"dependencies": {
"construct-style-sheets-polyfill": "^3.1.0",
"mode-watcher": "^1.1.0",
"svelte-sonner": "^1.0.8",
"zod": "^4.3.6"
"svelte-sonner": "^1.2.1",
"zod": "^4.4.3"
}
}
+1592 -2033
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -10,7 +10,8 @@
--radius: 0.625rem;
/* Typography — system stacks, so nothing depends on a CDN or a webfont build. */
--app-font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--app-font-sans:
system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--app-font-serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif;
--app-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
+1 -1
View File
@@ -4,7 +4,7 @@ import { BACKEND_API_URL } from '$lib/server/config';
import { invalid, redirect } from '@sveltejs/kit';
export const login = form(loginSchema, async (data, issue) => {
const { cookies, locals } = getRequestEvent();
const { cookies } = getRequestEvent();
// Create URL-encoded form data
const formData = new URLSearchParams();
+34 -24
View File
@@ -1,5 +1,10 @@
import { command, getRequestEvent, query } from '$app/server';
import { bookshelfCreate, bookshelfQuerySchema, modifyBooksInShelf, type Bookshelf } from '$lib/schema/bookshelf';
import {
bookshelfCreate,
bookshelfQuerySchema,
modifyBooksInShelf,
type Bookshelf
} from '$lib/schema/bookshelf';
import { createQueryParams } from '$lib/utils';
import { error } from '@sveltejs/kit';
@@ -19,46 +24,51 @@ export const listBookshelves = query(bookshelfQuerySchema, async (data) => {
return await response.json();
});
export const addBooksToShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise<Bookshelf> => {
const { locals } = getRequestEvent();
export const addBooksToShelf = command(
modifyBooksInShelf,
async ({ shelf_id, ...data }): Promise<Bookshelf> => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const params = createQueryParams(data);
const response = await locals.api.post(`/shelves/${shelf_id}/books?${params.toString()}`, {});
const response = await locals.api.post(`/shelves/${shelf_id}/books?${params.toString()}`, {});
if (!response.ok) {
const message = await response.text();
error(response.status, message);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
return await response.json();
}
);
return await response.json()
});
export const removeBooksFromShelf = command(
modifyBooksInShelf,
async ({ shelf_id, ...data }): Promise<Bookshelf> => {
const { locals } = getRequestEvent();
export const removeBooksFromShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise<Bookshelf> => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const params = createQueryParams(data);
const response = await locals.api.delete(`/shelves/${shelf_id}/books?${params.toString()}`);
const response = await locals.api.delete(`/shelves/${shelf_id}/books?${params.toString()}`);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
if (!response.ok) {
const message = await response.text();
error(response.status, message);
return await response.json();
}
return await response.json()
});
);
export const createBookshelf = command(bookshelfCreate, async (data) => {
const { locals } = getRequestEvent();
const response = await locals.api.post(`/shelves`, data)
const response = await locals.api.post(`/shelves`, data);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
return await response.json()
})
return await response.json();
});
@@ -0,0 +1,51 @@
import { command, getRequestEvent, query } from '$app/server';
import { error } from '@sveltejs/kit';
import { stringCoerce } from '$lib/schema/common';
import type { CalibreImport } from '$lib/schema/library';
/** The backend's own message for a failed response, rather than its JSON envelope. */
function detailOf(body: string): string {
try {
const parsed = JSON.parse(body);
return typeof parsed?.detail === 'string' ? parsed.detail : body;
} catch {
return body;
}
}
/**
* Where an import has got to.
*
* A `query` rather than a `command` so it can be refreshed, but it is polled on a timer
* rather than cached the answer changes on its own.
*
* Starting an import is deliberately **not** here: the archive goes straight to the
* backend through the proxy, so it never passes through this process. See the import
* screen's `upload`.
*/
export const getCalibreImport = query(stringCoerce, async (jobId): Promise<CalibreImport> => {
const { locals } = getRequestEvent();
const response = await locals.api.get(`/libraries/imports/${jobId}`);
if (!response.ok) error(response.status, detailOf(await response.text()));
return await response.json();
});
/**
* Ask an import to stop after the book it is on.
*
* Not an abort: a book abandoned mid-copy would leave files on disk with no row
* describing them. Whatever it has imported stays imported.
*/
export const cancelCalibreImport = command(stringCoerce, async (jobId): Promise<CalibreImport> => {
const { locals } = getRequestEvent();
const response = await locals.api.delete(`/libraries/imports/${jobId}`);
if (!response.ok) error(response.status, detailOf(await response.text()));
return await response.json();
});
+17 -17
View File
@@ -4,40 +4,40 @@ import { error } from '@sveltejs/kit';
import z from 'zod';
export const listDevices = query(async (): Promise<Device[]> => {
const { locals } = getRequestEvent();
const { locals } = getRequestEvent();
const response = await locals.api.get(`/devices`);
const response = await locals.api.get(`/devices`);
if (!response.ok) error(500, 'An unkown error occurred');
if (!response.ok) error(500, 'An unkown error occurred');
const deviceResult = await response.json();
return deviceResult.items
const deviceResult = await response.json();
return deviceResult.items;
});
export const createDevice = form(createDeviceSchema, async (data): Promise<Device> => {
const { locals } = getRequestEvent();
const { locals } = getRequestEvent();
const response = await locals.api.post(`/devices`, data);
const response = await locals.api.post(`/devices`, data);
if (!response.ok) error(500, 'An unknown error occurred');
if (!response.ok) error(500, 'An unknown error occurred');
return await response.json();
return await response.json();
});
export const regenerateDeviceApiKey = command(z.string(), async (deviceId): Promise<Device> => {
const { locals } = getRequestEvent();
const { locals } = getRequestEvent();
const response = await locals.api.get(`/devices/${deviceId}/regenerate`);
const response = await locals.api.get(`/devices/${deviceId}/regenerate`);
if (!response.ok) error(500, 'An unknown error occurred');
if (!response.ok) error(500, 'An unknown error occurred');
return await response.json();
})
return await response.json();
});
export const deleteDevice = command(z.string(), async (deviceId): Promise<void> => {
const { locals } = getRequestEvent();
const { locals } = getRequestEvent();
const response = await locals.api.delete(`/devices/${deviceId}`);
const response = await locals.api.delete(`/devices/${deviceId}`);
if (!response.ok) error(500, 'An unknown error occurred');
if (!response.ok) error(500, 'An unknown error occurred');
});
+1
View File
@@ -2,6 +2,7 @@ export * from './auth.remote';
export * from './author.remote';
export * from './book.remote';
export * from './bookshelf.remote';
export * from './calibre-import.remote';
export * from './library.remote';
export * from './publisher.remote';
export * from './tag.remote';
+1 -1
View File
@@ -25,6 +25,6 @@ export const createLibrary = form(libraryCreateSchema, async (data) => {
return await response.json();
});
export const deleteLibrary = query('unchecked', async (data) => {
export const deleteLibrary = query('unchecked', async (_data) => {
throw new Error('Not implemented');
});
@@ -3,8 +3,6 @@
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
let {
open = $bindable(),
@@ -13,12 +11,9 @@
}: {
open: boolean;
title?: string;
deleteFn: (deleteFiles: boolean) => {};
deleteFn: (deleteFiles: boolean) => void | Promise<void>;
} = $props();
const selectedState = getBookSelectionState();
const bookOps = getBookOperationsState();
let deleteFiles = $state(false);
</script>
@@ -13,7 +13,7 @@
</script>
<form
{...createDevice.enhance(async ({ form, submit }) => {
{...createDevice.enhance(async ({ element, submit }) => {
try {
await submit();
@@ -24,7 +24,7 @@
const deviceName = createDevice.fields.name.value();
form.reset();
element.reset();
toast.success(`Device '${deviceName}' created`);
onSuccess?.();
} catch (error) {
@@ -39,7 +39,7 @@
<Field.Field>
<Field.Label for="name">Device name</Field.Label>
<Input {...createDevice.fields.name.as('text')} placeholder="e.g. Kindle Paperwhite" />
{#each createDevice.fields.name.issues() ?? [] as issue}
{#each createDevice.fields.name.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -50,10 +50,10 @@
<form
bind:this={formEl}
{...updateBookCover.enhance(async ({ submit, form }) => {
{...updateBookCover.enhance(async ({ submit, element }) => {
try {
await submit();
form.reset();
element.reset();
// Deliberately does not close the dialog. The cover is one panel of a
// larger form now, and closing here would throw away metadata edits
// the reader has not saved yet.
@@ -128,7 +128,7 @@
<Field.Field class="col-span-full">
<Field.Label for="title">Title</Field.Label>
<Input {...updateBookMetadata.fields.title.as('text')} />
{#each updateBookMetadata.fields.title.issues() ?? [] as issue}
{#each updateBookMetadata.fields.title.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -136,7 +136,7 @@
<Field.Field>
<Field.Label for="subtitle">Subtitle</Field.Label>
<Input {...updateBookMetadata.fields.subtitle.as('text')} />
{#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue}
{#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -144,7 +144,7 @@
<Field.Field>
<Field.Label for="edition">Edition</Field.Label>
<Input {...updateBookMetadata.fields.edition.as('number')} />
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -152,7 +152,7 @@
<Field.Field>
<Field.Label for="series">Series</Field.Label>
<Input {...updateBookMetadata.fields.series.as('text')} />
{#each updateBookMetadata.fields.series.issues() ?? [] as issue}
{#each updateBookMetadata.fields.series.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -161,7 +161,7 @@
<Field.Field>
<Field.Label for="series_position">No.</Field.Label>
<Input {...updateBookMetadata.fields.series_position.as('text')} />
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue}
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -169,7 +169,7 @@
<Field.Field>
<Field.Label for="language">Language</Field.Label>
<Input {...updateBookMetadata.fields.language.as('text')} />
{#each updateBookMetadata.fields.language.issues() ?? [] as issue}
{#each updateBookMetadata.fields.language.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -185,10 +185,10 @@
placeholder="Add an author"
class="min-h-10 p-2 text-sm"
/>
{#each authors as author}
{#each authors as author, i (i)}
<input class="hidden" {...updateBookMetadata.fields.authors.as('checkbox', author)} />
{/each}
{#each updateBookMetadata.fields.authors.issues() ?? [] as issue}
{#each updateBookMetadata.fields.authors.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -201,10 +201,10 @@
placeholder="Add a tag"
class="min-h-10 p-2 text-sm"
/>
{#each tags as tag}
{#each tags as tag, i (i)}
<input class="hidden" {...updateBookMetadata.fields.tags.as('checkbox', tag)} />
{/each}
{#each updateBookMetadata.fields.tags.issues() ?? [] as issue}
{#each updateBookMetadata.fields.tags.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -214,7 +214,7 @@
<Field.Field>
<Field.Label for="publisher">Publisher</Field.Label>
<Input {...updateBookMetadata.fields.publisher.as('text')} />
{#each updateBookMetadata.fields.publisher.issues() ?? [] as issue}
{#each updateBookMetadata.fields.publisher.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -223,7 +223,7 @@
<Field.Field>
<Field.Label for="published_date">Published</Field.Label>
<Input {...updateBookMetadata.fields.published_date.as('date')} />
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue}
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -231,7 +231,7 @@
<Field.Field>
<Field.Label for="pages">Pages</Field.Label>
<Input {...updateBookMetadata.fields.pages.as('number')} />
{#each updateBookMetadata.fields.pages.issues() ?? [] as issue}
{#each updateBookMetadata.fields.pages.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -240,7 +240,7 @@
<Field.Field class="col-span-full">
<Field.Label for="identifiers">Identifiers</Field.Label>
<div class="flex flex-col gap-2">
{#each identifierKeys as _, idx}
{#each identifierKeys as _, idx (idx)}
<div class="grid grid-cols-[1fr_1.6fr_auto] gap-2">
<Input bind:value={identifierKeys[idx]} placeholder="ISBN, DOI…" />
<Input bind:value={identifierValues[idx]} placeholder="Value" />
@@ -271,7 +271,7 @@
<Field.Field class="col-span-full">
<Field.Label for="description" class="sr-only">Description</Field.Label>
<Textarea {...updateBookMetadata.fields.description.as('text')} rows={6} />
{#each updateBookMetadata.fields.description.issues() ?? [] as issue}
{#each updateBookMetadata.fields.description.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -23,7 +23,7 @@
</Dialog.Header>
<form
{...createLibrary.enhance(async ({ form, submit }) => {
{...createLibrary.enhance(async ({ element, submit }) => {
try {
await submit();
@@ -35,7 +35,7 @@
const libraryName = createLibrary.fields.name.value();
form.reset();
element.reset();
selectedIcon = 'library';
open = false;
toast.success(`Library '${libraryName}' created.`);
@@ -56,7 +56,7 @@
<Field.Field>
<Field.Label for="name">Library name</Field.Label>
<Input {...createLibrary.fields.name.as('text')} />
{#each createLibrary.fields.name.issues() ?? [] as issue}
{#each createLibrary.fields.name.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -73,7 +73,7 @@
<Field.Field>
<Field.Label for="description">Description</Field.Label>
<Textarea {...createLibrary.fields.description.as('text')} />
{#each createLibrary.fields.description.issues() ?? [] as issue}
{#each createLibrary.fields.description.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -82,7 +82,7 @@
<Field.Field>
<Field.Label for="root_path">Root Path</Field.Label>
<Input {...createLibrary.fields.root_path.as('text')} />
{#each createLibrary.fields.root_path.issues() ?? [] as issue}
{#each createLibrary.fields.root_path.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
<Field.Description class="text-xs">
@@ -94,7 +94,7 @@
<Field.Field>
<Field.Label for="path_template">Path Template</Field.Label>
<Input {...createLibrary.fields.path_template.as('text')} />
{#each createLibrary.fields.path_template.issues() ?? [] as issue}
{#each createLibrary.fields.path_template.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
<Field.Description class="text-xs">
@@ -21,7 +21,7 @@
<Field.Field>
<Field.Label for="email">Email</Field.Label>
<Input {...login.fields.email.as('email')} />
{#each login.fields.email.issues() ?? [] as issue}
{#each login.fields.email.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -30,7 +30,7 @@
<Field.Field>
<Field.Label for="password">Password</Field.Label>
<Input {...login.fields.password.as('password')} />
{#each login.fields.password.issues() ?? [] as issue}
{#each login.fields.password.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -1,32 +1,33 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
let { open = $bindable(), onSubmit }: { open?: boolean, onSubmit: (name: string) => Promise<undefined> } = $props()
let shelfName = $state('')
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
let {
open = $bindable(),
onSubmit
}: { open?: boolean; onSubmit: (name: string) => Promise<undefined> } = $props();
let shelfName = $state('');
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Create bookshelf</Dialog.Title>
</Dialog.Header>
<div class="flex items-center gap-4">
<Label>Name</Label>
<Input bind:value={shelfName}/>
</div>
<div class="flex items-center gap-4">
<Label>Name</Label>
<Input bind:value={shelfName} />
</div>
<!-- Buttons -->
<Dialog.Footer class="ml-auto flex">
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
<Button
onclick={async () => {
await onSubmit(shelfName);
await onSubmit(shelfName);
}}
variant="default">Create</Button
>
@@ -17,7 +17,7 @@
</Card.Header>
<form
{...signup.enhance(async ({ submit, form }) => {
{...signup.enhance(async ({ submit, element }) => {
try {
await submit();
@@ -29,7 +29,7 @@
// Move to login tab on success
// TODO: Fix previous errors showing on login form
form.reset();
element.reset();
toast.success('Successfully registered!');
login.fields.set({ email: '', password: '' });
login.validate();
@@ -47,7 +47,7 @@
<Field.Field>
<Field.Label for="email">Email</Field.Label>
<Input {...signup.fields.email.as('email')} />
{#each signup.fields.email.issues() ?? [] as issue}
{#each signup.fields.email.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -56,7 +56,7 @@
<Field.Field>
<Field.Label for="password">Password</Field.Label>
<Input {...signup.fields.password.as('password')} />
{#each signup.fields.password.issues() ?? [] as issue}
{#each signup.fields.password.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -65,7 +65,7 @@
<Field.Field>
<Field.Label for="confirmPassword">Confirm Password</Field.Label>
<Input {...signup.fields.confirmPassword.as('password')} />
{#each signup.fields.confirmPassword.issues() ?? [] as issue}
{#each signup.fields.confirmPassword.issues() ?? [] as issue, i (i)}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
@@ -28,7 +28,9 @@
// directly in the markup keeps it static.
const header = $derived({
title: 'chitai',
url: resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(libraryState.activeLibrary!.id) })
url: resolve('/(root)/(library)/library/[libraryId]', {
libraryId: String(libraryState.activeLibrary!.id)
})
});
</script>
@@ -2,8 +2,8 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { Badge } from "$lib/components/ui/badge/index.js";
import { getLibraryState, LibraryState } from '$lib/state/library.svelte';
import { Badge } from '$lib/components/ui/badge/index.js';
import { getLibraryState } from '$lib/state/library.svelte';
import { LIBRARY_ICONS } from '$lib/components/ui/icon-picker/index.js';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import PlusIcon from '@lucide/svelte/icons/plus';
@@ -12,7 +12,9 @@
const libraryState = getLibraryState();
const sidebar = useSidebar();
const activeIcon = $derived(LIBRARY_ICONS[libraryState.activeLibrary?.icon ?? 'library'] ?? LIBRARY_ICONS['library']);
const activeIcon = $derived(
LIBRARY_ICONS[libraryState.activeLibrary?.icon ?? 'library'] ?? LIBRARY_ICONS['library']
);
</script>
<Sidebar.Menu>
@@ -35,7 +37,7 @@
<span class="ml-1 truncate font-semibold">
{libraryState.activeLibrary!.name}
</span>
<span class="ml-1 truncate font-mono text-xs tabular-nums text-muted-foreground">
<span class="ml-1 truncate font-mono text-xs text-muted-foreground tabular-nums">
{libraryState.activeLibrary!.total ?? 0} books
</span>
</div>
@@ -51,15 +53,15 @@
>
<DropdownMenu.Label class="text-xs text-muted-foreground">Libraries</DropdownMenu.Label>
{#each libraryState.libraries as library (library.name)}
{@const LibraryIcon = (LIBRARY_ICONS[library.icon ?? 'library'] ?? LIBRARY_ICONS['library']).component}
{@const LibraryIcon = (
LIBRARY_ICONS[library.icon ?? 'library'] ?? LIBRARY_ICONS['library']
).component}
<DropdownMenu.Item onSelect={() => libraryState.setActive(library.id)} class="gap-2 p-2">
<div class="flex size-6 items-center justify-center rounded-md border">
<LibraryIcon class="size-3.5 shrink-0" />
</div>
{library.name}
<Badge
variant="outline"
class="font-semibold ml-auto">
<Badge variant="outline" class="ml-auto font-semibold">
{library.total ?? 0}
</Badge>
</DropdownMenu.Item>
@@ -53,9 +53,9 @@
}
}
function handleClick(e: any) {
function handleClick(e: Event & { currentTarget: HTMLElement }) {
open = true;
e.target.blur();
e.currentTarget.blur();
}
</script>
@@ -93,7 +93,7 @@
</div>
{:else}
<Command.List class="max-h-[600px]">
{#if searchResult?.items.length > 0}
{#if (searchResult?.items?.length ?? 0) > 0}
<Command.Group heading="Books">
{#each searchResult?.items as book (book.id)}
<Command.Item
@@ -121,7 +121,7 @@
{#if book.authors.length > 0}
<span class="line-clamp-1 w-full text-sm text-muted-foreground">
by
{#each book.authors as author}
{#each book.authors as author (author.id)}
<a
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary!.id)
@@ -132,7 +132,7 @@
</span>
{/if}
<div class="mt-1 ml-[-1.5] flex">
{#each book.tags as tag}
{#each book.tags as tag (tag.id)}
<Badge class="scale-75">{tag.name}</Badge>
{/each}
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -14,10 +14,10 @@
bind:this={ref}
data-slot="avatar-badge"
class={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
bind:ref
data-slot="avatar-fallback"
class={cn(
"rounded-full bg-muted text-muted-foreground flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
className
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="avatar-group-count"
class={cn(
"size-8 rounded-full bg-muted text-sm text-muted-foreground group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex shrink-0 items-center justify-center ring-2 ring-background",
'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
className
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="avatar-group"
class={cn(
"cn-avatar-group group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
'cn-avatar-group group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
className
)}
{...restProps}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,6 +12,6 @@
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn("rounded-full aspect-square size-full object-cover", className)}
class={cn('aspect-square size-full rounded-full object-cover', className)}
{...restProps}
/>
@@ -1,15 +1,15 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
loadingStatus = $bindable("loading"),
size = "default",
loadingStatus = $bindable('loading'),
size = 'default',
class: className,
...restProps
}: AvatarPrimitive.RootProps & {
size?: "default" | "sm" | "lg";
size?: 'default' | 'sm' | 'lg';
} = $props();
</script>
@@ -19,7 +19,7 @@
data-slot="avatar"
data-size={size}
class={cn(
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten",
'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
className
)}
{...restProps}
@@ -1,9 +1,9 @@
import Badge from "./avatar-badge.svelte";
import Fallback from "./avatar-fallback.svelte";
import GroupCount from "./avatar-group-count.svelte";
import Group from "./avatar-group.svelte";
import Image from "./avatar-image.svelte";
import Root from "./avatar.svelte";
import Badge from './avatar-badge.svelte';
import Fallback from './avatar-fallback.svelte';
import GroupCount from './avatar-group-count.svelte';
import Group from './avatar-group.svelte';
import Image from './avatar-image.svelte';
import Root from './avatar.svelte';
export {
Root,
@@ -18,5 +18,5 @@ export {
Fallback as AvatarFallback,
Badge as AvatarBadge,
Group as AvatarGroup,
GroupCount as AvatarGroupCount,
GroupCount as AvatarGroupCount
};
@@ -15,8 +15,7 @@
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
accent:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90'
accent: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',

Some files were not shown because too many files have changed in this diff Show More