8.3 KiB
Chitai backend
Litestar REST API for the eBook library. See the repo-root AGENTS.md for the overall picture and
dev-environment setup.
Stack: Python 3.13 · Litestar 2 · advanced-alchemy over async SQLAlchemy 2 · asyncpg ·
PostgreSQL 17 · Alembic (through advanced-alchemy's alchemy CLI) · pydantic-settings · uv.
Layering
controllers/ HTTP surface only — parse, delegate, serialise. Keep thin.
services/ Business logic. One SQLAlchemyAsyncRepositoryService subclass per aggregate.
database/models/ SQLAlchemy models.
schemas/ Pydantic DTOs for request bodies and responses.
Controllers call service methods, not the repository. app.py assembles the app: route
handlers, JWT auth, exception handlers, the SQLAlchemy plugin, and two lifespan context managers.
advanced-alchemy idioms
These are the conventions that are easy to get wrong if you write plain SQLAlchemy here:
-
Models extend
BigIntAuditBase(adds id/created_at/updated_at); pure link and child rows extendBigIntBase(e.g.Identifier,FileMetadata,BookAuthorLink). -
A service declares an inner repository and points at it:
class BookService(SQLAlchemyAsyncRepositoryService[Book]): class Repo(SQLAlchemyAsyncRepository[Book]): model_type = Book repository_type = Repo -
Transform incoming data with the
to_model_on_create/to_model_on_updatehooks, not by overridingcreate/updatewholesale — seeservices/book.py:407onward. -
Serialise responses through
service.to_schema(obj, schema_type=s.SomeRead); for lists,to_schema(items, total, filters, schema_type=…)produces theOffsetPaginationenvelope. -
Author,Tag,PublisherandBookSeriesare deduplicated withas_unique_async. Never construct them directly when attaching to a book — useawait Author.as_unique_async(session, name=name)asBookService._populate_with_unique_relationshipsdoes, or you will create duplicate rows. -
Domain methods on services are named for the domain (
create_book,update_book,add_files), deliberately distinct from the inherited CRUD names.
Dependency injection
services/dependencies.py is the hub; controllers wire providers in their dependencies dict.
- Most providers come from
create_service_provider(SomeService, …)— one line each. provide_book_serviceis hand-written because it must inject eager loads and scope user-specific rows:selectinloadfor authors/tags/files/etc., pluswith_loader_criteriasoBookProgressandBookListLinkonly load rows belonging tocurrent_user. If you add a relationship that the API returns, add it to thatloadlist.create_book_filter_dependenciesintentionally overrides advanced-alchemy's stock providers: the search filter becomes a trigram search, and order-by gains arandomsort order. Do not replace it with the stockcreate_filter_dependencies.get_library_by_idresolves the target library from either alibrary_idquery param or the book's ownlibrary_id, and raises 404 for either miss.
Filters
services/filters/ holds StatementFilter dataclasses that compose into any list /
list_and_count call: TagFilter, AuthorFilter, BookshelfFilter, ProgressFilter,
TrigramSearchFilter, CustomOrderBy, FileHashFilter, plus the *LibraryFilter variants used by
OPDS.
To add list-filtering behaviour: write the dataclass here, add a provide_*_filter function in
dependencies.py, register it in the controller's dependencies, and fold it into
provide_book_filters — the controller handler itself does not change.
Authentication — three schemes
| Scheme | Where | Used by |
|---|---|---|
JWT bearer (OAuth2PasswordBearerAuth) |
app.py |
The web frontend and the main API. Public paths are listed in its exclude=[…]. |
HTTP Basic (middleware/basic_auth.py) |
OpdsController |
E-reader / OPDS clients, which only speak Basic. |
X-AUTH-USER API key (middleware/kosync_auth.py) |
KosyncController |
KOReader devices; the key maps to a KosyncDevice row, which maps to a user. |
Each middleware resolves a User onto the connection; the matching
provide_user_via_basic_auth / provide_user_via_kosync_auth dependencies expose it to handlers.
Filesystem behaviour
The backend owns files on disk, not just rows:
- Layout —
services/filesystem_library.py(BookPathGenerator) renders a Jinja2 template against book metadata to decide where a book lives under the library'sroot_path(default:author/series/position - title/). - Metadata extraction —
services/metadata_extractor.pyreads EPUB (ebooklib) and PDF (pypdfium2) files; extracted values fill only empty fields on the incoming payload. - Covers — converted to WebP with a UUID filename under
settings.book_cover_path, served by a static-files router mounted at/covers. - Consume directory —
services/consume.py(ConsumeDirectoryWatcher) watchessettings.consume_pathwithwatchfiles, creates one subdirectory per library slug, batches additions (3 s debounce) and imports them viaBookService.create_many_from_existing_files. Started as an asyncio task from thesetup_directory_watcherlifespan hook. - Updates move files.
BookService.update_bookregenerates the path from the new metadata and, if it differs, moves the directory contents and prunes empty parents. Keep that in mind before changing metadata handling.
KOReader hashing
services/utils.py reimplements KOReader's partial-MD5 document identifier: 1 KiB samples at
offsets produced by LuaJIT's 32-bit bit.lshift, including its shift-masking wrap-around
(shift & 0x1F, so i=-1 yields offset 0). _lshift32 looks wrong and is not — the overflow is
what makes hashes match real devices. Don't "simplify" it; tests/integration/test_file_hash.py
guards the behaviour.
Database
pg_trgmis required.Book.__table_args__declares a GIN trigram index ontitle, whichTrigramSearchFilteruses for fuzzy title search. The extension is enabled by a migration and, in tests, byconftest.py.- Migrations live in
migrations/versions/. Generate withalchemy --config chitai.database.config.config make-migrations(add--no-autogeneratefor a blank revision), apply with… upgrade.database/config.pysetscreate_all=False, so nothing is auto-created at runtime; production applies migrations fromentrypoint.sh. - Sessions use
expire_on_commit=Falseand Litestar'sbefore_send_handler="autocommit", so a handler that returns 2xx commits automatically.
Testing
pytest tests/ — asyncio_mode = "auto", so async tests need no marker.
tests/unit/exercises services directly against a real session;tests/integration/drives the whole app throughAsyncTestClient(app=create_app()).- The database is a throwaway container from
pytest-databases, so Docker must be running. tests/conftest.pyprovides the shared fixtures:client,authenticated_client,other_authenticated_client(a second user, for access-control tests), one fixture per service,test_user/test_library, and an autouse fixture that redirects cover storage intotmp_path.tests/integration/conftest.pymonkeypatches the module-level alchemyconfigonto the test engine/sessionmaker and drops+recreates+reseeds the schema for every test.- Real EPUB and PDF fixtures live in
tests/data_files/.
Known rough edges
Observed in the current tree — don't mistake these for intentional patterns to copy:
controllers/book.py— file-level TODO:book_idis a path parameter on some endpoints and a query parameter on others.set_book_progress_batchdoes a documented N+1 (one select + one upsert per book).services/filesystem_library.py— TODO to replace Jinja2 templating with simple placeholders;generate_filenameaccepts afilename_templatebut currently ignores it and returns the original filename.app.py—watcher_taskis declared as a module-level global but assigned locally insidesetup_directory_watcher, so the global is never populated (cancellation still works via the closure).BookService.get_files(the multi-book ZIP download) opensPath(file.path), butfile.pathis stored relative tobook.path— worth verifying before relying on that endpoint.