Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412a73cedf | ||
|
|
5176dfcb77 | ||
|
|
b85ba92380 | ||
|
|
e9fe18266d | ||
|
|
5ec5a4d334 | ||
|
|
0c25f63600 | ||
|
|
64fed8671e | ||
|
|
3a29294f96 | ||
|
|
428168c07a | ||
|
|
fbe8a8bf21 | ||
|
|
157cc60e91 | ||
|
|
930b222b28 | ||
|
|
01cdd95bc7 | ||
|
|
e898069b03 | ||
|
|
b33f57d942 | ||
|
|
54043a97d2 | ||
|
|
45b03764d2 | ||
|
|
b70ed5cb51 | ||
|
|
0220f2d970 | ||
|
|
55e00ba960 | ||
|
|
85367daf7e |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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,3 +1,4 @@
|
||||
.env
|
||||
.postgres/
|
||||
.venv
|
||||
tmp/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!"""
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
# src/chitai/controllers/access.py
|
||||
|
||||
# Standard library
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated
|
||||
import logging
|
||||
|
||||
# Third-party libraries
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
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]:
|
||||
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
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -22,7 +29,6 @@ from litestar.params import Dependency
|
||||
from advanced_alchemy.service import FilterTypeT
|
||||
|
||||
|
||||
|
||||
class OpdsController(Controller):
|
||||
"""Controller for managing OPDS endpoints"""
|
||||
|
||||
@@ -76,10 +82,11 @@ 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(
|
||||
@@ -94,13 +101,10 @@ class OpdsController(Controller):
|
||||
title="Search books",
|
||||
)
|
||||
],
|
||||
entries=entries
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
return Response(
|
||||
feed,
|
||||
media_type="application/xml"
|
||||
)
|
||||
return Response(feed, media_type="application/xml")
|
||||
|
||||
@get("/acquisition")
|
||||
async def get_acquisition_feed(
|
||||
@@ -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(
|
||||
"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(
|
||||
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(
|
||||
),
|
||||
),
|
||||
"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(
|
||||
uniquify=True,
|
||||
),
|
||||
),
|
||||
"publishers": (
|
||||
publisher_service,
|
||||
lambda: publisher_service.list_and_count(
|
||||
*filters,
|
||||
PublisherLibraryFilter(libraries=[library.id]),
|
||||
OrderBy("name", "asc"),
|
||||
uniquify=True
|
||||
))
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from chitai.services.user import UserService
|
||||
from litestar.middleware import (
|
||||
AbstractAuthenticationMiddleware,
|
||||
AuthenticationResult,
|
||||
DefineMiddleware
|
||||
DefineMiddleware,
|
||||
)
|
||||
from litestar.connection import ASGIConnection
|
||||
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
|
||||
@@ -11,7 +11,9 @@ from chitai.config import settings
|
||||
|
||||
|
||||
class BasicAuthenticationMiddleware(AbstractAuthenticationMiddleware):
|
||||
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult:
|
||||
async def authenticate_request(
|
||||
self, connection: ASGIConnection
|
||||
) -> AuthenticationResult:
|
||||
"""Given a request, parse the header for Base64 encoded basic auth credentials."""
|
||||
|
||||
# retrieve the auth header
|
||||
@@ -19,11 +21,14 @@ class BasicAuthenticationMiddleware(AbstractAuthenticationMiddleware):
|
||||
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)
|
||||
|
||||
@@ -3,7 +3,7 @@ from chitai.services.kosync_device import KosyncDeviceService
|
||||
from litestar.middleware import (
|
||||
AbstractAuthenticationMiddleware,
|
||||
AuthenticationResult,
|
||||
DefineMiddleware
|
||||
DefineMiddleware,
|
||||
)
|
||||
from litestar.connection import ASGIConnection
|
||||
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
|
||||
@@ -11,7 +11,9 @@ from chitai.config import settings
|
||||
|
||||
|
||||
class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
|
||||
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult:
|
||||
async def authenticate_request(
|
||||
self, connection: ASGIConnection
|
||||
) -> AuthenticationResult:
|
||||
"""Given a request, parse the header for Base64 encoded basic auth credentials."""
|
||||
|
||||
# retrieve the auth header
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from pydantic import BaseModel, Field, computed_field
|
||||
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)]
|
||||
root_path: str
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ShelfRead(BaseModel):
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
).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)
|
||||
Identifier.book_id.in_(merged_ids),
|
||||
Identifier.name.notin_(held_names),
|
||||
)
|
||||
.order_by(Identifier.book_id, Identifier.id)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
taken: set[str] = set()
|
||||
for identifier in incoming:
|
||||
@@ -873,6 +946,7 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
||||
merged = set(merged_ids)
|
||||
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(DuplicateDismissal).where(
|
||||
or_(
|
||||
@@ -881,7 +955,10 @@ class BookService(SQLAlchemyAsyncRepositoryService[Book]):
|
||||
)
|
||||
)
|
||||
)
|
||||
).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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
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."""
|
||||
|
||||
@@ -18,7 +23,7 @@ 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:
|
||||
@@ -30,6 +35,5 @@ class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
|
||||
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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -4,25 +4,37 @@ 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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -32,17 +44,19 @@ class LinkRelations(StrEnum):
|
||||
_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
|
||||
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
|
||||
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
|
||||
xmlns: Literal["http://www.w3.org/2005/Atom"] = Field(
|
||||
default="http://www.w3.org/2005/Atom", serialization_alias="@xmlns"
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
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["currentPage"] = params.get("currentPage", 1) + 1
|
||||
|
||||
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)
|
||||
|
||||
@@ -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 #
|
||||
###############################
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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é & 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()) == []
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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(
|
||||
assert (
|
||||
await books_service.find_duplicate_books(
|
||||
incoming | {"series_position": "2"}, test_library
|
||||
) == []
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
# The same volume, written a little differently, still matches.
|
||||
assert len(
|
||||
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)
|
||||
)
|
||||
).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)
|
||||
)
|
||||
).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)
|
||||
|
||||
Generated
+27
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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" ]
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1592
-2033
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,7 +24,9 @@ export const listBookshelves = query(bookshelfQuerySchema, async (data) => {
|
||||
return await response.json();
|
||||
});
|
||||
|
||||
export const addBooksToShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise<Bookshelf> => {
|
||||
export const addBooksToShelf = command(
|
||||
modifyBooksInShelf,
|
||||
async ({ shelf_id, ...data }): Promise<Bookshelf> => {
|
||||
const { locals } = getRequestEvent();
|
||||
|
||||
const params = createQueryParams(data);
|
||||
@@ -31,10 +38,13 @@ export const addBooksToShelf = command(modifyBooksInShelf, async ({ shelf_id, ..
|
||||
error(response.status, message);
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
);
|
||||
|
||||
export const removeBooksFromShelf = command(modifyBooksInShelf, async ({ shelf_id, ...data }): Promise<Bookshelf> => {
|
||||
export const removeBooksFromShelf = command(
|
||||
modifyBooksInShelf,
|
||||
async ({ shelf_id, ...data }): Promise<Bookshelf> => {
|
||||
const { locals } = getRequestEvent();
|
||||
|
||||
const params = createQueryParams(data);
|
||||
@@ -46,19 +56,19 @@ export const removeBooksFromShelf = command(modifyBooksInShelf, async ({ shelf_i
|
||||
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();
|
||||
});
|
||||
@@ -11,7 +11,7 @@ export const listDevices = query(async (): Promise<Device[]> => {
|
||||
if (!response.ok) error(500, 'An unkown error occurred');
|
||||
|
||||
const deviceResult = await response.json();
|
||||
return deviceResult.items
|
||||
return deviceResult.items;
|
||||
});
|
||||
|
||||
export const createDevice = form(createDeviceSchema, async (data): Promise<Device> => {
|
||||
@@ -32,7 +32,7 @@ export const regenerateDeviceApiKey = command(z.string(), async (deviceId): Prom
|
||||
if (!response.ok) error(500, 'An unknown error occurred');
|
||||
|
||||
return await response.json();
|
||||
})
|
||||
});
|
||||
|
||||
export const deleteDevice = command(z.string(), async (deviceId): Promise<void> => {
|
||||
const { locals } = getRequestEvent();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,15 +1,16 @@
|
||||
<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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user