Reads metadata.db and copies the books into a library — from a zip uploaded on the library settings page, or from a path with `litestar calibre-import`. The source is never touched, and re-running only picks up what is new. Also names the formats mimetypes does not know: a Calibre library is full of MOBI and AZW3, and a null content type used to fail the book endpoint.
389 lines
24 KiB
Markdown
389 lines
24 KiB
Markdown
# 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 extend
|
|
`BigIntBase` (e.g. `Identifier`, `FileMetadata`, `BookAuthorLink`).
|
|
- A service declares an inner repository and points at it:
|
|
|
|
```python
|
|
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_update` hooks**, not by
|
|
overriding `create`/`update` wholesale — see `services/book.py:407` onward.
|
|
- Serialise responses through `service.to_schema(obj, schema_type=s.SomeRead)`; for lists,
|
|
`to_schema(items, total, filters, schema_type=…)` produces the `OffsetPagination` envelope.
|
|
- `Author`, `Tag`, `Publisher` and `BookSeries` are deduplicated with **`as_unique_async`**. Never
|
|
construct them directly when attaching to a book — use
|
|
`await Author.as_unique_async(session, name=name)` as
|
|
`BookService._populate_with_unique_relationships` does, 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_service` is hand-written because it must inject eager loads *and* scope
|
|
user-specific rows: `selectinload` for authors/tags/files/etc., plus `with_loader_criteria` so
|
|
`BookProgress` and `BookListLink` only load rows belonging to `current_user`. If you add a
|
|
relationship that the API returns, add it to that `load` list.
|
|
- `create_book_filter_dependencies` intentionally **overrides** advanced-alchemy's stock providers:
|
|
the search filter becomes a trigram search, and order-by gains a `random` sort order. Do not
|
|
replace it with the stock `create_filter_dependencies`.
|
|
- `get_library_by_id` resolves the target library from either a `library_id` query param or the
|
|
book's own `library_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's `root_path`
|
|
(default: `author/series/position - title/`).
|
|
- **One directory per book, never shared.** The generated path is a pure function of the metadata,
|
|
so two books with the same author and title produce the same one — two editions, or an
|
|
`allow_duplicates` copy. `BookService._reserve_book_path` moves the later one to `title (2)`
|
|
before anything is written, and `update_book` reserves the same way so a rename cannot move a
|
|
book in on top of another. This matters because `book.path` is what deletes, moves and file
|
|
lookups act on: books sharing a directory means one overwrites the other's files, and deleting
|
|
either takes both. `_unused_path` does the same job for filenames within a directory.
|
|
A book that already has a `path` keeps it — `add_files` must follow the book, not the template.
|
|
- **Metadata extraction** — `services/metadata_extractor.py` reads 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`) watches
|
|
`settings.consume_path` with `watchfiles`, creates one subdirectory per library slug, batches
|
|
additions (3 s debounce) and imports them via `BookService.create_many_from_existing_files`.
|
|
Started as an asyncio task from the `setup_directory_watcher` lifespan hook.
|
|
- **Updates move files.** `BookService.update_book` regenerates 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.
|
|
|
|
### 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
|
|
**`(hash, size)`** — never the hash alone, because it samples 12 KiB (see below) and
|
|
EPUBs from one toolchain often share their first window. `FileMetadata.hash` carries a
|
|
plain, deliberately **non-unique** index: a collision must not be able to fail an import,
|
|
and older databases may already hold duplicates.
|
|
|
|
Scope comes from `CHITAI_DUPLICATE_SCOPE` (`library`, the default | `global` | `off`).
|
|
|
|
The policy differs by how deliberate the import is:
|
|
|
|
| Path | Behaviour |
|
|
| --- | --- |
|
|
| `create_many_from_files` (browser bulk) | Skip per file, skip a whole group whose files are all known, report everything skipped in `ImportResult.duplicates`. Re-dropping a folder to pick up what is new is the case this serves. |
|
|
| `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
|
|
import needs a way through. The **web UI deliberately does not offer it**: storing the
|
|
same bytes twice splits reading progress and shelf membership across two records that
|
|
can never converge, which is nothing anyone wants on purpose.
|
|
|
|
Three things to preserve when touching this code:
|
|
|
|
- **A match only counts while the file is on disk.** `find_duplicate_files` stats each
|
|
candidate, and `add_files` writes a missing file back into the row that already
|
|
describes it (`_restore_file`) instead of adding a second row beside it. The hash
|
|
lives in the database and the file does not, so without this a file deleted behind
|
|
the app's back would go on refusing its own replacement.
|
|
|
|
- **Screening runs before anything is written.** `fingerprint_upload` reads the spooled
|
|
upload and rewinds it; the resulting fingerprints are handed to `_save_book_files`,
|
|
which skips its own `StreamingHasher` when it already has the answer. Passing them
|
|
through is what keeps the file from being read twice.
|
|
- **`_screen_for_duplicates` extends the `known` dict as it goes**, so the same bytes
|
|
submitted twice in one request are caught. Those duplicates report `book_id: None` —
|
|
there is no row to point at yet.
|
|
|
|
`POST /books/duplicate-files` answers the same question from fingerprints alone, for
|
|
clients that want to ask before uploading anything.
|
|
|
|
### Book-level detection — a different question
|
|
|
|
The file check answers "are these the same bytes?". `find_duplicate_books` answers "is
|
|
this the same book?", which a re-scan, a re-zipped EPUB or another edition cannot be
|
|
asked with a hash. Two signals, either sufficient: a **shared identifier**, or a
|
|
**matching normalized title with at least one shared author**.
|
|
|
|
It **never blocks**. A metadata match is a guess — a work shares title and author with
|
|
its own translation, its own second edition and its own audiobook — so the book is
|
|
created and the candidates are reported alongside it in
|
|
`ImportResult.possible_duplicates`. File-level dedupe keeps its refuse/skip behaviour;
|
|
that one is near-certain and this one is not. Do not "improve" this into a refusal.
|
|
|
|
Two rules narrow it, both applied in Python over the small candidate set:
|
|
|
|
- **A shared author is required for a title match.** Without it every book the
|
|
extractors gave up on and titled `Unknown` is a duplicate of every other one. A book
|
|
with no authors can therefore only match on an identifier.
|
|
- **The same series at a different `series_position` disqualifies a match.** A trilogy
|
|
shares an author and often most of its title; the position is the library saying
|
|
outright that these are two books.
|
|
|
|
A book is compared under **several title keys, not one** (`_title_keys`). Ebook files
|
|
are overwhelmingly named `Title - Author.epub`, and wherever nothing inside the file
|
|
overrode that name the author ended up in the title column — so one copy is stored as
|
|
`Building Microservices` and another as `Building Microservices Sam Newman`. Both
|
|
directions are generated, the author stripped off and the author added on, which is why
|
|
the query is `normalized_title.in_(keys)` rather than `==`. This is still exact matching
|
|
on an indexed column: no similarity score, nothing to tune. It does not weaken the
|
|
shared-author requirement, which is a separate condition.
|
|
|
|
`find_duplicate_book_groups` is the library-wide pass behind
|
|
`GET /books/duplicate-books`, since the import-time check says nothing about a
|
|
collection someone already has. It buckets books by every key they carry and merges the
|
|
buckets with union-find, so A~B by ISBN and B~C by title land in one group. Pairs in
|
|
`duplicate_dismissals` are never merged — a reader disagreeing with one pairing must not
|
|
silently break a group that stands on other evidence.
|
|
|
|
### Author names have one stored form
|
|
|
|
`Author.name` is always the canonical form, produced by `format_author_name`. Extractors
|
|
hand over whatever the file said — `Newman, Sam;` from a `DC:creator` list, `Sam Newman`
|
|
from a PDF, `Sam Newman.epub` from a filename — and storing those verbatim is how one
|
|
person becomes four rows in the sidebar, four entries in the author filter, and four
|
|
books that never look like each other.
|
|
|
|
This is **display** canonicalization, distinct from `normalize_author`, which throws
|
|
away case, accents and spacing to build a comparison key nobody sees. Tidying only
|
|
removes what an extractor added: a trailing separator, a file extension, and the
|
|
`Surname, Given` ordering. It never touches case or accents — `Michał Płachta` and
|
|
`Steve McConnell` are the author's own spelling, not something to correct.
|
|
|
|
Three places have to agree, and `Author` keeps them together: the `@validates("name")`
|
|
hook, `unique_hash`, and `unique_filter`. `as_unique_async` looks a row up with the
|
|
filter and then constructs with the validator, so if the lookup used the raw name and
|
|
the insert used the tidy one, every variant spelling would miss the existing row and
|
|
then collide with it on the unique index.
|
|
|
|
A form the rule does not recognise is **left exactly as it was found** — `Dave Thomas,
|
|
Andy Hunt` is two people in one string, and flipping it would invent a third. Leaving a
|
|
mess visible beats rewriting it wrongly.
|
|
|
|
### The normalized columns are written by validators
|
|
|
|
`Book.normalized_title`, `Author.normalized_name` and `Identifier.normalized_value` are
|
|
derived from `services/matching.py` and kept current by **SQLAlchemy `@validates` hooks
|
|
on the models**, not by any service. Assigning them directly is always wrong.
|
|
|
|
This is deliberate and it is invisible at the call sites: `BookService` writes titles
|
|
through at least three paths (`to_model_on_create`, `to_model_on_update`, and the
|
|
`setattr` loop in `_populate_with_unique_relationships`), and a validator is the only
|
|
thing a fourth cannot bypass. The columns carry plain, **non-unique** btree indexes —
|
|
two spellings collapsing onto one value is the entire point.
|
|
|
|
`services/matching.py` is imported *inside* those validators rather than at module
|
|
scope: reaching it initialises the `chitai.services` package, which imports the
|
|
services, which import the models. Keep the local import.
|
|
|
|
The validators only fire on write, so a migration that adds one of these columns must
|
|
backfill existing rows through the same helpers — see the `data_upgrades()` hook in
|
|
`2026-08-15_add_book_matching_keys_and_duplicate__4358e7d4743a.py`.
|
|
|
|
**Changing anything in `services/matching.py` needs a revision that recomputes them.**
|
|
The keys are derived and already written, so a normalization change silently invalidates
|
|
every stored row: a book written under the old rules just stops matching one written
|
|
under the new rules, with nothing to show that anything is wrong. Copy
|
|
`2026-08-15_recompute_book_matching_keys_ed41acf21270.py`, which exists because
|
|
`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
|
|
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_trgm` is required.** `Book.__table_args__` declares a GIN trigram index on `title`, which
|
|
`TrigramSearchFilter` uses for fuzzy title search. The extension is enabled by a migration and, in
|
|
tests, by `conftest.py`.
|
|
- Migrations live in `migrations/versions/`. Generate with
|
|
`alchemy --config chitai.database.config.config make-migrations` (add `--no-autogenerate` for a
|
|
blank revision), apply with `… upgrade`. `database/config.py` sets `create_all=False`, so nothing
|
|
is auto-created at runtime; production applies migrations from `entrypoint.sh`.
|
|
- Sessions use `expire_on_commit=False` and Litestar's `before_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 through `AsyncTestClient(app=create_app())`.
|
|
- The database is a throwaway container from `pytest-databases`, so **Docker must be running**.
|
|
- `tests/conftest.py` provides 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 into `tmp_path`.
|
|
- `tests/integration/conftest.py` monkeypatches the module-level alchemy `config` onto 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_id` is a path parameter on some endpoints and a
|
|
query parameter on others. `set_book_progress_batch` does a documented N+1 (one select + one
|
|
upsert per book).
|
|
- `services/filesystem_library.py` — TODO to replace Jinja2 templating with simple placeholders;
|
|
`generate_filename` accepts a `filename_template` but currently ignores it and returns the
|
|
original filename.
|
|
- `app.py` — `watcher_task` is declared as a module-level global but assigned locally inside
|
|
`setup_directory_watcher`, so the global is never populated (cancellation still works via the
|
|
closure).
|
|
- `BookService.get_files` (the multi-book ZIP download) opens `Path(file.path)`, but `file.path` is
|
|
stored relative to `book.path` — worth verifying before relying on that endpoint.
|