No description
  • Python 81.4%
  • JavaScript 6.7%
  • CSS 6.4%
  • Nix 5.3%
  • HTML 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-14 14:58:01 +02:00
doc Add screenshot 2026-09-30 07:14:42 +02:00
examples Accept MOBI, AZW3 and FB2, converting them to EPUB 2026-10-14 11:23:59 +02:00
nix Accept MOBI, AZW3 and FB2, converting them to EPUB 2026-10-14 11:23:59 +02:00
src/leseratte Give Calibre an input name that matches the bytes 2026-10-14 14:58:01 +02:00
tests Give Calibre an input name that matches the bytes 2026-10-14 14:58:01 +02:00
.gitignore Add configuration loading with secret-file support 2026-09-28 16:37:34 +02:00
flake.lock Package leseratte for NixOS with a declarative module 2026-09-28 16:37:34 +02:00
flake.nix Accept MOBI, AZW3 and FB2, converting them to EPUB 2026-10-14 11:23:59 +02:00
LICENSE Document the pipeline, options, endpoints and third-party code 2026-09-28 16:37:34 +02:00
NOTICE Add preact frontend 2026-09-29 08:55:22 +02:00
pyproject.toml Refactor frontend design 2026-09-29 18:39:41 +02:00
README.md Accept MOBI, AZW3 and FB2, converting them to EPUB 2026-10-14 11:23:59 +02:00

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!

Screenshot

For every book it does:

  1. Query — GraphQL request against the Hardcover API for the books on your Want-to-Read shelf (status_id = 1).
  2. 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 /search sits behind a DDoS-Guard JS challenge no plain HTTP client can pass); downloads then go through the mirror's slow queue, library.lol, or libgen.li. No account required, but flaky and prone to anti-bot pages — only useful when slskd/Usenet both came up empty.
  3. Convert — anything that is not already an EPUB is handed to Calibre's ebook-convert (MOBI, AZW3, FB2 → EPUB). The conversion backend is configurable via converter.binary/converter.timeout.
  4. Optimize — run the EPUB through the CrossPoint reader optimizer (crosspoint_reader/optimizer.py from crosspoint-reader/calibre-plugins, vendored under src/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.
  5. 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.issuer and auth.oidc.client_id and the dashboard, /book/*, /api/*, /sync and the /auth/* routes require a signed session cookie. auth.oidc.scopes defaults to openid email profile; auth.oidc.allowed_emails restricts 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* answers 401 with a WWW-Authenticate: Basic challenge 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:

  1. Fast API (/dyn/api/fast_download.json) — used only when providers.libgen.secret_key (or ANNAS_SECRET_KEY) is set.
  2. IPFS (/dyn/api/record.json for the CID, then cloudflare-ipfs.com and ipfs.io gateways).
  3. Slow queue (/slow_download/<md5>/0/0).
  4. library.lol and 5. libgen.li as 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 Range support 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.