- JavaScript 83.8%
- CSS 10.4%
- HTML 3.6%
- Shell 2.2%
| deploy | ||
| html | ||
| src | ||
| .gitignore | ||
| CHANGELOG.md | ||
| env.example | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| prowlar-api.json | ||
| README.md | ||
BookSearch
A small self-hosted web app that searches Prowlarr for ebooks and sends matches straight to a qBittorrent watch folder, then automatically copies finished downloads into a library folder (e.g. Kavita).
It's designed to sit alongside an existing site on the same nginx server block (e.g. a ReadMeABook instance) without interfering with it — it only adds new location blocks, never touching the existing one.
How it works
- Search — the UI queries Prowlarr, scoped server-side to a single ebook category, and shows results as a vertical list (title, author — parsed from the release title when the indexer embeds it as a "... by AUTHOR" suffix — indexer, size, age, seeders), capped at the top 8 results with the first marked as suggested.
- Request — clicking Download fetches the chosen release's torrent file from Prowlarr server-side and writes it into
WATCHED_DIR, where qBittorrent picks it up automatically. - Import — a background poll loop watches
DOWNLOAD_DIRfor finished downloads and copies the ebook file intoLIBRARY_DIR, named using theLIBRARY_DIR_FORMATtemplate, for your library manager to pick up on its next scan. Naming prefers the{title, author}already parsed from the release at request time (matched to the download via the torrent's own internal name) over re-deriving it from the downloaded folder name, which is frequently just the torrent's bare internal name with no usable title/author shape.
The Prowlarr API key never reaches the browser, and the app has no login of its own — it's meant to run behind an nginx location block with HTTP Basic Auth, bound to 127.0.0.1 only.
Requirements
- Node.js 22+
- A running Prowlarr instance with at least one ebook-capable indexer configured
- qBittorrent with a watched folder for adding torrents automatically
- A library manager that periodically rescans a folder (Kavita, Calibre-Web, etc.) — optional, the importer just copies files into a folder
Configuration
Copy env.example to .env and fill in real values. .env is gitignored — never commit it.
| Key | Description |
|---|---|
PROWLARR_URL |
Base URL of your Prowlarr instance |
API_TOKEN |
Prowlarr API key |
WEB_DIR_BASE |
Deploy target for the app (backend + config) |
WEB_DIR_HTML |
Deploy target for the static frontend (served directly by nginx) |
PORT |
Port the Node app listens on (default 3031, 127.0.0.1 only) |
CATEGORY_ID |
Newznab/Torznab category to search (default 7020, Books/EBook) |
LOG_DIR |
Directory for the app's log file (default /var/log/booksearch) |
WATCHED_DIR |
qBittorrent's watched folder — fetched .torrent files are written here |
DOWNLOAD_DIR |
qBittorrent's completed-download folder — polled for finished downloads |
LIBRARY_DIR |
Destination library folder |
LIBRARY_DIR_FORMAT |
Folder naming template for imports, using {TITLE}/{AUTHOR} tokens |
IMPORTER_POLL_INTERVAL_MS |
How often the importer checks DOWNLOAD_DIR (default 60000) |
Local development
npm install
npm start
The server binds to 127.0.0.1:$PORT only — there's no auth at this layer (that's nginx's job in production), so test it directly:
curl http://127.0.0.1:3031/api/health
curl "http://127.0.0.1:3031/api/search?q=some+title"
API
| Route | Description |
|---|---|
GET /api/search?q= |
Search Prowlarr, returns simplified results with an opaque id per release |
POST /api/request { id } |
Fetch that release's torrent file and write it into WATCHED_DIR |
GET /api/health |
Liveness check |
Deployment
Runs as a systemd service behind nginx, on the same host/subdomain as an existing site.
deploy/booksearch.service— systemd unit template. Fill in<YOUR_USER>,<ABSOLUTE_PATH_TO_NODE>, and theReadWritePathsplaceholders for your host before installing — left as placeholders on purpose so this tracked file doesn't bake in machine-specific paths. Runs as a real login account (User=<YOUR_USER>), not a dedicated system user — pick a user on your host that already ownsWATCHED_DIR/LIBRARY_DIRand can reach your Node install; a locked-down system user needsProtectHome/directory permissions that get in the way of an nvm-managed Node binary under/home.deploy/booksearch.logrotate— logrotate config for the file-based log (copy to/etc/logrotate.d/)deploy/deploy.sh— syncs this repo intoWEB_DIR_BASE/WEB_DIR_HTML(per.env) and restarts the service
nginx configuration
The app has no authentication or TLS of its own — nginx is the only thing standing between it and the internet. Add an upstream and two new location blocks to your existing server block; do not touch its current location / (that's whatever site you're already hosting on the subdomain).
upstream BOOKSEARCH {
server 127.0.0.1:3031; # match PORT in .env
}
location = /booksearch {
return 301 /booksearch/$is_args$args;
}
location /booksearch/api/ {
auth_basic "BookSearch";
auth_basic_user_file /etc/nginx/.htpasswd_booksearch;
proxy_pass http://BOOKSEARCH/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
proxy_http_version 1.1;
proxy_connect_timeout 10s;
proxy_read_timeout 75s; # > the backend's own download timeout
client_max_body_size 64k; # POST body is just {id}
}
location /booksearch/ {
alias /var/www/booksearch/html/; # match WEB_DIR_HTML in .env
index index.html;
auth_basic "BookSearch";
auth_basic_user_file /etc/nginx/.htpasswd_booksearch;
}
A few details that are easy to get wrong:
- The bare
/booksearchredirect is required, not cosmetic.alias-based prefix locations only match requests that include the trailing slash — a request to/booksearch(no slash) doesn't matchlocation /booksearch/at all and falls through to whateverlocation /on that subdomain proxies to, not this app. Thelocation = /booksearch { return 301 /booksearch/; }exact-match block catches that case and 301s to the trailing-slash form (preserving any query string via$is_args$args). alias, notroot, on the static block —aliasreplaces the whole matched/booksearch/prefix;rootwould look for a nonexistent.../html/booksearch/...subfolder and 404 everything.- Keep the trailing
/api/onproxy_pass— since both thelocationand theproxy_passURL end in/, nginx strips/booksearch/api/and forwards the rest to/api/...on the Node app, matching its routes. - Same
auth_basic_user_fileand realm string on both blocks — otherwise the browser's cached Basic Auth credentials from loading the page won't reattach to itsfetch()calls to the API. - Create the credentials file once:
sudo htpasswd -c /etc/nginx/.htpasswd_booksearch <user>. - Validate and reload before it's live:
sudo nginx -t && sudo systemctl reload nginx.
Project layout
src/ Express backend + library importer (one process, one systemd unit)
html/ Static frontend, served directly by nginx (no build step)
deploy/ systemd unit, logrotate config, deploy script
License
GPLv3 — see LICENSE.
Copyright (C) 2026 kurt
Acknowledgments
Built with the assistance of Claude (Sonnet 5, model claude-sonnet-5) via Claude Code.