- Python 81.4%
- JavaScript 6.7%
- CSS 6.4%
- Nix 5.3%
- HTML 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| doc | ||
| examples | ||
| nix | ||
| src/leseratte | ||
| tests | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| NOTICE | ||
| pyproject.toml | ||
| README.md | ||
leseratte
Keeps a local, e-reader-optimized book library in sync with your Hardcover "Want to Read" shelf and serves it over the network as an OPDS 1.2 catalog, with a small web UI for babysitting the odd book.
Warning, this is entirely vibe-coded slop made for personal use – use at your own risk!
For every book it does:
- Query — GraphQL request against the Hardcover API for the books on your
Want-to-Read shelf (
status_id = 1). - Download — ask the configured providers in order. EPUB, MOBI, AZW3 and
FB2 are all accepted, and a candidate has to match the book well enough to
be used. EPUB wins ties, since it needs no conversion.
- slskd (Soulseek): search, queue the best peer file, wait for the transfer, pick it up from slskd's download dir.
- SABnzbd (Usenet): search NZBs through the Prowlarr API, upload the NZB to SABnzbd, then copy the unpacked book out of its completed directory.
- libgen (last-resort fallback): search results are scraped from the
public Libgen mirrors Anna's Archive indexes (AA's own
/searchsits behind a DDoS-Guard JS challenge no plain HTTP client can pass); downloads then go through the mirror's slow queue,library.lol, orlibgen.li. No account required, but flaky and prone to anti-bot pages — only useful when slskd/Usenet both came up empty.
- Convert — anything that is not already an EPUB is handed to Calibre's
ebook-convert(MOBI, AZW3, FB2 → EPUB). The conversion backend is configurable viaconverter.binary/converter.timeout. - Optimize — run the EPUB through the CrossPoint reader optimizer
(
crosspoint_reader/optimizer.pyfromcrosspoint-reader/calibre-plugins, vendored undersrc/leseratte/crosspoint/): images are scaled to the device screen, flattened, grayscaled and re-encoded as JPEG, XHTML/OPF/NCX/CSS are rewritten, oversized paragraphs and chapters are split and embedded fonts are dropped. - Serve — publish the optimized EPUB in an OPDS 1.2 acquisition feed, with
covers, so CrossPoint/CrossInk and other OPDS readers can browse and install
books. The web UI on
/lists every book with its state, and each book has a page where you can search the providers by hand and pick a result yourself.
Books that fail (nothing found, download broken, not a supported ebook) are recorded in the database with their error and retried on the next sync. Books that leave the Want-to-Read shelf are deleted from the library, files included.
leseratte does not install slskd, Prowlarr or SABnzbd — it talks to the instances you already run.
NixOS
{
inputs.leseratte.url = "github:your/leseratte";
# in your configuration
imports = [ inputs.leseratte.nixosModules.default ];
services.leseratte = {
enable = true;
server = {
host = "0.0.0.0";
port = 8080;
openFirewall = true;
baseUrl = "http://leseratte.local:8080"; # used in feed links
};
hardcover.tokenFile = "/run/secrets/hardcover-api-token";
providers = {
order = [ "slskd" "sabnzbd" ];
slskd = {
apiKeyFile = "/run/secrets/slskd-api-key";
downloadsDir = "/var/lib/slskd/downloads";
};
prowlarr = {
url = "http://127.0.0.1:9696";
apiKeyFile = "/run/secrets/prowlarr-api-key";
};
sabnzbd = {
url = "http://127.0.0.1:8081";
apiKeyFile = "/run/secrets/sabnzbd-api-key";
};
};
optimizer.device = "X4"; # or "X3"
};
}
Every credential is a file path, never a value: the service reads them
through systemd LoadCredential, so nothing secret ends up in the Nix store or
in the process environment. See examples/configuration.nix
for a fuller example.
The effective, secret-free configuration ends up at /etc/leseratte/config.toml.
nixos-rebuild switch gives you a leseratte.service that syncs on start and
then every sync.interval seconds.
Options
| Option | Default | Meaning |
|---|---|---|
enable |
false |
Enable the service. |
package |
this flake | Package to run. |
user, group |
leseratte |
Account the service runs as. |
libraryDir |
/var/lib/leseratte/library |
Where optimized EPUBs live (one directory per book). |
dataDir |
/var/lib/leseratte |
State database + scratch downloads. |
logLevel |
INFO |
DEBUG/INFO/WARNING/ERROR. |
server.host |
127.0.0.1 |
Bind address. |
server.port |
8080 |
Listen port. |
server.baseUrl |
"" |
Public URL used in feed links (empty = derive from the request). |
server.openFirewall |
false |
Open server.port. |
sync.interval |
3600 |
Seconds between syncs; 0 disables periodic syncs. |
sync.onStart |
true |
Sync once at service start. |
sync.timeout |
3600 |
Per-operation network timeout in seconds. |
sync.maxAttempts |
0 |
Give up on a book after N failures; 0 = retry forever. |
hardcover.tokenFile |
— | File containing the Hardcover API token. |
hardcover.endpoint |
https://api.hardcover.app/v1/graphql |
API endpoint. |
hardcover.statusId |
1 |
Shelf to sync (1 = Want to Read). |
hardcover.queryLimit |
100 |
Page size for user_books. |
hardcover.userAgent |
leseratte (NixOS) |
User-Agent (Hardcover asks for a descriptive one). |
providers.order |
[ "slskd" "sabnzbd" ] |
Providers, tried in order. |
providers.slskd.url |
http://127.0.0.1:5030 |
slskd API base URL. |
providers.slskd.apiKeyFile |
null |
File with the slskd API key; unset disables slskd. |
providers.slskd.downloadsDir |
"" |
slskd's directories.downloads. |
providers.slskd.searchTimeout |
12 |
Seconds slskd collects peer responses. |
providers.prowlarr.url |
"" |
Prowlarr base URL (needed by SABnzbd). |
providers.prowlarr.apiKeyFile |
null |
File with the Prowlarr API key. |
providers.prowlarr.categories |
7020 |
Prowlarr category ids (7020 = Books/Ebook). |
providers.prowlarr.limit |
50 |
Releases asked for per search. |
providers.sabnzbd.url |
"" |
SABnzbd base URL; empty disables it. |
providers.sabnzbd.apiKeyFile |
null |
File with the SABnzbd API key. |
providers.sabnzbd.category |
"" |
SABnzbd category (empty = Default). |
providers.libgen.mirrors |
[ "annas-archive.gl" "annas-archive.pk" "annas-archive.gd" ] |
Bare hostnames of Anna's Archive mirrors (download tiers and AA search fallback), tried in order; first one that answers wins. |
providers.libgen.secretKeyFile |
null |
File with the optional Anna's Archive Fast API key (faster, no wait). |
providers.libgen.timeout |
30 |
Per-request timeout in seconds for the mirror probe and download pages. |
converter.binary |
ebook-convert |
Calibre executable used to convert MOBI/AZW3/FB2 to EPUB (already on the packaged service's PATH). |
converter.timeout |
600 |
Seconds allowed per conversion. |
optimizer.device |
X4 |
X4 (480x800) or X3 (528x792). |
optimizer.jpegQuality |
85 |
JPEG quality for re-encoded images. |
optimizer.grayscale |
true |
Convert images to grayscale. |
optimizer.autoCrop |
false |
Crop uniform borders from images. |
optimizer.splitText |
true |
Split oversized paragraphs/chapters, strip embedded fonts. |
opds.title, opds.author |
leseratte |
Catalog metadata. |
auth.sessionTtl |
86400 |
Browser session lifetime (seconds). |
auth.sessionSecretFile |
null |
File with the cookie-signing secret; unset generates an ephemeral one at startup. |
auth.oidc.issuer |
"" |
OIDC issuer URL. Setting issuer + clientId turns on WebUI/API login. |
auth.oidc.clientId |
"" |
OIDC client id. |
auth.oidc.clientSecretFile |
null |
File with the OIDC client secret (leave unset for public PKCE clients). |
auth.oidc.scopes |
openid email profile |
Requested scopes. |
auth.oidc.redirectUrl |
"" |
Public callback URL; empty = server.baseUrl + /auth/callback. |
auth.oidc.allowedEmails |
[] |
Login allow-list; empty allows any authenticated account. |
auth.opds.username |
"" |
OPDS Basic-auth username. Setting it turns on OPDS auth. |
auth.opds.passwordFile |
null |
File with the OPDS Basic-auth password. |
Endpoints
| Path | Description |
|---|---|
/ |
Dashboard SPA: all books, states, errors, provider health, sync button. |
/book/<id> |
SPA route: one book's details, provider search and manual pick. |
/static/* |
Frontend assets (HTML/CSS/JS). The UI is a buildless Preact + htm app that talks only to the JSON API. |
/opds |
OPDS navigation feed. |
/opds/all |
Acquisition feed, all ready books, ?page=N. |
/opds/recent |
Acquisition feed, newest first. |
/opds/download/<id> |
The EPUB. |
/opds/cover/<id> |
Cover JPEG (if Hardcover had one). |
/auth/login |
Start OIDC login; redirects to the identity provider. |
/auth/callback |
OIDC redirect target; sets the session cookie. |
/auth/logout |
Clears the session cookie. |
/healthz |
JSON status: version, ready book count, sync state, last error, jobs. |
/sync |
POST triggers a sync in the background (202). GET returns 405. |
/api/status |
The /healthz payload. |
/api/providers |
Provider health. |
/api/sync |
POST a sync. |
/api/books?limit=&offset= |
Book rows. |
/api/books/upload?filename= |
POST the raw ebook body (EPUB/MOBI/AZW3/FB2); converts it to EPUB, stages it and returns {token, title, author}. |
/api/books |
POST {"token", "title", "author"} — create the staged book and optimize it. |
/api/books/<id> |
One book row plus its running job. |
/api/books/<id>/retry |
POST — forget the failure, try again. |
/api/books/<id>/search |
POST {"query": ...} — search all providers. |
/api/books/<id>/download |
POST {"candidate": {...}} — download a chosen result. |
/api/books/<id>/delete |
POST — delete a manually added book. Synced books are refused. |
Manual uploads
The dashboard's Add ebook button uploads a file you already have. The server
streams the body to work/uploads/ (staged files older than an hour are
swept), converts MOBI/AZW3/FB2 to EPUB with ebook-convert, reads the title
and author from the OPF so the form can prefill them, then optimizes the book
for the configured reader profile, extracts the embedded cover and serves it
over OPDS like any other book. Hand-added books get a negative id, so a sync
never sweeps them, and only they can be deleted from the dashboard.
Authentication
Two independent mechanisms, each off by default:
- WebUI / JSON API — OIDC login. Set
auth.oidc.issuerandauth.oidc.client_idand the dashboard,/book/*,/api/*,/syncand the/auth/*routes require a signed session cookie.auth.oidc.scopesdefaults toopenid email profile;auth.oidc.allowed_emailsrestricts logins to an allow-list (empty = any authenticated account). The client secret and session secret come from the environment or a file, never the TOML. - OPDS — HTTP Basic. Set
auth.opds.username(password via env/file) and/opds*answers401with aWWW-Authenticate: Basicchallenge until the reader supplies credentials. Every mainstream OPDS client supports this.
/healthz and /static/* always stay public. If both are unset the server is
unauthenticated, as before: bind it to a trusted network or put a reverse proxy
in front of it.
When a browser session expires the SPA bounces to /auth/login; the return URL
is validated to stay on the same host.
The backend renders no HTML: it serves the static app from src/leseratte/static/
and the JSON/OPDS API, and the app (a single vendored Preact + htm bundle, no
build step) fetches from the API and renders every page client-side.
Without Nix
Needs Python 3.11+ (Pillow, lxml and joserfc are the only dependencies).
python -m venv .venv && .venv/bin/pip install -e .
export HARDCOVER_API_TOKEN_FILE=~/.secrets/hardcover
export SLSKD_API_KEY_FILE=~/.secrets/slskd
.venv/bin/leseratte check # show resolved paths, then exit
.venv/bin/leseratte sync # one sync
.venv/bin/leseratte serve # server + periodic sync
Configuration is a TOML file (--config, or $LESERATTE_CONFIG, or defaults).
Keys mirror the NixOS options in snake_case; unknown keys are an error:
library_dir = "/srv/leseratte/library"
data_dir = "/srv/leseratte"
[server]
host = "0.0.0.0"
port = 8080
[sync]
interval = 21600
[providers]
order = ["slskd", "sabnzbd"]
[providers.slskd]
url = "http://127.0.0.1:5030"
downloads_dir = "/var/lib/slskd/downloads"
[providers.prowlarr]
url = "http://127.0.0.1:9696"
categories = "7020"
[providers.sabnzbd]
url = "http://127.0.0.1:8081"
category = "books"
[optimizer]
device = "X4"
[auth]
session_ttl = 86400
[auth.oidc]
issuer = "https://idp.example.com"
client_id = "leseratte"
scopes = "openid email profile"
# redirect_url = "https://leseratte.example.com/auth/callback"
allowed_emails = ["you@example.com"]
[auth.opds]
username = "reader"
OIDC secrets and the OPDS password are never put in the TOML: they come from
LESERATTE_SESSION_SECRET, LESERATTE_OIDC_CLIENT_SECRET, LESERATTE_OPDS_PASSWORD
or their *_FILE variants.
API keys are never put in the TOML: they come from HARDCOVER_API_TOKEN,
SLSKD_API_KEY, PROWLARR_API_KEY, SABNZBD_API_KEY, ANNAS_SECRET_KEY or
their *_FILE variants (the *_FILE variants win if both are set).
Libgen / Anna's Archive fallback
The libgen provider is a last-resort scraper. Search is served by the public
Libgen mirrors (libgen.li, libgen.bz, libgen.la, libgen.vg) because
Anna's Archive's own /search is behind a DDoS-Guard JS challenge that plain
HTTP clients cannot pass. Downloads still use Anna's Archive first. No API key
is required and it routinely hits anti-bot pages; if you do not want it, just
drop it from providers.order (the default keeps it out):
[providers]
order = ["slskd", "sabnzbd", "libgen"]
[providers.libgen]
# Optional: skip the slow queue when you have a paid Fast API key.
# secret_key_file = "/run/secrets/annas-fast-api-key"
mirrors = ["annas-archive.gl", "annas-archive.pk", "annas-archive.gd"]
Pick hostnames only (no scheme, no path).
Download tiers, in order:
- Fast API (
/dyn/api/fast_download.json) — used only whenproviders.libgen.secret_key(orANNAS_SECRET_KEY) is set. - IPFS (
/dyn/api/record.jsonfor the CID, thencloudflare-ipfs.comandipfs.iogateways). - Slow queue (
/slow_download/<md5>/0/0). library.loland 5.libgen.lias last-resort alternates.
All five tiers are tried in order; the first one that returns a supported book (EPUB, MOBI, AZW3 or FB2) wins. If a tier surfaces an anti-bot page (Cloudflare interstitial), the chain aborts immediately instead of wasting time on dead tiers.
Search: the Libgen mirror pool is probed on first use and the first host whose search page carries real result rows is cached as the active search mirror; hosts that fail are remembered for a 5-minute cooldown.
Mirror pool: the provider probes each mirrors entry on first use and
caches the first one that answers (used for the Anna's Archive download
tiers). Failed probes are remembered for a
5-minute cooldown so a dead mirror does not slow down every sync. When every
configured mirror is in cooldown, the provider tries GET /slumm on one of
them to discover fresh hostnames and adds them to the pool.
Retries: transient errors (HTTP 5xx, connection drops, timeouts) are retried with exponential backoff (3 attempts, 0.5s base).
Tests
python -m unittest discover -s tests
They cover the optimizer wrapper, candidate scoring and provider parsing, the slskd/SABnzbd clients against stub APIs, the state database, feed generation (including XML escaping and pagination), the JSON API and static SPA serving, and the full sync pipeline (including provider fall-through) with fake providers. The auth flow is covered against a stub OIDC provider (discovery, JWKS, PKCE token exchange, claim/nonce/email checks) and the OPDS Basic challenge.
Limitations
- EPUB, MOBI, AZW3 and FB2 are accepted; anything else is converted to EPUB
with Calibre's
ebook-convert. A book stays failed and is retried while no supported book shows up. - Manually uploaded books are not part of the Hardcover shelf: a sync never removes them, and only they can be deleted from the dashboard.
- No HTTP
Rangesupport for downloads. - RAR-packed Usenet releases are not supported; SABnzbd should unpack to EPUB/MOBI/FB2/zip (its own repair/unpack handles that).
- Anna's Archive mirror scrapes break when Cloudflare serves an interstitial page; the provider surfaces the anti-bot error instead of saving the HTML as a book, and the book is retried on the next sync. A mirror that fails to respond is marked down for 5 minutes; SLUM discovery is attempted only when every configured mirror is in cooldown.
- The database is SQLite and the server is a threaded stdlib HTTP server, which is plenty for one reader and a few hundred books.
License
MIT (see LICENSE). The vendored CrossPoint optimizer is MIT as well; see
NOTICE for its origin, the one local change, and its license text.
