Cross Reader Sinker #
Should be called CrossPoint Reader Sinker, but eh. Forgot what the firmware was called there for a min. Optimizes EPUB files for xteink e-ink readers running CrossPoint firmware, and syncs them to a BookHive personal library so they can be pulled down over OPDS.
There are two commands:
| Command | What it does |
|---|---|
epub-optimize |
The original one-shot converter. Takes a folder or file, writes optimized EPUBs. |
sinker |
OAuth login, BookHive sync, folder watcher, status/logout. |
What the optimizer does #
A close port of the compression pipeline in CrossPoint's
FilesPage.html:
- resizes images to the target screen and re-encodes them as grayscale baseline JPEG
- fixes SVG-wrapped covers and SVG image references
- removes stale
width/heightattributes - patches the OPF manifest and NCX identifier
- injects defensive CSS so images/tables cannot overflow the page
- writes
mimetypefirst and uncompressed, per the EPUB OCF spec
Originals are never modified.
Install #
npm install
Requires Node 20+ (developed on Node 24). sharp ships prebuilt binaries; no build toolchain is
needed on macOS, Linux or Windows.
Optimize without syncing #
npx epub-optimize ./unformatted-books --out ./formatted-books --device X4 --quality 85 --rename
| Flag | Meaning |
|---|---|
--out DIR |
output folder (default <input>/optimized) |
--device X4|X3 |
X4 = 480x800 (default), X3 = 528x792 |
--quality N |
JPEG quality, 1–100 (default 85) |
--auto-crop |
trim uniform margins before resizing |
--rename |
name output Title - Author.epub from EPUB metadata |
Sync to BookHive #
1. Log in once #
cp .env.example .env
# edit ATPROTO_HANDLE
mkdir -p unformatted-books formatted-books
npx sinker login
This opens a browser, authorizes a loopback OAuth client, and stores the session under
~/.config/cross-reader-sinker. The requested scopes are deliberately narrow:
atproto
rpc:buzz.bookhive.getPersonalLibrary?aud=*
rpc:buzz.bookhive.getPersonalBook?aud=*
rpc:buzz.bookhive.uploadPersonalBook?aud=*
BookHive does not accept OAuth access tokens directly — its XRPC methods take an atproto
inter-service auth JWT. So each call uses the OAuth session to mint a 60-second token from your PDS
(com.atproto.server.getServiceAuth) and posts that to bookhive.buzz.
Sessions for public loopback clients refresh indefinitely while used, but expire after roughly two weeks of inactivity. If
sinkersays the session can no longer be refreshed, runsinker loginagain.
2. Sync once, or keep watching #
# one pass: compress and upload anything new
npx sinker sync
# or run as a service
npx sinker watch
watch keeps running until Ctrl+C, waits for files to finish copying, serializes uploads, and
rescans periodically to catch missed filesystem events and retry transient failures.
3. Pull books on the reader #
BookHive's OPDS catalog serves whatever is in your personal library — no extra step needed. Uploaded
books are named Title - Author.epub so cross-reader filename matching and KOSync progress work.
How "don't upload it twice" works #
BookHive identifies personal-library files by a KOReader partial MD5 (12 × 1 KB windows), which it
calls contentHash. sinker computes the same value and checks, in order:
- Local ledger —
(path, size, mtime)already uploaded or skipped. - Source hash — the unchanged source file is already in BookHive (catches books you uploaded by hand before using this tool).
- Title + author — normalized the way BookHive parses EPUB metadata (
dc:title, alldc:creatorjoined with,). Catches compressed copies uploaded from another machine, where the bytes differ. - Compressed hash — the exact compressed output is already there.
Anything matched is skipped. --force overrides all of it. Nothing is ever deleted from BookHive.
The ledger lives in SINKER_STATE_DIR (one JSON file per atproto account) and is written atomically.
Configuration #
CLI flags override .env, which overrides defaults. .env is loaded from the working directory;
use --env-file FILE for another file.
| Variable | Default | Meaning |
|---|---|---|
ATPROTO_HANDLE |
— | account to log in as |
BOOKHIVE_URL |
https://bookhive.buzz |
BookHive base URL |
BOOKHIVE_DID |
did:plc:enu2j5xjlqsjaylv3du4myh4 |
BookHive service DID |
SINKER_SOURCE_DIR |
./unformatted-books |
folder to watch (top level only) |
SINKER_OUTPUT_DIR |
./formatted-books |
compressed archive |
SINKER_STATE_DIR |
~/.local/state/cross-reader-sinker |
sync ledger |
SINKER_OAUTH_DIR |
~/.config/cross-reader-sinker |
OAuth session |
SINKER_DEVICE |
X4 |
X4 or X3 |
SINKER_QUALITY |
85 |
JPEG quality |
SINKER_AUTO_CROP |
false |
trim margins |
SINKER_RENAME |
true |
upload/archive as Title - Author.epub |
SINKER_LOGIN_PORT |
8765 |
loopback callback port |
SINKER_LOGIN_HOST |
127.0.0.1 |
callback bind address |
SINKER_WATCH_POLL |
false |
poll instead of FSEvents (network volumes) |
SINKER_BACKFILL |
true |
scan existing files on startup |
SINKER_RESCAN_MS |
300000 |
periodic rescan interval |
SINKER_RETRY_MAX |
5 |
upload attempts |
SINKER_TIMEOUT_MS |
120000 |
per-request timeout |
Commands #
npx sinker login [--handle HANDLE] [--port 8765] [--no-open]
npx sinker logout
npx sinker status [--json] [--check]
npx sinker sync [file…] [--dry-run] [--force] [--retry-failed]
npx sinker watch [--no-backfill] [--poll] [--dry-run]
--dry-run authenticates and reports what it would upload, but writes no files, uploads nothing,
and does not touch the ledger. Good for a first run. --check verifies the session against BookHive
and reports library size and storage usage.
Docker #
docker compose build
# one-time login; the published port forwards the browser callback into the container
docker compose run --rm -it --service-ports sinker login --no-open
# run the watcher
docker compose up -d
docker compose logs -f
The compose file mounts ./unformatted-books, ./formatted-books, and a named volume for the OAuth
session + ledger. Set SINKER_LOGIN_HOST=0.0.0.0 (already set in the image) so the callback reaches
the container.
Standalone:
docker run --rm -it \
-p 8765:8765 \
-v "$PWD/unformatted-books:/books/in" \
-v "$PWD/formatted-books:/books/out" \
-v sinker-data:/data \
cross-reader-sinker login --no-open
Limits and behavior #
- Only
.epubfiles at the top level of the source folder are processed; everything else is ignored. - BookHive accepts up to 100 MB per file (default 2 GiB library quota).
Busy, 429s, 5xx and network errors are retried with exponential backoff.- Permanent failures (quota, unsupported format, unreadable EPUB) are recorded and skipped on later
runs. Fix the cause and use
--retry-failedto try again. - A book that already exists but whose source changed (e.g. a new edition) is skipped by metadata;
use
--forceto upload it as a second entry. - No remote deletion, ever.
- The login port is part of the OAuth client identity. It is saved with the session, so later commands
reuse the port that
loginused; changing it only affects a new login.
Troubleshooting #
| Message | Fix |
|---|---|
not signed in |
npx sinker login |
session ... can no longer be refreshed |
Log in again; sessions expire after ~2 weeks idle |
port 8765 is already in use |
npx sinker login --port 8766 |
| Docker login URL opens but the callback never arrives | Use docker compose run --rm --service-ports, or -p 8765:8765 |
QuotaExceeded |
Remove books in BookHive, or retry later with --retry-failed |
| A file is ignored | Only top-level .epub files are processed; check SINKER_SOURCE_DIR |
Development #
npm test # unit + pipeline tests (no network)
npm run fixture # build EPUB fixtures
npm run verify # verify optimizer output
Layout:
src/sinker-cli.js command dispatch
src/sinker/config.js .env + flag merge
src/sinker/auth.js OAuth loopback client, file stores, login/logout
src/sinker/bookhive.js service JWT minting, XRPC calls, retries
src/sinker/hash.js KOReader partial MD5
src/sinker/metadata.js OPF title/creators
src/sinker/dedupe.js normalization + library index
src/sinker/state.js atomic per-account ledger
src/sinker/pipeline.js compress → dedupe → upload
src/sinker/watcher.js chokidar watcher + queue
The optimization logic comes from crosspoint-reader/crosspoint-reader; the CLI is a faithful Node port.