This repository has no description
11 kB · 231 lines
Markdown
at main

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/height attributes
  • patches the OPF manifest and NCX identifier
  • injects defensive CSS so images/tables cannot overflow the page
  • writes mimetype first 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 sinker says the session can no longer be refreshed, run sinker login again.

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:

  1. Local ledger — (path, size, mtime) already uploaded or skipped.
  2. Source hash — the unchanged source file is already in BookHive (catches books you uploaded by hand before using this tool).
  3. Title + author — normalized the way BookHive parses EPUB metadata (dc:title, all dc:creator joined with , ). Catches compressed copies uploaded from another machine, where the bytes differ.
  4. 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 .epub files 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-failed to try again.
  • A book that already exists but whose source changed (e.g. a new edition) is skipped by metadata; use --force to 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 login used; 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.