Files
reader/CLAUDE.md
hectorqinandClaude Opus 5.5 4e149c73d7 ci: release only for v* tags or manual runs; CI on main; Android SDK packages
- main is the long-lived development branch: release.yml no longer runs on
  a push to it, only on v* tags and workflow_dispatch. ci.yml now runs on
  every branch push, main included.
- android-actions/setup-android's default package list includes `tools`,
  which current command-line tools no longer carry, so sdkmanager failed;
  install exactly platform-tools, android-35 and build-tools 35.0.1.
- reader-chrome.test.ts left its React roots mounted; a scheduled commit
  could land after jsdom teardown and fail the run with an unhandled
  "Right-hand side of 'instanceof' is not an object" (all tests passing).
  Unmount in afterEach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:19:56 +00:00

65 lines
8.2 KiB
Markdown

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Self-hosted book library + media server (books, movies/TV, music, audiobooks). Three independent parts, no root `package.json`:
- `server/` — Node ≥22 (CI uses 24), TypeScript run directly via `tsx`, Fastify, built-in `node:sqlite`.
- `web/` — React 19 + Vite client. The **same build** is served by the server and bundled into the Android app.
- `android/` — Kotlin WebView shell (+ native pager, TTS, Media3 playback). Contract with the web bundle is in `android/contract.md`.
Docs (mostly Chinese) live in `docs/`; `docs/architecture.md` explains *why* things are designed the way they are and `docs/ui.md` is the UI spec — read the relevant section before changing shelf/library/reader behaviour.
## Commands
Run from repo root (or `cd` into the package). CI runs install → typecheck → test → build per package: GitHub Actions `.github/workflows/ci.yml` (PRs and every branch push; also UI review, Android shell check + debug APK) and CNB `.cnb.yml`. `main` is the long-lived development branch and never releases on push: `.github/workflows/release.yml` runs only for `v*` tags or a manual run, runs CI first, then publishes the multi-arch (amd64 + arm64, native runners) image to Docker Hub `hectorqin/reader-pro` (`x.y.z`/`x.y`/`latest` + `sha-*` for tags; branch name + `sha-*` for manual runs); it needs the `DOCKER_USERNAME`/`DOCKER_PASSWORD` secrets. `android/scripts/check-shell.sh` checks the JS bridge contract: only `@JavascriptInterface` methods count, and each must be declared in `web/src/android-bridge.d.ts` (method syntax, `name?(): T` for optional).
```sh
npm ci --prefix server && npm ci --prefix web
# Dev: two terminals. DATA_DIR must NOT be inside BOOKS_DIR (server refuses to start).
BOOKS_DIR=/tmp/reader-dev-books DATA_DIR=/tmp/reader-dev-data npm run dev --prefix server # :5888
npm run dev --prefix web # http://localhost:5174, proxies /api to READER_SERVER (default 127.0.0.1:5888)
npm run typecheck --prefix server && npm test --prefix server && npm run build --prefix server
npm run typecheck --prefix web && npm test --prefix web && npm run build --prefix web
```
Single tests:
- Server (node:test): `cd server && npx tsx --test test/media-api.test.ts` (add `--test-name-pattern="..."` to filter).
- Web has **two runners**. Most files are Vitest: `cd web && npx vitest run test/foo.test.ts`. A few files written for `node:test` (`api`, `asset-url`, `paginator`, `window`, `windowed-toc`) are excluded from Vitest in `vite.config.ts` and run with `npx tsx --test test/<file>`. When adding a node:test file, add it to both the `test` script and the Vitest `exclude` list.
UI review (real Chromium via CDP; needs `npx playwright install chromium` or `CHROME_PATH`): `npm run ui:review --prefix web` (builds, then screenshots + measured assertions into git-ignored `artifacts/ui-review/react-app/`, result in `verification.json`). Also `ui:review:reader` and `ui:sources`. Never commit screenshots to `docs/`. Run this and look at the screenshots after layout changes.
Docker image (context must be repo root): `docker build -f server/Dockerfile -t reader:local .`
Android: `sh android/scripts/build-web-assets.sh && cd android && ./gradlew :app:assembleDebug`.
Icons: add path to `web/tools/icons/paths.mjs` (use this project's own English names — `test/icons.test.ts` rejects names borrowed from other icon sets), then `npm run icons`.
## Server architecture
- `main.ts` wires an `AppContext` (config, db, Scanner, services) and passes it to `http/app.ts` `buildApp`; routes are in `http/routes/*`. Tests build the same app against a temp BOOKS_DIR/DATA_DIR (see `test/api.test.ts`).
- **Paths**: every filesystem access goes through `lib/paths.ts` `resolveInside()` (throws `PATH_TRAVERSAL`); the scanner does not follow symlinks.
- **Book identity**: `id = sha256(lower(dc:identifier) + '\0' + sha256(fileBytes))`, not the path — renames/moves keep progress. `book_files` (many) → `books` (one); missing files are flagged `missing=1`, not deleted.
- **Scanning** (`indexer/scanner.ts`): mtime+size first, content hash only if that changed; concurrent `scan()` calls share one Promise.
- **Metadata precedence**: manual overrides (`metadata_overrides`, applied at read time in `ShelfService.toDto`) > embedded > provider (fills gaps) > filename parsing. The scanner may freely rewrite `books` because manual edits live elsewhere. Filename parsing is deliberately conservative: without evidence, the whole string stays the title and author stays empty.
- **Book DTOs are assembled only in `ShelfService`** (`toDto`/`getMany`); other services (sync, browse) call it instead of building their own rows. "On shelf" means `user_books.hidden = 0` AND a non-missing file exists — keep that definition consistent everywhere.
- **Sync** (`services/sync.ts`): last-writer-wins, note deletes are tombstones (`deleted=1`), each push batch is one transaction, and the POST response *is* the merged pull (the client must not GET afterwards).
- **Schema** (`db/schema.ts`): plain SQL; changes must be additive (`MIGRATIONS_SQL`, columns with defaults). Self-hosted users upgrade late and older builds may run against newer DBs.
- **Sources/plugins** (`sources/`): local library, OPDS, and process-based extension plugins (`plugin-manager.ts`, `process-plugin.ts`; example in `examples/plugins/demo-chapters`).
- **Media** (`media/`) is isolated: separate `media.db`, started asynchronously in a worker after reading is already serving. Routes return 503 `MEDIA_STARTING` / `MEDIA_UNAVAILABLE` while the reading side keeps working. Scans don't hold reader.db locks; publishing atomically swaps in a full snapshot in its own thread (failed/partial scans never replace the catalog); read-only catalog queries run on a separate query thread. Permissions are re-checked on every access/stream. reader.db and media.db (plus signing key) must be backed up/restored as a pair. See `docs/media-implementation.md`, `docs/media-write-isolation.md`.
## Web architecture
- `main.tsx` creates `AppRuntime` (`app/runtime.ts`: platform choice Web/Android, `ReaderApi`, `MediaApi`, player, `SyncEngine`, `OfflineStore`, settings, QueryClient), then renders providers + `RouterProvider`.
- **Hash routing only** (`createHashRouter`, `app/router/routes.tsx`): neither the server nor the `file://` WebView can serve client-side fallbacks. New links use `#/...`.
- **Single bundle, no code splitting, `base: './'`** (see `vite.config.ts`) because Android loads it from `file://`. Don't introduce dynamic chunks.
- Server data via React Query; shared client state via Zustand; long-lived resources (player, theme, sync) owned by Providers/services and cleaned up there. New pages go in `features/<domain>/pages`; cross-domain code goes through `shared/` or `app/`. Don't add to the old Screen dispatch (`app.ts`, `app/legacy-compat.ts` exist only for Android diagnostics/old tests).
- **Reader is imperative**: `features/reader/pages/ReaderPage.tsx` mounts `ReaderScreen` (`ui/reader-screen.tsx`) in an effect. Book content lives in a Shadow DOM with pagination measured via the DOM (`ui/reader-view.ts`, `render/`). Don't rewrite the stage as a React subtree; only the chrome is React.
- **Theming**: `ThemeController` (`shared/theme/theme.ts`) writes `data-theme`/`data-mode` on `<html>`; components use `--color-*` tokens from `styles/theme-tokens.css` (`--reader-*` and media aliases map onto them). Read/save theme via `readThemePreference`/`saveThemePreference`/`resolveAppTheme` (and `useThemeState` from `shared/theme/use-theme.ts` in React), never storage directly. New colors/spacing/durations become tokens, not literals.
- UI is built from the project's own components (`ui/toolkit.tsx`, `ui/sheet.tsx` as the only modal type, `ui/action-menu.tsx` for every ⋯ menu, `ui/dialog.tsx`, `ui/pager.tsx`) with Lucide/own icon font. Mantine was removed; some docs still mention it.
- Lists are server-paginated with the page in the URL (`docs/ui.md` §3.5). After a write, re-fetch and only then report. The client must not reconstruct facts the server already knows (e.g. book↔file joins); write by book id.
- Offline mirror (`store/offline.ts`) is additive-only; removals must call `offline.removeBooks` explicitly.