Books don't want an arr stack
/ 9 min read
Table of Contents
I run the *arr stack for video — Sonarr, Radarr, Prowlarr, the usual cast, all sitting in a media namespace on my k3s cluster behind a WireGuard VPN gateway. So when I decided I wanted the same treatment for books, I asked the obvious question:
What’s the Readarr for ebooks?
The build itself was quick — one evening, one complete rip-and-replace. What took a beat longer was understanding it, and that didn’t land until a page of reading statistics was glowing on a Kobo in my hands. It comes down to one sentence:
An arr app monitors. A library collects. Books are the second thing.
Most of this post is me arguing with that sentence until it told me what to build.
The setup
The arr model is a watch-list engine. You tell Radarr “I want this film,” it watches your indexers, grabs the best release when one appears, renames it, files it. The whole thing is built around monitoring a queue of wants against a churn of releases. For film and TV — where you’re tracking a show across seasons, waiting for the next episode — that’s exactly right.
Books, it turns out, don’t churn. A book is published once and then it just… exists. You don’t wait for “the next release of Project Hail Mary.” You either have it or you go get it. That mismatch is the whole story, and I had to walk into it face-first to see it.
Readarr is already dead
First clue, and I almost missed it. Readarr — the literal “Radarr for books” — was retired in 2024. Not abandoned-quietly; explicitly archived by its own team. The reason matters: its metadata pipeline depended on a Goodreads-derived API, and when that source collapsed, the book-identity layer underneath Readarr collapsed with it. No metadata, no “which book is this,” no monitoring.
That should have been the tell. The arr model needs a rich, queryable catalog of every book and its releases to monitor against, and for books that catalog is fragile, commercial, and prone to vanishing. Film has TMDB. Books have a graveyard of half-working APIs.
I did not take the tell. I reached for the next arr-shaped thing.
The first attempt: LazyLibrarian
LazyLibrarian is the spiritual successor — author monitoring, wanted-lists, multiple download providers. I wrote a Kubernetes manifest in the same shape as my Radarr one, dropped it into the media namespace so it inherited the WireGuard egress for free, and Flux reconciled it in. Clean. Behind the VPN. Done.
Then it started fighting me — a quick succession of small refusals, none fatal, all telling.
- SABnzbd refused it. “Unable to talk to sab_nzbd.” The download path was fine; SAB has a
host_whitelistthat rejects any request whoseHostheader isn’t on the list — and the in-cluster service namesabnzbdwasn’t. Add it, restart. - Then categories. “Unknown category [books].” SAB had no
bookscategory. Create it. - Then statuses. Nothing downloaded. Everything defaulted to Skipped; an author imported as Paused. The arr model has a whole state machine — Wanted, Skipped, Snatched, Have — and I had to learn which knob made a book actually get searched.
- Then language filters, metadata sources, ISBN toggles. Each one a small fight.
None of these are bugs. They’re the cost of the model. An arr app is a monitoring engine, and a monitoring engine has a lot of surface area: providers, categories, states, schedules. I was paying that cost for books that, again, don’t churn. I just hadn’t admitted it yet.
The wall: alive on my laptop, dead in the cluster
The breaking point was the download providers. LazyLibrarian’s direct ebook sources — LibGen and friends — simply didn’t work. The provider test failed. The mirror, I figured, was dead.
So I tested it. And this is the part worth keeping, because it’s a debugging lesson that has nothing to do with books.
From my laptop, libgen.is resolved and loaded fine. From inside the pod — the place the downloads actually originate — it was a black hole. Because that pod’s traffic doesn’t leave from my desk. It leaves through a gluetun WireGuard sidecar, out a NordVPN exit in the Netherlands. And from that vantage point, half the shadow-library internet is unreachable. So I ran the test from where the packets really start:
kubectl -n media exec deploy/lazylibrarian -- sh -c ' for h in libgen.is libgen.rs libgen.li annas-archive.org annas-archive.gl; do echo "$h -> $(curl -s -o /dev/null -m 10 -w "%{http_code}" https://$h/)" done'| Host | From the VPN exit |
|---|---|
libgen.is | 000 — dead |
libgen.rs | 000 — dead |
libgen.li | 200 — alive |
annas-archive.org | 000 — dead |
annas-archive.gl | 200 — alive |
The old LibGen fork was globally offline; only the .li family survived. Anna’s .org was blocked from my exit node, but three of its mirrors answered. Everything I needed to know was invisible from my laptop and obvious from the pod.
If a service reaches the internet through a tunnel, debug it from inside the tunnel. The egress point is the only honest vantage point.
But fixing the mirror would just have bought me a working version of the wrong thing. I stopped and asked what I was actually trying to do. I didn’t want to monitor anything. I wanted: see a book, get the book, read the book. That’s not a queue. That’s a search box and a shelf.
The pivot: an ingest folder and a librarian
I tore LazyLibrarian out — deleted the manifest, let Flux prune the deployment, service, ingress and PVC — and replaced it with two single-purpose tools:
- Shelfmark (formerly Calibre-Web-Automated Book Downloader): a search box. It queries Anna’s Archive and drops the file into a folder.
- Calibre-Web-Automated (CWA): the librarian. It watches that folder, imports anything that lands there, converts it, files it into a real Calibre library, and serves it.
The entire architecture is one shared folder:
flowchart TB S["🔎 Shelfmark<br/>search Anna's Archive"] I["📥 ingest folder<br/>NFS · the whole interface"] C["📚 Calibre-Web-Automated<br/>watch · import · KEPUB"] L["🗄️ library<br/>Synology NFS"] K["📖 Kobo<br/>Kobo Sync over WireGuard"] S --> I --> C --> L C --> K
No providers to sync. No categories. No state machine. A book appears in the folder; it ends up on the shelf. That’s the whole contract — and it’s the right primitive for a library, as opposed to a monitoring queue.
A few NFS-specific sharp edges were worth getting right, because the library and ingest folder both live on the Synology over NFS:
NETWORK_SHARE_MODE=trueon CWA — disables SQLite WAL, which corrupts over NFS.CWA_WATCH_MODE=poll— inotify doesn’t fire across an NFS mount, so the folder watcher has to poll. Without this, files land in the ingest folder and nothing ever notices.- Copy, don’t move. Shelfmark copies into the ingest folder rather than moving, which means if I ever do wire up torrents, the original keeps seeding while CWA consumes the copy.
The fork in the road I didn’t take
Shelfmark can also search through Prowlarr — torrents, usenet, the whole indexer world I already run for video. I started wiring it up. Path mappings, download-client categories, the works.
Then I stopped, because I was about to rebuild the exact complexity I’d just escaped. For ebooks, Anna’s Archive alone — direct download, straight into the ingest folder — covers the overwhelming majority of what I’ll ever want, with zero moving parts. Prowlarr earns its keep mostly for audiobooks, which I don’t care about yet.
So I went direct-only and turned Prowlarr back off. The whole point of the redesign was less machinery; bolting the machinery back on would have missed it. If I want audiobooks later, both download clients already save under the same /data mount, so it’s a cheap addition — later, when there’s an actual need.
The last mile: a Kobo
A library you read in a browser is only half a win. The real test was getting books onto my Kobo without USB-sideloading like it’s 2012.
CWA inherits Calibre-Web’s native Kobo Sync — the Kobo talks to my server using its own sync protocol, so my library shows up exactly like the official Kobo store, reading position and all. Most guides for this spend half their length fighting TLS, because Kobos are picky about certificates. Mine just worked on the first try, and the reason is boring and lovely: my cluster issues Let’s Encrypt certs via cert-manager, so calibre.huislab.app is already publicly trusted. No cert hacks. That’s the kind of payoff that only shows up months after you do the unglamorous infrastructure work.
Two gotchas worth flagging for anyone copying this:
- External Port. CWA’s Kobo setting defaulted to
8083— the container’s port. But the device reaches the server through Traefik on443. Leave it at8083and CWA hands the Kobo sync URLs it can’t connect to. Set it to443. - One endpoint, not two. You point the Kobo at the server by editing
[OneStoreServices]inKobo eReader.conf:There’s already an[OneStoreServices]api_endpoint=https://calibre.huislab.app/kobo/<your-sync-token>api_endpointline there pointing at the real Kobo store. You replace it — you don’t add a second one. Two keys in one section is undefined behaviour. (And because CWA proxies unknown requests back to Kobo, you don’t even lose the official store.)
I synced, opened a book, and there it was: 8% read, 23 minutes left in this chapter, 11.4 hours left in the book. Those time-based stats are the tell that CWA converted the EPUB to KEPUB on the way out — Kobo’s enhanced format — which it did automatically because the kepubify binary was present. Plain EPUB only gives you a flat percentage. This is the full native experience.
And because the whole thing lives behind WireGuard, it follows me. My travel router tunnels home; on a foreign network the Kobo resolves calibre.huislab.app through my home DNS over the tunnel and syncs as if it were on the couch. Internal-only, everywhere.
What I’d tell myself on day one
- Friction is a signal, not a tax. The pile of whitelists, categories and status flags wasn’t me holding it wrong — it was the arr model being the wrong shape for the job. When every step fights you, question the model before you grind through it.
- The right primitive for a library is an ingest folder. Not a monitoring queue. “A file appears, it gets filed” is a contract with almost no surface area, and almost no surface area is the goal.
- Debug from the egress point. A service that reaches the world through a tunnel must be tested from inside the tunnel. My laptop’s view of the internet was a comfortable lie.
- Simpler kept winning. Direct-only beat the indexer maze. Removing LazyLibrarian removed more problems than it created. The best version of this build is the one with the fewest things in it.
There is no Readarr, and after this I’m glad. Books didn’t want an arr stack. They wanted a folder and a librarian — and a couch that follows me onto the train.